ビジネスロジックの置き場所 — サービスクラスとサービスコンテナ
前章で入力の検証が終わりました。この章では、その先の処理を書きます。
素直に書くとコントローラが膨らみます。膨らんだままでも動きますが、テストが書きにくくなり、同じ処理を別の入口 (Artisan コマンドやキュー) から呼びたくなったときに困ります。この章では、何をどこに置くかの判断基準を作ります。
まず素朴に書く
第5章の StoreOrderRequest を受けて、注文を作ります。
public function store(StoreOrderRequest $request)
{
$validated = $request->validated();
$total = 0;
foreach ($validated['items'] as $item) {
$stock = Stock::where('product_id', $item['product_id'])->first();
if ($stock->quantity < $item['quantity']) {
return response()->json(['message' => '在庫が足りません'], 409);
}
$product = Product::find($item['product_id']);
$total += $product->price * $item['quantity'];
}
$order = Order::create([
'customer_email' => $validated['customer_email'],
'status' => OrderStatus::Pending,
'total_amount' => $total,
]);
foreach ($validated['items'] as $item) {
$product = Product::find($item['product_id']);
$order->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'unit_price' => $product->price,
]);
Stock::where('product_id', $item['product_id'])
->decrement('quantity', $item['quantity']);
}
Mail::raw('ご注文ありがとうございます', function ($message) use ($validated) {
$message->to($validated['customer_email'])->subject('注文を受け付けました');
});
return response()->json($order, 201);
}
動きます。第1章で見せたコードと同じ構造です。
何が入っているかを数える
このメソッドが扱っていることを並べます。
- 在庫が足りるかの確認
- 合計金額の計算
- 注文レコードの作成
- 注文明細の作成
- 在庫の減算
- 確認メールの送信
- HTTP レスポンスの組み立て
7 つあります。 この数え方が、切り出しの出発点です。
「コントローラが長いから短くする」ではありません。長さは結果であって原因ではありません。原因は、変更の理由が異なる処理が 1 つのメソッドに同居していることです。
- メール本文を変えたい → 6 だけが変わる
- 在庫の持ち方を変えたい → 1 と 5 が変わる
- レスポンスに項目を足したい → 7 だけが変わる
同居していると、メール本文を直すために注文作成のコードを開くことになります。開いた以上は読む必要があり、読めば誤って触るリスクが生まれます。
「変更の理由が 1 つに絞れているか」は単一責任の原則の言い換えです。原則そのものは PHPクラス設計ガイド が扱っています。この連載では、原則を Laravel のどのファイルに落とすかに集中します。
Eloquent モデルに書くもの・書かないもの
切り出し先を考える前に、モデルの役割を決めておきます。ここが曖昧だと、コントローラから追い出した処理がモデルに溜まって Fat Model になります。
モデルが持つのは、自分自身のデータの表現と解釈です。
class Order extends Model
{
/**
* 明細から計算した金額と、保存されている合計が一致するか
*/
public function isAmountConsistent(): bool
{
return $this->items->sum(fn ($item) => $item->unit_price * $item->quantity)
=== $this->total_amount;
}
/**
* キャンセルできる状態か
*/
public function isCancellable(): bool
{
return $this->status === OrderStatus::Pending;
}
}
どちらも Order が持つデータだけで判断できます。他のテーブルを見に行きませんし、外部と通信しません。
class Order extends Model
{
public function place(array $items): void
{
// 在庫テーブルを見て、減らして、メールを送る
}
}
これは Order 単体の話ではなく、Order と Stock、そしてメール送信を調整する話です。調整役はモデルの外に置きます。
判断に迷ったら、「このメソッドは他のモデルやサービスを触るか」を見てください。触るなら、それはモデルの仕事ではありません。
サービスクラスへ切り出す
調整役のクラスを作ります。
<?php
namespace App\Services;
use App\Enums\OrderStatus;
use App\Models\Order;
use App\Models\Product;
class OrderPlacementService
{
public function __construct(
private StockChecker $stockChecker,
) {}
/**
* @param array<int, array{product_id: int, quantity: int}> $items
*/
public function place(string $customerEmail, array $items): Order
{
$this->stockChecker->assertEnough($items);
$products = Product::findMany(array_column($items, 'product_id'))->keyBy('id');
$total = 0;
foreach ($items as $item) {
$total += $products[$item['product_id']]->price * $item['quantity'];
}
$order = Order::create([
'customer_email' => $customerEmail,
'status' => OrderStatus::Pending,
'total_amount' => $total,
]);
foreach ($items as $item) {
$order->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'unit_price' => $products[$item['product_id']]->price,
]);
}
$this->stockChecker->decrease($items);
return $order;
}
}
コントローラはこうなります。
public function store(StoreOrderRequest $request, OrderPlacementService $service)
{
$validated = $request->validated();
$order = $service->place($validated['customer_email'], $validated['items']);
return response()->json($order, 201);
}
コントローラに残ったのは、HTTP の入口と出口だけになりました。リクエストから値を取り出し、処理を呼び、レスポンスを組み立てる。この 3 つは HTTP に固有の仕事なので、コントローラの本来の役割です。
ファイルを分けただけにしない
切り出しで失敗する典型は、処理をそのまま別ファイルにコピーすることです。
class OrderService
{
public function place(...) {}
public function cancel(...) {}
public function ship(...) {}
public function refund(...) {}
public function exportCsv(...) {}
public function sendReminder(...) {}
}
コントローラは短くなりますが、問題は場所を移しただけです。CSV 出力を直すために、注文処理のファイルを開くことになります。
クラス名が処理の内容を説明できているかが目安です。OrderPlacementService は「注文を確定する」という 1 つのことを表します。OrderService は「注文に関する何か」で、何でも入ります。
上の例なら、OrderPlacementService / OrderCancellationService / SalesReportExporter のように分けます。
在庫の確認と減算は別クラスへ
StockChecker を分けたのは、在庫の扱いが注文以外からも使われるからです。管理画面からの在庫調整、返品による在庫の戻し、定期的な棚卸し。どれも同じロジックを必要とします。
<?php
namespace App\Services;
use App\Exceptions\InsufficientStockException;
use App\Models\Stock;
class StockChecker
{
/**
* @param array<int, array{product_id: int, quantity: int}> $items
*
* @throws InsufficientStockException
*/
public function assertEnough(array $items): void
{
$stocks = Stock::whereIn('product_id', array_column($items, 'product_id'))
->get()
->keyBy('product_id');
foreach ($items as $item) {
$available = $stocks->get($item['product_id'])?->quantity ?? 0;
if ($available < $item['quantity']) {
throw new InsufficientStockException(
productId: $item['product_id'],
requested: $item['quantity'],
available: $available,
);
}
}
}
/**
* @param array<int, array{product_id: int, quantity: int}> $items
*/
public function decrease(array $items): void
{
foreach ($items as $item) {
Stock::where('product_id', $item['product_id'])
->decrement('quantity', $item['quantity']);
}
}
}
InsufficientStockException は第9章で作ります。ここでは「在庫が足りなければ例外を投げる」という形だけ掴んでください。
2 つ、書き方の理由があります。
whereIn で 1 回にまとめています。 ループの中で Stock::where(...)->first() を呼ぶと、明細の数だけクエリが飛びます。第8章で扱う N+1 と同じ構造です。
?->quantity ?? 0 で在庫行が無い場合を扱っています。 ?-> は PHP 8 のヌル安全演算子で、左側が null ならメソッド呼び出しやプロパティ参照をせずに null を返します。第3章のスキーマは「商品には必ず在庫行がある」を強制していないので、在庫行が無い商品が存在しえます。確認せずに $stock->quantity と書くと、その商品を注文した瞬間に 500 になります。在庫行が無いなら在庫 0 として扱い、409 を返すのが正しい振る舞いです。
assertEnough で確認してから decrease で減らすまでのあいだに、別のリクエストが同じ在庫を持っていく可能性があります。第5章で触れた問題がここに残っています。第14章でトランザクションと行ロックを入れて塞ぎます。
サービスコンテナが依存を組み立てる
上のコントローラで、OrderPlacementService を new していないことに気づいたでしょうか。
public function store(StoreOrderRequest $request, OrderPlacementService $service)
引数に型を書いただけです。それでも $service にはインスタンスが渡ってきます。
これは第2章で触れたサービスコンテナの働きです。Laravel はコントローラのメソッドを呼ぶ前に、引数の型を見て必要なクラスを組み立てます。OrderPlacementService のコンストラクタが StockChecker を要求していれば、それも作って渡します。
公式ドキュメントはこれを zero configuration resolution と呼んでいます。依存を持たないクラス、または具象クラスにだけ依存するクラスは、設定を一切書かずに解決できます1。コントローラだけでなく、イベントリスナー・ミドルウェア・キューに載せるジョブでも同じように働きます。
何が嬉しいのか
new で書いた場合と比べます。
public function store(StoreOrderRequest $request)
{
$service = new OrderPlacementService(new StockChecker());
// ...
}
このコードには 2 つの問題があります。
依存が増えると呼び出し側が壊れます。 OrderPlacementService がロガーも必要になったら、new している箇所をすべて直すことになります。コンテナに任せていれば、コンストラクタに引数を足すだけで済みます。
テストで差し替えられません。 第10章でテストを書きますが、そのとき「在庫チェックだけを偽物に差し替えて、注文作成のロジックだけを試す」ことができません。new がメソッドの中に埋まっているためです。
インターフェースを渡すには結び付けが要る
自動解決が効くのは、型が具象クラスのときです。インターフェースを型宣言すると、Laravel はどの実装を渡せばよいか分かりません。
interface StockCheckerInterface
{
public function assertEnough(array $items): void;
public function decrease(array $items): void;
}
class OrderPlacementService
{
public function __construct(
private StockCheckerInterface $stockChecker, // どれを渡せばいい?
) {}
}
この対応を教えるのが bind です。第2章で見たとおり、サービスプロバイダの register メソッドに書きます。
use App\Services\StockCheckerInterface;
use App\Services\EloquentStockChecker;
public function register(): void
{
$this->app->bind(StockCheckerInterface::class, EloquentStockChecker::class);
}
これで StockCheckerInterface を型宣言した箇所には EloquentStockChecker が渡ります1。
公式ドキュメントは、コンテナを手で操作する必要がある場面を 2 つに絞っています。インターフェースに実装を結び付けるときと、Laravel のパッケージを作るときです1。それ以外は自動解決に任せられます。
インターフェースを作るかどうか
インターフェースを挟むと、実装を差し替えられます。在庫を外部の在庫管理システムから取るようになったとき、ApiStockChecker を作って bind を 1 行変えるだけで済みます。
一方、実装が 1 つしかなく差し替える予定もないなら、インターフェースは登録の手間を増やすだけです。具象クラスをそのまま型宣言すれば、bind は要りません。
この連載では、外部サービスとやり取りするクラス (決済・メール送信) にはインターフェースを置き、アプリケーション内部で完結するクラスには置きません。前者はテストで差し替える必要が確実にあるためです。
singleton でインスタンスを共有する
bind で登録したクラスは、要求されるたびに新しく作られます。1 リクエストの中で 3 箇所から要求すれば 3 個できます。
作るコストが高いもの (接続を張る、設定ファイルを読む) は、1 個を共有します。
public function register(): void
{
$this->app->singleton(PaymentGatewayClient::class);
}
AppServiceProvider にはプロパティで書く形もあります2。
public $bindings = [
StockCheckerInterface::class => EloquentStockChecker::class,
];
public $singletons = [
PaymentGatewayClient::class => StripeClient::class,
];
登録が単純な対応付けだけなら、こちらのほうが見通しがよくなります。
本章で扱わないこと
この連載は Laravel の標準構成のまま整理します。次のものには踏み込みません。
- ユースケース層 (アプリケーションサービス) — サービスクラスをさらに「1 クラス 1 操作」に分け、コマンドオブジェクトで入力を渡す設計。ユースケース層の設計 が扱います
- リポジトリパターン — Eloquent への依存をインターフェースの裏に隠し、ドメインモデルと永続化を分離する設計。リポジトリパターン が扱います
- contextual binding — 同じインターフェースに対して、注入先のクラスごとに違う実装を渡す仕組み。
when()->needs()->give()の形で書けます1
3 つとも、アプリケーションが育ってから必要になるものです。この章の分け方で手狭になったと感じたときが、検討する時期です。
本番で効く注意点
singleton にリクエスト固有の状態を持たせない
通常の Web リクエストでは、処理が終わるとプロセスの状態が捨てられます。しかしキューワーカーは 1 つのプロセスが動き続けます。第16章で扱いますが、ワーカーはジョブを 1 つ処理しても終了せず、次のジョブを取りに行きます。
このとき、singleton で登録したクラスに前のジョブの情報が残っていると、次のジョブがそれを読みます。
class OrderContext
{
private ?Order $currentOrder = null;
public function setCurrentOrder(Order $order): void
{
$this->currentOrder = $order;
}
}
このクラスを singleton で登録すると、ワーカーが前に処理した注文が次のジョブから見えます。別の顧客の注文が混ざるので、被害は大きくなります。
singleton にしてよいのは、状態を持たないものか、リクエストをまたいでも意味が変わらない状態を持つもの (設定値・接続) だけです。
ファサードを直接呼ぶとテストで差し替えづらい
Mail::raw() や Cache::get() のような書き方をファサードと呼びます。短く書けますが、サービスクラスの中で使うと、そのクラスのコンストラクタを見ても何に依存しているか分かりません。
class OrderPlacementService
{
public function __construct(
private StockChecker $stockChecker,
) {}
public function place(...): Order
{
// ...
Mail::raw(...); // コンストラクタに現れない依存
}
}
Laravel のファサードはテストで差し替える仕組みを持っているので、テストが書けなくなるわけではありません。第15章で Notification::fake() と Mail::fake() を使います。それでも、コンストラクタの引数がそのクラスの依存の一覧になっているほうが、読む人には親切です。
なお、この章のコードでもメール送信はまだ place() の中にありません。第15章でイベントとして切り出します。
切り出したあとに凝集度を確かめる
切り出しは 1 回で終わりません。機能を足すたびに、そのクラスが扱うことが増えます。
半年後に OrderPlacementService が 500 行になっていたら、もう一度数えてください。「変更の理由」が複数あれば、また分けるときです。
まとめ
- 切り出しの基準は長さでなく、変更の理由が複数あるかどうか
- モデルに置くのは自分のデータの表現と解釈まで。他のモデルやサービスを触る処理は外へ
- コントローラに残すのは HTTP の入口と出口だけ
- 引数に型を書くだけでサービスコンテナが依存を組み立てる。具象クラスなら設定は不要
- インターフェースを型宣言したときだけ
bindが要る singletonはリクエストをまたいで生き残る。処理中のデータを持たせない
次に読む
次章 出力の設計 — API Resource でレスポンスの形を決める では、いま response()->json($order, 201) で返しているレスポンスの形を設計します。モデルをそのまま返すのをやめ、何を公開するかをコードとして固定します。第1章で挙げた「カラムを足した瞬間に内部情報が漏れる」問題を、ここで塞ぎます。
練習問題
次のサービスクラスは切り出しに失敗しています。何が問題か、どう分けるかを説明してください
class OrderService
{
public function place(string $email, array $items): Order { /* ... */ }
public function cancel(Order $order): void { /* ... */ }
public function generateMonthlyReport(int $year, int $month): string { /* ... */ }
public function syncWithWarehouse(): void { /* ... */ }
}
解答例
4 つのメソッドは、変更の理由がすべて違います。
place/cancel— 注文のビジネスルールが変わったときgenerateMonthlyReport— 経理が欲しい項目が変わったときsyncWithWarehouse— 倉庫システムの API が変わったとき
倉庫システムの仕様変更に対応するために、注文確定のコードが入ったファイルを開くことになります。開けば読みますし、読めば触るリスクが生まれます。
クラス名が「注文に関する何か」を表しているのが原因です。OrderService という名前は、あらゆる処理を受け入れてしまいます。
分け方の一例です。
OrderPlacementService— 注文の確定OrderCancellationService— 注文のキャンセルMonthlySalesReporter— 月次レポートの生成WarehouseSynchronizer— 倉庫システムとの同期
place と cancel を同じクラスに残すか分けるかは、判断が分かれます。同じデータと同じルールを共有しているなら、同居していても変更の理由は 1 つと見なせます。キャンセル可能な期間の判定が注文確定のロジックと絡むなら、同居させたほうが読みやすいこともあります。
次のコードは、Web からのリクエストでは正しく動きますが、キューワーカーから呼ばれると別の顧客のデータが混ざります。原因を説明してください
// app/Providers/AppServiceProvider.php
$this->app->singleton(InvoiceNumberGenerator::class);
// app/Services/InvoiceNumberGenerator.php
class InvoiceNumberGenerator
{
private int $sequence = 0;
private ?string $customerCode = null;
public function forCustomer(string $code): self
{
$this->customerCode = $code;
return $this;
}
public function next(): string
{
$this->sequence++;
return sprintf('%s-%04d', $this->customerCode, $this->sequence);
}
}
解答例
singleton で登録されているため、コンテナは 1 つのインスタンスを作って以降それを返し続けます。
Web リクエストではプロセスが 1 リクエストで終わるので問題が表面化しません。リクエストが終われば PHP のプロセスも状態も捨てられ、次のリクエストは新しいインスタンスから始まります。
キューワーカーは違います。ワーカーは起動したまま、ジョブを次々に処理します。第16章で扱いますが、これは意図された動作で、毎回フレームワークを起動し直さないぶん速くなります。
その結果、こうなります。
ジョブ 1 (顧客 A): forCustomer('A') → next() → "A-0001"
ジョブ 2 (顧客 B): next() を呼ぶ前に forCustomer('B') を呼び忘れると "A-0002"
ジョブ 3 (顧客 C): forCustomer('C') → next() → "C-0003" ← 連番が引き継がれている
$customerCode は前のジョブの値が残り、$sequence はジョブをまたいで増え続けます。請求書番号としてはどちらも誤りです。
原因は singleton の使い方です。このクラスは処理ごとに変わる状態を持っているので、共有してはいけません。bind にする (要求のたびに新しく作る) か、そもそも状態を持たない設計にします。
public function generate(string $customerCode, int $sequence): string
{
return sprintf('%s-%04d', $customerCode, $sequence);
}
引数で受け取ってその場で返す形なら、共有しても問題は起きません。