実践:注文システムの実装 — 全層を貫く実装例
実装の全体像
本章では、これまで学んだ内容を統合して、注文システムを実装します。
本章は既存のクラスを組み合わせて動かす例なので、次のクラスは本章では定義せず、所有章で定義したものを使います。
ConfirmOrderUseCase/ConfirmOrderCommand— 第11章OrderPolicy— 第12章(Roleenum とUserモデルの$castsは第18章)OrderRepositoryInterface/OrderModel/OrderLineModel/RepositoryServiceProvider— 第13章- 例外クラス(
InvalidOrderStateException/OrderAlreadyConfirmedException) — 第16章 - 値オブジェクト(
OrderId/OrderLine/Moneyなど)とドメインイベント(HasDomainEvents/OrderConfirmedなど) — 第5章・第6章・第9章
本章の実装とテストのフェンスは、先頭のパスコメント → namespace → use の順で書いています。他章から持ってくるクラスは、その章のパスコメント(パスコメントの無い章は次の「ディレクトリ構成」)が示す場所に置いてください。名前空間は PSR-4 でそのパスから決まります。
[実装する機能]
1. 注文作成(POST /orders)
2. 注文確定(POST /orders/{id}/confirm)
[関連する集約]
- Order集約(Order, OrderLine, ShippingAddress)
在庫の確認は第11章の ConfirmOrderUseCase が担うので、本章では実装しない
ディレクトリ構成
app/
├── Domain/
│ ├── Order/
│ │ ├── Order.php
│ │ ├── OrderId.php
│ │ ├── Event/
│ │ │ ├── OrderCancelled.php
│ │ │ ├── OrderConfirmed.php
│ │ │ └── OrderShipped.php
│ │ ├── Exception/
│ │ │ ├── InvalidOrderStateException.php
│ │ │ └── OrderAlreadyConfirmedException.php
│ │ ├── OrderLine.php
│ │ ├── OrderLineId.php
│ │ ├── OrderStatus.php
│ │ ├── ProductId.php
│ │ ├── ShippingAddress.php
│ │ └── OrderRepositoryInterface.php # 定義は第13章
│ ├── Shared/
│ │ ├── DomainEventDispatcherInterface.php
│ │ ├── HasDomainEvents.php
│ │ └── Money.php
│ └── User/
│ └── UserId.php
│
├── Application/
│ └── UseCase/
│ └── Order/
│ ├── CreateOrderUseCase.php
│ ├── CreateOrderCommand.php
│ ├── ConfirmOrderUseCase.php # 定義は第11章
│ └── ConfirmOrderCommand.php # 定義は第11章
│
├── Infrastructure/
│ ├── Eloquent/
│ │ ├── OrderModel.php # 定義は第13章
│ │ └── OrderLineModel.php # 定義は第13章
│ ├── Event/
│ │ └── LaravelDomainEventDispatcher.php
│ ├── Repository/
│ │ └── EloquentOrderRepository.php
│ └── Provider/
│ └── RepositoryServiceProvider.php
│
├── Policies/
│ └── OrderPolicy.php # 定義は第12章
│
├── Providers/
│ └── AppServiceProvider.php # boot() は第9章とも共用
│
└── Http/
├── Controllers/
│ └── OrderController.php
└── Requests/
└── CreateOrderRequest.php
データの流れ
各層の実装
プレゼンテーション層
プレゼンテーション層の責務はHTTPリクエストの受け取りとレスポンスの返却です。ビジネスロジックは一切持たず、UseCaseに処理を委譲します。
// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;
use App\Application\UseCase\Order\ConfirmOrderCommand;
use App\Application\UseCase\Order\ConfirmOrderUseCase;
use App\Application\UseCase\Order\CreateOrderUseCase;
use App\Http\Requests\CreateOrderRequest;
use App\Infrastructure\Eloquent\OrderModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
final class OrderController extends Controller
{
/**
* コンストラクタインジェクション
*
* Laravelのサービスコンテナが自動的にUseCaseをインスタンス化して渡してくれます。
* これにより、テスト時にモックオブジェクトを差し替えやすくなります。
*/
public function __construct(
private readonly CreateOrderUseCase $createOrderUseCase,
private readonly ConfirmOrderUseCase $confirmOrderUseCase,
) {}
/**
* 注文を作成する
*
* Controllerの責務:
* 1. バリデーション(FormRequestに委譲)
* 2. HTTPリクエストをCommandオブジェクトに変換
* 3. UseCaseを実行
* 4. 結果をHTTPレスポンスに変換
*
* Controllerが「やらない」こと:
* - ビジネスロジック(UseCaseの責務)
* - データベース操作(Repositoryの責務)
* - ドメインオブジェクトの生成(Domainの責務)
*/
public function store(CreateOrderRequest $request): JsonResponse
{
// FormRequestで既にバリデーション済み
// toCommand()でHTTPリクエストをCommandオブジェクトに変換
$orderId = $this->createOrderUseCase->execute($request->toCommand());
// UseCaseの実行結果をHTTPレスポンスに変換
// 201 Createdステータスコードを返すことで、リソースの作成を示す
return response()->json(['orderId' => $orderId->value()], 201);
}
/**
* 注文を確定する
*
* シンプルなケースではFormRequestを使わず、直接Commandを生成してもOK
*/
public function confirm(int $id): JsonResponse
{
// Policyに渡す対象を取るためのfindOrFail()。存在チェックそのものはUseCaseが担う(第12章)
$order = OrderModel::findOrFail($id);
// 他人の注文を確定できないようにする。所有者の判定はOrderPolicy::confirm()(第12章)
Gate::authorize('confirm', $order);
$this->confirmOrderUseCase->execute(new ConfirmOrderCommand($id));
return response()->json(['message' => '注文を確定しました']);
}
}
// app/Http/Requests/CreateOrderRequest.php
namespace App\Http\Requests;
use App\Application\UseCase\Order\CreateOrderCommand;
use Illuminate\Foundation\Http\FormRequest;
final class CreateOrderRequest extends FormRequest
{
/**
* バリデーションルール
*
* ここで定義するのは「HTTPリクエストとしての妥当性」のみ。
* ビジネスルール(例:在庫チェック、注文上限)はドメイン層で検証します。
*
* 責任の分離:
* - FormRequest: HTTP層での型・形式のバリデーション
* - Domain: ビジネスルールのバリデーション
*
* 第12章のCreateOrderRequestに対して、このクラスは次の4点を持ちません。
* - rules()の 'shipping' => ['required', 'array'](配送先オブジェクト自体の存在チェック)
* - rules()の 'items.*.quantity' の max:100(1商品あたりの最大数量)
* - authorize()(Laravelの既定がtrueなので、無くても写経したコードは動く)
* - messages()(日本語のエラーメッセージ。max:100が無いので対応するメッセージも要らない)
* 置き場も第12章とは別(第12章はRequests/Order/の下)なので、両方を写すとクラスが2つできる
*/
public function rules(): array
{
return [
// 住所情報のバリデーション
'shipping.prefecture' => ['required', 'string', 'max:255'],
'shipping.city' => ['required', 'string', 'max:255'],
'shipping.street' => ['required', 'string', 'max:255'],
// 注文明細のバリデーション
'items' => ['required', 'array', 'min:1'], // 最低1つの商品が必要
'items.*.productId' => ['required', 'integer', 'min:1'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'items.*.unitPrice' => ['required', 'integer', 'min:0'], // 円単位で送信される前提
];
}
/**
* HTTPリクエストをCommandオブジェクトに変換
*
* この変換により:
* 1. UseCase層はHTTPの詳細($_POSTなど)を知らなくてよい
* 2. 同じUseCaseをCLIやキューからも実行可能になる
* 3. テストが容易になる(Commandオブジェクトを直接作成できる)
*/
public function toCommand(): CreateOrderCommand
{
return new CreateOrderCommand(
$this->input('shipping.prefecture'),
$this->input('shipping.city'),
$this->input('shipping.street'),
$this->input('items'),
// 注文者はリクエストボディではなく認証済みユーザーから取る
$this->user()->id,
);
}
}
// app/Providers/AppServiceProvider.php
namespace App\Providers;
use App\Infrastructure\Eloquent\OrderModel;
use App\Policies\OrderPolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// 第9章のEvent::listenも同じboot()に書く。片方だけを写すともう片方が消える
// LaravelはOrderModelに対してOrderModelPolicyを探すため、明示的に結びつける(第12章)
Gate::policy(OrderModel::class, OrderPolicy::class);
}
}
Gate::authorize() は認証済みユーザーを前提にします。POST /orders/{id}/confirm のルートは第18章の auth:api の下に置いてください。未認証のままだと所有者の判定ができず、必ず 403 になります。
// 悪い例: Controllerでドメインオブジェクトを直接操作している
final class OrderController extends Controller
{
public function store(CreateOrderRequest $request): JsonResponse
{
$order = new Order();
$order->status = 'draft';
foreach ($request->items as $item) {
$orderLine = new OrderLine();
$orderLine->product_id = $item['productId'];
$orderLine->quantity = $item['quantity'];
$order->orderLines()->save($orderLine);
}
// ビジネスルールがControllerに散在
if ($order->totalAmount() > 1000000) {
throw new Exception('注文金額が上限を超えています');
}
return response()->json($order);
}
}
// 良い例: UseCaseに処理を委譲し、Controllerは薄く保つ
final class OrderController extends Controller
{
public function __construct(
private readonly CreateOrderUseCase $createOrderUseCase,
) {}
public function store(CreateOrderRequest $request): JsonResponse
{
$orderId = $this->createOrderUseCase->execute($request->toCommand());
return response()->json(['orderId' => $orderId->value()], 201);
}
}
Controller層は薄く保ち、ビジネスロジックは必ずUseCase以降の層に配置しましょう。
アプリケーション層
アプリケーション層の責務はユースケース(ワークフロー)の調整です。ドメインオブジェクトを組み合わせて、1つのビジネスシナリオを実現します。
// app/Application/UseCase/Order/CreateOrderCommand.php
namespace App\Application\UseCase\Order;
/**
* 注文作成コマンド
*
* Commandオブジェクトのメリット:
* 1. HTTPリクエストから独立したデータ構造
* 2. CLIやキューからも同じUseCaseを実行可能
* 3. 型安全性の向上(PHPStanなどの静的解析が効く)
*
* 不変オブジェクトとして定義することで、意図しない変更を防ぎます。
*/
final class CreateOrderCommand
{
/**
* @param string $prefecture 配送先都道府県
* @param string $city 配送先市区町村
* @param string $street 配送先番地
* @param array<array{productId: int, quantity: int, unitPrice: int}> $items 注文明細
* @param int $userId 注文者。orders.user_idの供給元(第13章)
*/
public function __construct(
public readonly string $prefecture,
public readonly string $city,
public readonly string $street,
public readonly array $items,
public readonly int $userId,
) {}
}
// app/Application/UseCase/Order/CreateOrderUseCase.php
namespace App\Application\UseCase\Order;
use App\Domain\Order\Exception\InvalidOrderStateException;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepositoryInterface;
use App\Domain\Order\ProductId;
use App\Domain\Order\ShippingAddress;
use App\Domain\Shared\Money;
use App\Domain\User\UserId;
/**
* 注文作成ユースケース
*
* このUseCaseが行うこと:
* 1. 新しい注文IDの採番
* 2. 配送先住所の値オブジェクト化
* 3. 注文エンティティの生成
* 4. 各商品を注文明細として追加
* 5. 注文の永続化
*
* このUseCaseが「やらない」こと:
* - ビジネスルールの検証(ドメイン層の責務)
* - データベースの直接操作(インフラ層の責務)
* - HTTPレスポンスの生成(プレゼンテーション層の責務)
*/
final class CreateOrderUseCase
{
/**
* Repositoryインターフェースに依存
*
* 具象クラス(EloquentOrderRepository)ではなく、
* インターフェースに依存することで:
* - テストでモックに差し替え可能
* - 実装の変更(EloquentからDoctrine等)に強い
* - ドメイン層が外部技術に依存しない
*/
public function __construct(
private readonly OrderRepositoryInterface $orderRepository
) {}
/**
* 注文を作成する
*
* @param CreateOrderCommand $command HTTP層から渡されるコマンド
* @return OrderId 生成された注文ID
* @throws InvalidOrderStateException ドメインルール違反時
*/
public function execute(CreateOrderCommand $command): OrderId
{
// ステップ1: 新しい注文IDを採番
// Repositoryに採番ロジックを委譲することで、
// DBの自動採番やUUIDなど、実装方法を隠蔽できます
$orderId = $this->orderRepository->nextIdentity();
// ステップ2: 配送先住所を値オブジェクトに変換
// プリミティブな文字列ではなく、意味のある型として扱う
$shippingAddress = new ShippingAddress(
$command->prefecture,
$command->city,
$command->street
);
// ステップ3: 注文エンティティを生成
// ファクトリメソッドを使うことで、
// 正しい初期状態(DRAFT)で生成されることを保証
$order = Order::create($orderId, $shippingAddress);
// ステップ4: 各商品を注文明細として追加
foreach ($command->items as $item) {
// 注文明細IDも採番が必要
$lineId = $this->orderRepository->nextLineIdentity();
// addItem()メソッドでビジネスルールが検証される
// 例: 「確定済み注文には商品を追加できない」など
$order->addItem(
$lineId,
new ProductId($item['productId']), // プリミティブではなく値オブジェクト
$item['quantity'],
new Money($item['unitPrice'], 'JPY'), // 金額は通貨と一緒に扱う
);
}
// ステップ5: 注文を永続化
// 新規作成なのでcreate()。Orderは注文者を持たないので引数で渡す(第13章)
// create()の内部でトランザクション管理が行われる(詳細は第15章参照)
$this->orderRepository->create($order, new UserId($command->userId));
// ステップ6: 生成された注文IDを返却
// Controller層でHTTPレスポンスに変換される
return $orderId;
}
}
注文作成時のデータ変換の流れ:
各層で適切なデータ型を使うことで、型安全性とビジネスルールの明確化を実現します。
// 悪い例: UseCaseがEloquentに直接依存している
final class CreateOrderUseCase
{
public function execute(CreateOrderCommand $command): OrderId
{
// 直接Eloquentモデルを操作している
$order = new OrderModel();
$order->status = 'draft';
$order->shipping_prefecture = $command->prefecture;
$order->save(); // Eloquentに依存
foreach ($command->items as $item) {
$orderLine = new OrderLineModel();
$orderLine->order_id = $order->id;
$orderLine->product_id = $item['productId'];
$orderLine->save(); // Eloquentに依存
}
return new OrderId($order->id);
}
}
// 良い例: Repositoryインターフェースに依存
final class CreateOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository
) {}
public function execute(CreateOrderCommand $command): OrderId
{
$orderId = $this->orderRepository->nextIdentity();
$shippingAddress = new ShippingAddress(
$command->prefecture,
$command->city,
$command->street
);
$order = Order::create($orderId, $shippingAddress); // ドメインエンティティを使用
// 明細の追加は前掲の実装と同じ
$this->orderRepository->create($order, new UserId($command->userId)); // インターフェースに依存
return $orderId;
}
}
UseCaseは永続化の詳細を知るべきではありません。Repositoryインターフェースを通じて抽象的に扱いましょう。
ドメイン層
ドメイン層の責務はビジネスルールの表現と保護です。フレームワークやデータベースに依存せず、純粋なビジネスロジックのみを扱います。
// app/Domain/Order/Order.php
namespace App\Domain\Order;
use App\Domain\Order\Event\OrderCancelled;
use App\Domain\Order\Event\OrderConfirmed;
use App\Domain\Order\Event\OrderShipped;
use App\Domain\Order\Exception\InvalidOrderStateException;
use App\Domain\Order\Exception\OrderAlreadyConfirmedException;
use App\Domain\Shared\HasDomainEvents;
use App\Domain\Shared\Money;
use DateTimeImmutable;
/**
* 注文集約のルート
*
* 集約とは「整合性を保証すべき境界」です。
* Order集約では、注文本体とすべての注文明細(OrderLine)の整合性を保証します。
*
* 集約のルール:
* 1. 集約の外から内部のエンティティ(OrderLine)に直接アクセスさせない
* 2. すべての操作はルートエンティティ(Order)経由で行う
* 3. 不変条件(invariants)を常に守る
*/
final class Order
{
// ドメインイベントの記録・取り出しはトレイトに委譲(第9章)
use HasDomainEvents;
/** @var OrderLine[] 注文明細のコレクション */
private array $orderLines;
/**
* コンストラクタはprivateにして、外部から直接newできないようにする
*
* なぜprivateにするのか?
* - 不正な状態のオブジェクトを作らせないため
* - ファクトリメソッド(create, reconstruct)を使わせることで、
* 正しい初期化を強制する
*/
private function __construct(
private readonly OrderId $id, // 識別子は不変(readonlyで変更不可)
private OrderStatus $status, // ステータスは変更可能
private readonly ShippingAddress $shippingAddress, // 配送先は不変
array $orderLines,
private readonly DateTimeImmutable $createdAt, // 作成日時は不変
private int $version = 1, // 楽観的ロック用のバージョン(第15章)
) {
$this->orderLines = $orderLines;
}
/**
* 新規注文を作成するファクトリメソッド
*
* 新規作成時の不変条件:
* - ステータスは必ずDRAFT(下書き)
* - 注文明細は空配列
*
* @param OrderId $id 注文ID(Repository経由で採番済み)
* @param ShippingAddress $shippingAddress 配送先住所
* @return self 注文エンティティ
*/
public static function create(OrderId $id, ShippingAddress $shippingAddress): self
{
// 必ずDRAFTステータスで開始
// これにより「作成直後の注文は必ず下書き状態」というルールを保証
return new self($id, OrderStatus::DRAFT, $shippingAddress, [], new DateTimeImmutable());
}
/**
* DBから復元するファクトリメソッド
*
* create()との違い:
* - create(): ビジネスロジックとして新規作成(必ずDRAFT)
* - reconstruct(): 永続化されたデータからの復元(任意のステータス)
*
* Repository層からのみ呼ばれることを想定しています。
* 作成日時と版数を省略すると復元時刻と初期値になるので、RepositoryはDBの値を必ず渡します。
*/
public static function reconstruct(
OrderId $id,
OrderStatus $status,
ShippingAddress $shippingAddress,
array $orderLines,
?DateTimeImmutable $createdAt = null,
int $version = 1
): self {
return new self($id, $status, $shippingAddress, $orderLines, $createdAt ?? new DateTimeImmutable(), $version);
}
/**
* 注文に商品を追加する
*
* ビジネスルール:
* 1. 下書き状態でのみ商品を追加できる
* 2. 確定後やキャンセル後は追加不可
*
* このメソッド内でビジネスルールを検証することで、
* 不正な状態を防ぎます。
*
* @throws InvalidOrderStateException 下書き状態以外で呼ばれた場合
*/
public function addItem(
OrderLineId $lineId,
ProductId $productId,
int $quantity,
Money $unitPrice
): void {
// ビジネスルールの検証
// この検証をエンティティ内で行うことで、
// 外部(UseCaseなど)にルールが漏れることを防ぐ
if (!$this->status->isDraft()) {
throw new InvalidOrderStateException('下書き状態でのみ商品を追加できます');
}
// 注文明細を追加
// OrderLineは値オブジェクトとして扱う(変更不可)
$this->orderLines[] = new OrderLine($lineId, $productId, $quantity, $unitPrice);
}
/**
* 注文を確定する
*
* ビジネスルール:
* 1. 下書き状態からのみ確定できる
* 2. 注文明細が1件以上必要
*
* ステータス遷移はエンティティ内でのみ行い、
* 外部から直接ステータスを変更させません。
*
* @throws OrderAlreadyConfirmedException 既に確定済みの場合
* @throws InvalidOrderStateException その他のビジネスルール違反時
*/
public function confirm(): void
{
// 既に確定済みの場合は「競合」として区別する(第16章で409にマップ)
if ($this->status->isConfirmed()) {
throw new OrderAlreadyConfirmedException($this->id);
}
// ルール1: 確定可能な状態かチェック
if (!$this->status->canBeConfirmed()) {
throw new InvalidOrderStateException('この注文は確定できません');
}
// ルール2: 注文明細が空でないかチェック
if (empty($this->orderLines)) {
throw new InvalidOrderStateException('注文明細が空の状態では確定できません');
}
// すべてのルールをクリアしたらステータス変更
$this->status = OrderStatus::CONFIRMED;
// 何が起きたかをイベントとして記録する(配信はリポジトリが行う。第9章)
$this->recordEvent(new OrderConfirmed(
$this->id,
array_map(fn (OrderLine $line) => $line->id(), $this->orderLines),
$this->totalAmount(),
new DateTimeImmutable(),
));
}
/**
* 注文をキャンセルする
*
* ビジネスルール:
* - 出荷済みの注文はキャンセル不可など
*
* @param string $reason キャンセル理由。OrderCancelledイベントのpayloadになる(第9章)
*/
public function cancel(string $reason): void
{
if (!$this->status->canBeCancelled()) {
throw new InvalidOrderStateException('この注文はキャンセルできません');
}
$this->status = OrderStatus::CANCELLED;
// 理由はイベントの必須項目なので引数で受け取る(第9章)
$this->recordEvent(new OrderCancelled($this->id, $reason, new DateTimeImmutable()));
}
/**
* 注文を発送する
*
* ビジネスルール:
* - 確定済みの注文のみ発送できる
*
* @param string $trackingNumber 追跡番号。OrderShippedイベントのpayloadになる(第9章)
*/
public function ship(string $trackingNumber): void
{
if (!$this->status->canBeShipped()) {
throw new InvalidOrderStateException('この注文は発送できません');
}
$this->status = OrderStatus::SHIPPED;
$this->recordEvent(new OrderShipped($this->id, $trackingNumber, new DateTimeImmutable()));
}
/**
* 合計金額を計算
*
* 計算ロジックもドメインエンティティ内に持つことで、
* ビジネスルール(どう合計を計算するか)を一箇所に集約できます。
*
* @return Money 注文の合計金額
*/
public function totalAmount(): Money
{
$total = new Money(0, 'JPY');
foreach ($this->orderLines as $line) {
// Moneyオブジェクト同士の加算
// 値オブジェクトとして扱うことで、通貨の不一致を防げる
$total = $total->add($line->subtotal());
}
return $total;
}
// ゲッター
// readonlyを使うことで、外部から変更できないことを保証
public function id(): OrderId { return $this->id; }
public function status(): OrderStatus { return $this->status; }
public function shippingAddress(): ShippingAddress { return $this->shippingAddress; }
public function createdAt(): DateTimeImmutable { return $this->createdAt; }
// 楽観的ロック用。DBのversionカラムと突き合わせる(第15章)
public function version(): int { return $this->version; }
public function incrementVersion(): void { $this->version++; }
/**
* 注文明細の取得
*
* 配列をそのまま返すと外部から変更される可能性があるため、
* 本来はコピーを返すか、ReadOnlyCollectionでラップするのが理想的です。
* 簡略化のため、この実装では配列をそのまま返しています。
*/
public function orderLines(): array { return $this->orderLines; }
}
-
コンストラクタをprivateに
- ファクトリメソッド(create, reconstruct)経由でのみ生成可能にする
- 不正な初期状態を防ぐ
-
ビジネスルールはエンティティ内に
- 「確定済み注文には商品を追加できない」などのルールはaddItem()内で検証
- 外部(UseCaseなど)にルールを書かない
-
不変条件(Invariants)を常に保つ
- どんな操作をしても、エンティティは常に正しい状態を保つ
- 例: 「注文明細が空の注文は確定できない」
-
計算ロジックもエンティティ内に
- totalAmount()のような計算もドメイン知識の一部
- UseCase層で計算すると、ロジックが散在する
-
値オブジェクトを活用
- Money, OrderStatus, ProductId など、意味のある型を使う
- プリミティブ型(int, string)の直接使用を避ける
// 悪い例: publicなSetterがある
final class Order
{
private OrderStatus $status;
public function setStatus(OrderStatus $status): void
{
$this->status = $status; // ビジネスルールを無視して変更可能
}
}
// 使用例: ビジネスルールを無視できてしまう
$order->setStatus(OrderStatus::CONFIRMED); // 注文明細が空でも確定できてしまう
// 良い例: 意図を表すメソッドのみ公開
final class Order
{
private OrderStatus $status;
/** @var OrderLine[] */
private array $orderLines;
public function confirm(): void
{
// メソッド内でビジネスルールを検証
if (empty($this->orderLines)) {
throw new InvalidOrderStateException('注文明細が空の状態では確定できません');
}
$this->status = OrderStatus::CONFIRMED;
}
}
// 使用例: ビジネスルールが必ず守られる
$order->confirm(); // 注文明細が空なら例外が投げられる
Setterを公開すると、ビジネスルールを回避した変更が可能になってしまいます。 意図を表すメソッド(confirm, cancel など)のみを公開しましょう。
インフラ層
インフラ層の責務は永続化の詳細を隠蔽することです。ドメイン層で定義されたRepositoryインターフェースを実装し、ドメインエンティティとEloquentモデル間の変換を担当します。
// app/Infrastructure/Repository/EloquentOrderRepository.php
namespace App\Infrastructure\Repository;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderLine;
use App\Domain\Order\OrderLineId;
use App\Domain\Order\OrderRepositoryInterface;
use App\Domain\Order\OrderStatus;
use App\Domain\Order\ProductId;
use App\Domain\Order\ShippingAddress;
use App\Domain\Shared\DomainEventDispatcherInterface;
use App\Domain\Shared\Money;
use App\Domain\User\UserId;
use App\Infrastructure\Eloquent\OrderModel;
use Illuminate\Support\Facades\DB;
/**
* Eloquentを使ったOrderRepositoryの実装
*
* この層が行うこと:
* 1. ドメインエンティティとEloquentモデルの相互変換
* 2. トランザクション管理
* 3. データベース固有の操作(Eager Loading など)
*
* この層が「やらない」こと:
* - ビジネスルールの検証(ドメイン層の責務)
* - ワークフローの制御(アプリケーション層の責務)
*/
final class EloquentOrderRepository implements OrderRepositoryInterface
{
/**
* ディスパッチャを注入する
*
* エンティティが記録したドメインイベントは、この実装がコミット後に配信します(第9章)。
* インターフェースと実装クラスの束縛は第13章のRepositoryServiceProviderが持ちます。
*/
public function __construct(
private readonly DomainEventDispatcherInterface $eventDispatcher,
) {}
/**
* IDで注文を検索
*
* @param OrderId $id 注文ID
* @return Order|null 見つかった注文(存在しない場合はnull)
*/
public function findById(OrderId $id): ?Order
{
// with()でEager Loading → N+1問題を回避(詳細は第13章参照)
// 注文(orders)と注文明細(order_lines)を一度に取得
$model = OrderModel::with('orderLines')->find($id->value());
// Eloquentモデルが存在すればドメインエンティティに変換、なければnull
return $model ? $this->toEntity($model) : null;
}
/**
* 注文を新規作成
*
* Orderは注文者を持たない(第18章)ので、所有者はUseCaseから引数で受け取る。
* save()との差は、新規なのでfindOrNew()を使わない点とuser_idを代入する点の2つ。
*/
public function create(Order $order, UserId $userId): void
{
DB::transaction(function () use ($order, $userId) {
$orderModel = new OrderModel();
// 識別子も所有者も$fillableに含めず直接代入する
$orderModel->id = $order->id()->value();
$orderModel->user_id = $userId->value();
$orderModel->fill([
'status' => $order->status()->value,
'total_amount' => $order->totalAmount()->amount(),
// 新規行なので初期値の版数(1)をそのまま書き込む(第13章)
'version' => $order->version(),
'shipping_prefecture' => $order->shippingAddress()->prefecture(),
'shipping_city' => $order->shippingAddress()->city(),
'shipping_street' => $order->shippingAddress()->street(),
])->save();
$this->saveOrderLines($orderModel, $order->orderLines());
});
// 記録されたドメインイベントを取り出してコミット後に配信する(第9章)
$events = $order->pullDomainEvents();
DB::afterCommit(fn () => $this->eventDispatcher->dispatchAll($events));
}
/**
* 注文を保存(更新)
*
* このメソッドの責務:
* 1. ドメインエンティティをEloquentモデルに変換
* 2. トランザクション内で注文本体と注文明細を保存
* 3. 削除された注文明細も適切に処理
*
* トランザクション管理のポイント:
* - 1つの集約の保存なので、Repository内でトランザクションを管理
* - 注文と注文明細は一緒に保存される(一貫性保証)
* - 途中で失敗したら全てロールバック
*/
public function save(Order $order): void
{
// トランザクションで囲む理由:
// 注文本体(orders)と注文明細(order_lines)の整合性を保つため
DB::transaction(function () use ($order) {
// ステップ1: 注文本体を保存
// findOrNew(): 存在すれば取得、なければ新しいインスタンス
$orderModel = OrderModel::findOrNew($order->id()->value());
// 識別子はドメイン側で採番済み。$fillableに含めず直接代入する
$orderModel->id = $order->id()->value();
// 保存するデータ: ドメインエンティティの状態をカラムにマッピング
$orderModel->fill([
'status' => $order->status()->value, // Enum → 文字列
'total_amount' => $order->totalAmount()->amount(),
// versionは書かない。競合検出は第15章の楽観的ロックが担う
'shipping_prefecture' => $order->shippingAddress()->prefecture(),
'shipping_city' => $order->shippingAddress()->city(),
'shipping_street' => $order->shippingAddress()->street(),
])->save();
// ステップ2: 注文明細を保存(削除含む)
$this->saveOrderLines($orderModel, $order->orderLines());
});
// UseCase側が外側でDB::transaction()を張っている場合、上のトランザクションは
// SAVEPOINTでしかなく実コミットではない。DB::afterCommit()で最外のコミットを待つ(第9章)
$events = $order->pullDomainEvents();
DB::afterCommit(fn () => $this->eventDispatcher->dispatchAll($events));
}
/**
* 次の注文IDを生成
*
* ID採番戦略をカプセル化:
* - 現在は「最大値+1」方式(本書では学習目的の簡易版)
* - Snowflake IDのような正整数への差し替えはこのメソッドの中で収まる
* - IDの型を変えるならOrderId(第5章)も変わる
*
* ⚠️ 本番環境では下記の「本番での採番について」を参照
*/
public function nextIdentity(): OrderId
{
$maxId = OrderModel::max('id') ?? 0;
return new OrderId($maxId + 1);
}
/**
* 次の注文明細IDを生成
*
* 注文明細も独立したIDを持つため、別途採番が必要
*/
public function nextLineIdentity(): OrderLineId
{
$maxId = DB::table('order_lines')->max('id') ?? 0;
return new OrderLineId($maxId + 1);
}
/**
* Eloquentモデルからドメインエンティティへの変換
*
* この変換により:
* - ドメイン層はEloquentの存在を知らずに済む
* - テーブル構造の変更がドメイン層に影響しない
* - ビジネスロジックとデータ構造を分離できる
*
* @param OrderModel $model Eloquentモデル
* @return Order ドメインエンティティ
*/
private function toEntity(OrderModel $model): Order
{
// 注文明細のコレクションを変換
// Eloquent Collection → ドメインエンティティの配列
$orderLines = $model->orderLines->map(fn($line) => new OrderLine(
new OrderLineId($line->id), // プリミティブ → 値オブジェクト
new ProductId($line->product_id),
$line->quantity,
new Money($line->unit_price, 'JPY'), // 金額 → Moneyオブジェクト
))->toArray();
// reconstruct()を使ってDBから復元
// create()ではなくreconstruct()を使う理由:
// - DBから読み込んだデータは任意のステータスを持ちうる
// - create()は「必ずDRAFT」という前提があるため不適切
return Order::reconstruct(
new OrderId($model->id),
// 文字列 → Enum。不正値なら ValueError を投げてフェイルファスト(詳細は第13章)
OrderStatus::from($model->status),
new ShippingAddress(
$model->shipping_prefecture,
$model->shipping_city,
$model->shipping_street
),
$orderLines,
// 作成日時はDBの値を渡す。省略すると復元時刻になってしまう(第13章)
$model->created_at->toImmutable(),
// 楽観的ロックの版数もDBの値を渡す(第15章)。省略すると常に1から始まり、
// 2回目以降の更新で競合していないのに競合と判定される
$model->version,
);
}
/**
* 注文明細を保存(追加・更新・削除を含む)
*
* この処理の複雑さの理由:
* - 単なる追加だけでなく、削除された明細も処理する必要がある
* - 例: 注文編集時に商品を1つ削除した場合、DBからも削除すべき
*
* アルゴリズム:
* 1. 現在のエンティティが持つ注文明細のIDリストを取得
* 2. DBに存在するが、エンティティに存在しない明細を削除
* 3. エンティティが持つ全ての明細を保存(findOrNew + fill)
*
* @param OrderModel $orderModel 親となる注文
* @param array $orderLines 保存する注文明細の配列
*/
private function saveOrderLines(OrderModel $orderModel, array $orderLines): void
{
// 現在有効な注文明細のIDリスト
$currentLineIds = array_map(fn($line) => $line->id()->value(), $orderLines);
// 削除された注文明細をDBから削除
// 例: 編集前は3件、編集後は2件の場合、削除された1件を消す
$orderModel->orderLines()->whereNotIn('id', $currentLineIds)->delete();
// 全ての注文明細を保存(新規 or 更新)
foreach ($orderLines as $line) {
// HasMany経由のfindOrNew()は外部キー(order_id)を自動で設定する
$lineModel = $orderModel->orderLines()->findOrNew($line->id()->value());
// 識別子は$fillableに含めず直接代入する(第13章)
$lineModel->id = $line->id()->value();
// 保存データ: ドメインエンティティ → テーブルカラム
$lineModel->fill([
'product_id' => $line->productId()->value(),
'quantity' => $line->quantity(),
'unit_price' => $line->unitPrice()->amount(), // Money → int
])->save();
}
}
}
本書の nextIdentity() / nextLineIdentity() は MAX(id) + 1 方式を使っていますが、これは 複数プロセスが同時に採番した際に ID が衝突する可能性があります。本番環境では以下のいずれかを推奨します。
- DB Auto Increment:
INSERT時に DB が採番(Laravel の$table->id()で十分)。ID が確定するのは保存後なので、「採番してからエンティティを作る」という本章の流れそのものを組み替えることになる - UUID v7: 時系列順にソート可能な UUID(Laravel 11.17 以降の
Str::uuid7()が使える)。文字列なので、intしか受け取らないOrderId(第5章)も一緒に変わる - Snowflake ID: 分散システム向けの一意 ID。63bit の正整数なので
OrderIdのintに収まる
つまり、差し替えが nextIdentity() の中に閉じるのは Snowflake ID だけです。ID の型を変えるなら OrderId が変わり、採番のタイミングを変えるなら UseCase の流れが変わります。
本書は「採番方法はリポジトリに隠蔽される」という設計ポイントを学ぶための例示です。実運用ではスレッドセーフな採番方法に差し替えてください。
-
変換ロジックを隠蔽する
- toEntity() / toModel() のような変換メソッドを private にする
- ドメイン層にテーブル構造を漏らさない
-
Eager Loadingでパフォーマンス最適化
with('orderLines')でN+1問題を回避- ドメイン層はパフォーマンス最適化を気にしなくてよい
-
トランザクションは適切な場所で
- 1集約の保存: Repository内でトランザクション
- 複数集約の保存: UseCase内でトランザクション(第15章参照)
-
ID採番戦略をカプセル化
- nextIdentity() で採番方法を隠蔽
- Snowflakeのような正整数への差し替えはこのメソッドに閉じる(IDの型を変えるならOrderIdも変わる)
-
新規作成はcreate()、更新はsave()
- 新規は注文者(user_id)を引数で受け取るので経路を分ける
- どちらもfindOrNew + fillで$fillableの防御を保つ
// 悪い例: 配列を返している
// OrderRepositoryInterfaceは?Orderを返す契約なので、この形では契約を満たせない
final class EloquentOrderRepository
{
public function findById(OrderId $id): ?array
{
$model = OrderModel::with('orderLines')->find($id->value());
return $model ? $model->toArray() : null;
}
}
// 問題点:
// 1. 戻り値が配列なので、型安全でない
// 2. ビジネスロジック(confirm()など)が使えない
// 3. ドメインモデルとしての振る舞いがない
// 良い例: ドメインエンティティを返す
// findById()のみ抜粋。他のメソッドは前掲の実装のとおり
final class EloquentOrderRepository
{
public function findById(OrderId $id): ?Order
{
$model = OrderModel::with('orderLines')->find($id->value());
return $model ? $this->toEntity($model) : null;
}
}
// メリット:
// 1. 型安全(Order型が保証される)
// 2. ビジネスロジックが使える($order->confirm()など)
// 3. ドメインモデルとして扱える
Repositoryは必ずドメインエンティティを返すようにしましょう。
テストの例
各層のテストには、それぞれ異なる目的とアプローチがあります。
ドメイン層のテスト(DBなし、高速)
ドメインロジックのテストはデータベースを使わずに実行できます。これにより高速で信頼性の高いテストが書けます。
// tests/Unit/Domain/Order/OrderPracticeTest.php
namespace Tests\Unit\Domain\Order;
use App\Domain\Order\Exception\InvalidOrderStateException;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderLineId;
use App\Domain\Order\ProductId;
use App\Domain\Order\ShippingAddress;
use App\Domain\Shared\Money;
use Tests\TestCase;
// 第17章の OrderTest とは別クラス・別ファイルにする
final class OrderPracticeTest extends TestCase
{
/**
* ビジネスルールのテスト: 確定済み注文には商品を追加できない
*
* このテストの目的:
* - ドメインエンティティがビジネスルールを正しく保護しているかを確認
* - 不正な状態遷移を防げているかを検証
*
* DBを使わない理由:
* - ビジネスロジックのテストに永続化は不要
* - テスト実行が高速
* - テストが安定(DB状態に依存しない)
*/
public function test_確定済み注文には商品を追加できない(): void
{
// Arrange(準備): テストデータを用意
$order = Order::create(
new OrderId(1),
new ShippingAddress('東京都', '渋谷区', '1-1-1')
);
$order->addItem(new OrderLineId(1), new ProductId(1), 2, new Money(1000, 'JPY'));
$order->confirm(); // 注文を確定
// Act & Assert(実行と検証): 確定後に商品追加すると例外が出ることを確認
$this->expectException(InvalidOrderStateException::class);
$this->expectExceptionMessage('下書き状態でのみ商品を追加できます');
$order->addItem(new OrderLineId(2), new ProductId(2), 1, new Money(500, 'JPY'));
}
/**
* ビジネスルールのテスト: 明細が空の注文は確定できない
*/
public function test_明細が空の注文は確定できない(): void
{
// Arrange: 注文を作成(商品は追加しない)
$order = Order::create(
new OrderId(1),
new ShippingAddress('東京都', '渋谷区', '1-1-1')
);
// Act & Assert: 商品なしで確定しようとすると例外
$this->expectException(InvalidOrderStateException::class);
$this->expectExceptionMessage('注文明細が空の状態では確定できません');
$order->confirm();
}
/**
* 計算ロジックのテスト: 合計金額が正しく計算される
*/
public function test_合計金額が正しく計算される(): void
{
// Arrange
$order = Order::create(
new OrderId(1),
new ShippingAddress('東京都', '渋谷区', '1-1-1')
);
$order->addItem(new OrderLineId(1), new ProductId(1), 2, new Money(1000, 'JPY')); // 2,000円
$order->addItem(new OrderLineId(2), new ProductId(2), 3, new Money(500, 'JPY')); // 1,500円
// Act
$totalAmount = $order->totalAmount();
// Assert: 合計3,500円
$this->assertEquals(3500, $totalAmount->amount());
}
}
UseCase層のテスト(モックを活用)
UseCaseのテストでは、Repositoryをモック化してワークフローの正しさを検証します。
// tests/Unit/Application/UseCase/Order/CreateOrderUseCasePracticeTest.php
namespace Tests\Unit\Application\UseCase\Order;
use App\Application\UseCase\Order\CreateOrderCommand;
use App\Application\UseCase\Order\CreateOrderUseCase;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderLineId;
use App\Domain\Order\OrderRepositoryInterface;
use App\Domain\Order\OrderStatus;
use App\Domain\User\UserId;
use Tests\TestCase;
// 第17章の CreateOrderUseCaseTest とは別クラス・別ファイルにする
final class CreateOrderUseCasePracticeTest extends TestCase
{
public function test_注文が正しく作成される(): void
{
// Arrange: Repositoryのモックを作成
$orderRepository = $this->createMock(OrderRepositoryInterface::class);
// nextIdentity()が呼ばれたら OrderId(1) を返す
$orderRepository->expects($this->once())
->method('nextIdentity')
->willReturn(new OrderId(1));
// nextLineIdentity()は2回呼ばれる(商品が2件)
$orderRepository->expects($this->exactly(2))
->method('nextLineIdentity')
->willReturnOnConsecutiveCalls(
new OrderLineId(1),
new OrderLineId(2)
);
// UseCaseが呼ぶのは save() ではなく create($order, $userId)。
// 第2引数まで含めて期待を書く
$orderRepository->expects($this->once())
->method('create')
->with($this->callback(function (Order $order) {
// callback制約は照合のたびに呼ばれうるので、
// アサーションではなくbooleanを返す
return $order->status() === OrderStatus::DRAFT
&& count($order->orderLines()) === 2;
}), new UserId(7));
// UseCase作成
$useCase = new CreateOrderUseCase($orderRepository);
// Act: コマンドを実行
$command = new CreateOrderCommand(
prefecture: '東京都',
city: '渋谷区',
street: '1-1-1',
items: [
['productId' => 1, 'quantity' => 2, 'unitPrice' => 1000],
['productId' => 2, 'quantity' => 1, 'unitPrice' => 500],
],
userId: 7,
);
$orderId = $useCase->execute($command);
// Assert: 返却される注文IDを検証
$this->assertEquals(1, $orderId->value());
}
}
Repository層のテスト(DBを使った統合テスト)
Repository層はデータベースとの連携を確認するため、実際のDBを使ってテストします。
// tests/Integration/Infrastructure/Repository/EloquentOrderRepositoryPracticeTest.php
namespace Tests\Integration\Infrastructure\Repository;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderLineId;
use App\Domain\Order\OrderStatus;
use App\Domain\Order\ProductId;
use App\Domain\Order\ShippingAddress;
use App\Domain\Shared\Money;
use App\Domain\User\UserId;
use App\Infrastructure\Event\LaravelDomainEventDispatcher;
use App\Infrastructure\Repository\EloquentOrderRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
// 第17章の EloquentOrderRepositoryTest と同じパスに置くとクラス名が衝突するので、
// 本章のテストは別クラス・別ファイルにする
final class EloquentOrderRepositoryPracticeTest extends TestCase
{
use RefreshDatabase; // テストごとにDBをリセット
public function test_注文を保存して取得できる(): void
{
// Arrange
$repository = new EloquentOrderRepository(new LaravelDomainEventDispatcher());
$order = Order::create(
new OrderId(1),
new ShippingAddress('東京都', '渋谷区', '1-1-1')
);
$order->addItem(
new OrderLineId(1),
new ProductId(100),
2,
new Money(1000, 'JPY')
);
// Act: 保存(新規なのでcreate)
$repository->create($order, new UserId(7));
// Assert: 取得して検証
$fetchedOrder = $repository->findById(new OrderId(1));
$this->assertNotNull($fetchedOrder);
$this->assertEquals(OrderStatus::DRAFT, $fetchedOrder->status());
$this->assertCount(1, $fetchedOrder->orderLines());
}
public function test_注文明細の削除が反映される(): void
{
// Arrange: 2件の商品を持つ注文を保存
$repository = new EloquentOrderRepository(new LaravelDomainEventDispatcher());
$order = Order::create(new OrderId(1), new ShippingAddress('東京都', '渋谷区', '1-1-1'));
$order->addItem(new OrderLineId(1), new ProductId(100), 2, new Money(1000, 'JPY'));
$order->addItem(new OrderLineId(2), new ProductId(200), 1, new Money(500, 'JPY'));
$repository->create($order, new UserId(7));
// Act: 注文を再取得して1件の商品を削除
$fetchedOrder = $repository->findById(new OrderId(1));
$remainingLine = $fetchedOrder->orderLines()[0]; // 最初の1件のみ残す
$modifiedOrder = Order::reconstruct(
$fetchedOrder->id(),
$fetchedOrder->status(),
$fetchedOrder->shippingAddress(),
[$remainingLine], // 1件だけ残す
// 作成日時と版数もDBから読んだ値を渡す。省略すると作成日時が復元時刻になり、
// 版数が1に巻き戻る(第13章)
$fetchedOrder->createdAt(),
$fetchedOrder->version(),
);
$repository->save($modifiedOrder);
// Assert: 商品が1件になっていることを確認
$refetchedOrder = $repository->findById(new OrderId(1));
$this->assertCount(1, $refetchedOrder->orderLines());
}
}
| テスト対象 | DB使用 | モック | テスト目的 |
|---|---|---|---|
| ドメイン層 | 不要 | 不要 | ビジネスルールの正しさ |
| UseCase層 | 不要 | 使う | ワークフローの正しさ |
| Repository層 | 使う | 不要 | 永続化の正しさ |
| Controller層 | 使う | 使う(一部) | HTTPレスポンスの正しさ |
テストピラミッドの原則:
- ドメイン層のテストを最も多く書く(高速・安定)
- UseCase層のテストを中程度書く
- Repository層のテストは必要最小限(低速・不安定)
詳細は第17章「テスト戦略」を参照してください。
まとめ
本章では、注文システムの実装を通じて、各層の責務とデータフローを詳しく見てきました。
重要なポイント:
-
各層の責務を明確に分離
- Controller: HTTPリクエスト/レスポンス変換のみ
- UseCase: ワークフローの調整のみ
- Domain: ビジネスルールの表現と保護のみ
- Repository: 永続化の詳細を隠蔽するのみ
-
データ変換の流れを理解する
- HTTPリクエスト → Command → 値オブジェクト → ドメインエンティティ → Eloquentモデル
-
ビジネスルールはドメイン層に集約
- エンティティのメソッド内でルールを検証
- 外部から不正な状態変更をさせない
-
テストは各層の責務に応じて書く
- ドメイン層はDBなしで高速にテスト
- Repository層は実DBでテスト
次に読む
次のチャプターでは、本書のまとめと、さらに学習を深めるためのリソースを紹介します。
- 第20章「まとめと次のステップ」 — 段階的な導入のロードマップと、次に読む書籍・資料
- 第6章「エンティティ」 — 本章の
Orderの元になっている実装。ファクトリメソッドと不変条件の設計 - 第13章「リポジトリパターン」 — 本章の
EloquentOrderRepositoryの元になっている実装。トランザクションとイベント配信 - 第17章「テスト戦略」 — 層ごとのテストの書き分けと、本章のテストの元になっている実装