メインコンテンツまでスキップ

実践:注文システムの実装 — 全層を貫く実装例

実装の全体像

本チャプターでは、これまで学んだ内容を統合して、注文システムを実装します。

[実装する機能]
1. 注文作成(POST /orders)
2. 注文確定(POST /orders/{id}/confirm)

[関連する集約]
- Order集約(Order, OrderLine, ShippingAddress)
- Inventory集約(在庫確認用)

ディレクトリ構成

app/
├── Domain/
│ ├── Order/
│ │ ├── Order.php
│ │ ├── OrderId.php
│ │ ├── Exception/
│ │ │ └── InvalidOrderStateException.php
│ │ ├── OrderLine.php
│ │ ├── OrderLineId.php
│ │ ├── OrderStatus.php
│ │ ├── ProductId.php
│ │ ├── ShippingAddress.php
│ │ └── OrderRepositoryInterface.php
│ └── Shared/
│ └── Money.php

├── Application/
│ └── UseCase/
│ └── Order/
│ ├── CreateOrderUseCase.php
│ ├── CreateOrderCommand.php
│ ├── ConfirmOrderUseCase.php
│ └── ConfirmOrderCommand.php

├── Infrastructure/
│ ├── Eloquent/
│ │ ├── OrderModel.php
│ │ └── OrderLineModel.php
│ ├── Repository/
│ │ └── EloquentOrderRepository.php
│ └── Provider/
│ └── RepositoryServiceProvider.php

└── Http/
├── Controllers/
│ └── OrderController.php
└── Requests/
└── CreateOrderRequest.php

データの流れ

各層の実装

プレゼンテーション層

プレゼンテーション層の責務はHTTPリクエストの受け取りとレスポンスの返却です。ビジネスロジックは一切持たず、UseCaseに処理を委譲します。

// app/Http/Controllers/OrderController.php
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
{
$this->confirmOrderUseCase->execute(new ConfirmOrderCommand($id));
return response()->json(['message' => '注文を確定しました']);
}
}

// app/Http/Requests/CreateOrderRequest.php
final class CreateOrderRequest extends FormRequest
{
/**
* バリデーションルール
*
* ここで定義するのは「HTTPリクエストとしての妥当性」のみ。
* ビジネスルール(例:在庫チェック、注文上限)はドメイン層で検証します。
*
* 責任の分離:
* - FormRequest: HTTP層での型・形式のバリデーション
* - Domain: ビジネスルールのバリデーション
*/
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'),
);
}
}
よくある間違い: Controllerに直接ビジネスロジックを書く
// 悪い例
public function store(CreateOrderRequest $request): JsonResponse
{
// Controllerでドメインオブジェクトを直接操作している
$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);
}

// 良い例
public function store(CreateOrderRequest $request): JsonResponse
{
// UseCaseに処理を委譲し、Controllerは薄く保つ
$orderId = $this->createOrderUseCase->execute($request->toCommand());
return response()->json(['orderId' => $orderId->value()], 201);
}

Controller層は薄く保ち、ビジネスロジックは必ずUseCase以降の層に配置しましょう。

アプリケーション層

アプリケーション層の責務はユースケース(ワークフロー)の調整です。ドメインオブジェクトを組み合わせて、1つのビジネスシナリオを実現します。

// app/Application/UseCase/Order/CreateOrderCommand.php

/**
* 注文作成コマンド
*
* 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 注文明細
*/
public function __construct(
public readonly string $prefecture,
public readonly string $city,
public readonly string $street,
public readonly array $items,
) {}
}

// app/Application/UseCase/Order/CreateOrderUseCase.php

/**
* 注文作成ユースケース
*
* この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: 注文を永続化
// save()の内部でトランザクション管理が行われる(詳細は15章参照)
$this->orderRepository->save($order);

// ステップ6: 生成された注文IDを返却
// Controller層でHTTPレスポンスに変換される
return $orderId;
}
}
データフローの詳細解説

注文作成時のデータ変換の流れ:

各層で適切なデータ型を使うことで、型安全性とビジネスルールの明確化を実現します。

よくある間違い: UseCaseに直接DBロジックを書く
// 悪い例: 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();
$order = Order::create($orderId, ...); // ドメインエンティティを使用
// ...
$this->orderRepository->save($order); // インターフェースに依存
return $orderId;
}
}

UseCaseは永続化の詳細を知るべきではありません。Repositoryインターフェースを通じて抽象的に扱いましょう。

ドメイン層

ドメイン層の責務はビジネスルールの表現と保護です。フレームワークやデータベースに依存せず、純粋なビジネスロジックのみを扱います。

// app/Domain/Order/Order.php

/**
* 注文集約のルート
*
* 集約とは「整合性を保証すべき境界」です。
* Order集約では、注文本体とすべての注文明細(OrderLine)の整合性を保証します。
*
* 集約のルール:
* 1. 集約の外から内部のエンティティ(OrderLine)に直接アクセスさせない
* 2. すべての操作はルートエンティティ(Order)経由で行う
* 3. 不変条件(invariants)を常に守る
*/
final class Order
{
/** @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,
) {
$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, []);
}

/**
* DBから復元するファクトリメソッド
*
* create()との違い:
* - create(): ビジネスロジックとして新規作成(必ずDRAFT)
* - reconstruct(): 永続化されたデータからの復元(任意のステータス)
*
* Repository層からのみ呼ばれることを想定しています。
*/
public static function reconstruct(
OrderId $id,
OrderStatus $status,
ShippingAddress $shippingAddress,
array $orderLines
): self {
return new self($id, $status, $shippingAddress, $orderLines);
}

/**
* 注文に商品を追加する
*
* ビジネスルール:
* 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 InvalidOrderStateException ビジネスルール違反時
*/
public function confirm(): void
{
// ルール1: 確定可能な状態かチェック
if (!$this->status->canBeConfirmed()) {
throw new InvalidOrderStateException('この注文は確定できません');
}

// ルール2: 注文明細が空でないかチェック
if (empty($this->orderLines)) {
throw new InvalidOrderStateException('注文明細が空の状態では確定できません');
}

// すべてのルールをクリアしたらステータス変更
$this->status = OrderStatus::CONFIRMED;

// ドメインイベントを発行する場合はここで記録
// $this->recordEvent(new OrderConfirmed($this->id));
}

/**
* 注文をキャンセルする
*
* ビジネスルール:
* - 出荷済みの注文はキャンセル不可など
*/
public function cancel(): void
{
if (!$this->status->canBeCancelled()) {
throw new InvalidOrderStateException('この注文はキャンセルできません');
}
$this->status = OrderStatus::CANCELLED;
}

/**
* 合計金額を計算
*
* 計算ロジックもドメインエンティティ内に持つことで、
* ビジネスルール(どう合計を計算するか)を一箇所に集約できます。
*
* @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; }

/**
* 注文明細の取得
*
* 配列をそのまま返すと外部から変更される可能性があるため、
* 本来はコピーを返すか、ReadOnlyCollectionでラップするのが理想的です。
* 簡略化のため、この実装では配列をそのまま返しています。
*/
public function orderLines(): array { return $this->orderLines; }
}
ドメインエンティティ設計のベストプラクティス
  1. コンストラクタをprivateに

    • ファクトリメソッド(create, reconstruct)経由でのみ生成可能にする
    • 不正な初期状態を防ぐ
  2. ビジネスルールはエンティティ内に

    • 「確定済み注文には商品を追加できない」などのルールはaddItem()内で検証
    • 外部(UseCaseなど)にルールを書かない
  3. 不変条件(Invariants)を常に保つ

    • どんな操作をしても、エンティティは常に正しい状態を保つ
    • 例: 「注文明細が空の注文は確定できない」
  4. 計算ロジックもエンティティ内に

    • totalAmount()のような計算もドメイン知識の一部
    • UseCase層で計算すると、ロジックが散在する
  5. 値オブジェクトを活用

    • Money, OrderStatus, ProductId など、意味のある型を使う
    • プリミティブ型(int, string)の直接使用を避ける
よくある間違い: Setterを公開する
// 悪い例: publicなSetterがある
final class Order
{
public function setStatus(OrderStatus $status): void
{
$this->status = $status; // ビジネスルールを無視して変更可能
}
}

// 使用例: ビジネスルールを無視できてしまう
$order->setStatus(OrderStatus::CONFIRMED); // 注文明細が空でも確定できてしまう

// 良い例: 意図を表すメソッドのみ公開
final class Order
{
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

/**
* Eloquentを使ったOrderRepositoryの実装
*
* この層が行うこと:
* 1. ドメインエンティティとEloquentモデルの相互変換
* 2. トランザクション管理
* 3. データベース固有の操作(Eager Loading など)
*
* この層が「やらない」こと:
* - ビジネスルールの検証(ドメイン層の責務)
* - ワークフローの制御(アプリケーション層の責務)
*/
final class EloquentOrderRepository implements OrderRepositoryInterface
{
/**
* 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;
}

/**
* 注文を保存
*
* このメソッドの責務:
* 1. ドメインエンティティをEloquentモデルに変換
* 2. トランザクション内で注文本体と注文明細を保存
* 3. 削除された注文明細も適切に処理
*
* トランザクション管理のポイント:
* - 1つの集約の保存なので、Repository内でトランザクションを管理
* - 注文と注文明細は一緒に保存される(一貫性保証)
* - 途中で失敗したら全てロールバック
*/
public function save(Order $order): void
{
// トランザクションで囲む理由:
// 注文本体(orders)と注文明細(order_lines)の整合性を保つため
DB::transaction(function () use ($order) {
// ステップ1: 注文本体を保存
// updateOrCreate() を使うことで、新規作成と更新を統一的に扱える
$orderModel = OrderModel::updateOrCreate(
// 検索条件: このIDの注文が既に存在するか?
['id' => $order->id()->value()],
// 保存するデータ: ドメインエンティティの状態をカラムにマッピング
[
'status' => $order->status()->value, // Enum → 文字列
'shipping_prefecture' => $order->shippingAddress()->prefecture(),
'shipping_city' => $order->shippingAddress()->city(),
'shipping_street' => $order->shippingAddress()->street(),
]
);

// ステップ2: 注文明細を保存(削除含む)
$this->saveOrderLines($orderModel, $order->orderLines());
});
}

/**
* 次の注文IDを生成
*
* ID採番戦略をカプセル化:
* - 現在は「最大値+1」方式(本書では学習目的の簡易版)
* - 将来UUIDに変更しても、この実装を変えるだけでOK
* - ドメイン層やアプリケーション層は変更不要
*
* ⚠️ 本番環境では下記の「本番での採番について」を参照
*/
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,
);
}

/**
* 注文明細を保存(追加・更新・削除を含む)
*
* この処理の複雑さの理由:
* - 単なる追加だけでなく、削除された明細も処理する必要がある
* - 例: 注文編集時に商品を1つ削除した場合、DBからも削除すべき
*
* アルゴリズム:
* 1. 現在のエンティティが持つ注文明細のIDリストを取得
* 2. DBに存在するが、エンティティに存在しない明細を削除
* 3. エンティティが持つ全ての明細を保存(updateOrCreate)
*
* @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) {
$orderModel->orderLines()->updateOrCreate(
// 検索条件: このIDの明細が存在するか?
['id' => $line->id()->value()],
// 保存データ: ドメインエンティティ → テーブルカラム
[
'product_id' => $line->productId()->value(),
'quantity' => $line->quantity(),
'unit_price' => $line->unitPrice()->amount(), // Money → int
]
);
}
}
}
本番環境での ID 採番について

本書の nextIdentity() / nextLineIdentity()MAX(id) + 1 方式を使っていますが、これは 複数プロセスが同時に採番した際に ID が衝突する可能性があります。本番環境では以下のいずれかを推奨します。

  • DB Auto Increment: INSERT 時に DB が採番(Laravel の $table->id() で十分)
  • UUID v7: 時系列順にソート可能な UUID(Laravel 11 の Str::uuid7() が利用可能)
  • Snowflake ID: 分散システム向けの一意 ID

本書は「採番方法はリポジトリに隠蔽される」という設計ポイントを学ぶための例示です。実運用ではスレッドセーフな採番方法に差し替えてください。

Repository実装のベストプラクティス
  1. 変換ロジックを隠蔽する

    • toEntity() / toModel() のような変換メソッドを private にする
    • ドメイン層にテーブル構造を漏らさない
  2. Eager Loadingでパフォーマンス最適化

    • with('orderLines') でN+1問題を回避
    • ドメイン層はパフォーマンス最適化を気にしなくてよい
  3. トランザクションは適切な場所で

    • 1集約の保存: Repository内でトランザクション
    • 複数集約の保存: UseCase内でトランザクション(15章参照)
  4. ID採番戦略をカプセル化

    • nextIdentity() で採番方法を隠蔽
    • 将来UUIDやSnowflakeに変更しやすい
  5. updateOrCreateで新規と更新を統一

    • 新規作成時も更新時も同じコードで処理
    • ロジックの重複を避ける
よくある間違い: toArray()でドメインエンティティを返す
// 悪い例: 配列を返している
public function findById(OrderId $id): ?array
{
$model = OrderModel::with('orderLines')->find($id->value());
return $model ? $model->toArray() : null;
}

// 問題点:
// 1. 戻り値が配列なので、型安全でない
// 2. ビジネスロジック(confirm()など)が使えない
// 3. ドメインモデルとしての振る舞いがない

// 良い例: ドメインエンティティを返す
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/OrderTest.php

/**
* ビジネスルールのテスト: 確定済み注文には商品を追加できない
*
* このテストの目的:
* - ドメインエンティティがビジネスルールを正しく保護しているかを確認
* - 不正な状態遷移を防げているかを検証
*
* 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/CreateOrderUseCaseTest.php

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)
);

// save()が1回呼ばれることを期待
$orderRepository->expects($this->once())
->method('save')
->with($this->callback(function (Order $order) {
// 保存される注文の状態を検証
$this->assertEquals(OrderStatus::DRAFT, $order->status());
$this->assertCount(2, $order->orderLines());
return true;
}));

// 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],
]
);
$orderId = $useCase->execute($command);

// Assert: 返却される注文IDを検証
$this->assertEquals(1, $orderId->value());
}

Repository層のテスト(DBを使った統合テスト)

Repository層はデータベースとの連携を確認するため、実際のDBを使ってテストします。

// tests/Integration/Infrastructure/Repository/EloquentOrderRepositoryTest.php

use Illuminate\Foundation\Testing\RefreshDatabase;

class EloquentOrderRepositoryTest extends TestCase
{
use RefreshDatabase; // テストごとにDBをリセット

public function test_注文を保存して取得できる(): void
{
// Arrange
$repository = new EloquentOrderRepository();

$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: 保存
$repository->save($order);

// 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();
$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->save($order);

// Act: 注文を再取得して1件の商品を削除
$fetchedOrder = $repository->findById(new OrderId(1));
$remainingLine = $fetchedOrder->orderLines()[0]; // 最初の1件のみ残す
$modifiedOrder = Order::reconstruct(
$fetchedOrder->id(),
$fetchedOrder->status(),
$fetchedOrder->shippingAddress(),
[$remainingLine] // 1件だけ残す
);
$repository->save($modifiedOrder);

// Assert: 商品が1件になっていることを確認
$refetchedOrder = $repository->findById(new OrderId(1));
$this->assertCount(1, $refetchedOrder->orderLines());
}
}
テスト戦略のまとめ
テスト対象DB使用モックテスト目的
ドメイン層不要不要ビジネスルールの正しさ
UseCase層不要使うワークフローの正しさ
Repository層使う不要永続化の正しさ
Controller層使う使う(一部)HTTPレスポンスの正しさ

テストピラミッドの原則:

  • ドメイン層のテストを最も多く書く(高速・安定)
  • UseCase層のテストを中程度書く
  • Repository層のテストは必要最小限(低速・不安定)

詳細は第17章「テスト戦略」を参照してください。

まとめ

本章では、注文システムの実装を通じて、各層の責務とデータフローを詳しく見てきました。

重要なポイント:

  1. 各層の責務を明確に分離

    • Controller: HTTPリクエスト/レスポンス変換のみ
    • UseCase: ワークフローの調整のみ
    • Domain: ビジネスルールの表現と保護のみ
    • Repository: 永続化の詳細を隠蔽するのみ
  2. データ変換の流れを理解する

    • HTTPリクエスト → Command → 値オブジェクト → ドメインエンティティ → Eloquentモデル
  3. ビジネスルールはドメイン層に集約

    • エンティティのメソッド内でルールを検証
    • 外部から不正な状態変更をさせない
  4. テストは各層の責務に応じて書く

    • ドメイン層はDBなしで高速にテスト
    • Repository層は実DBでテスト

次のチャプターでは、本書のまとめと、さらに学習を深めるためのリソースを紹介します。