💡

集約にこだわりすぎないDDD:Laravelで実践するアクション単位設計

に公開
2

はじめに

ドメイン駆動設計(DDD)を実践する際、集約(Aggregate)の概念にこだわりすぎることで、リポジトリが肥大化し、保守性が低下する問題に直面することがあります。

本記事では、Laravelで注文APIの実装例を示しながら、アクション単位設計(1プレゼンター-1ユースケース-1リポジトリ構成)で、アクションごとの独立性を高める設計の重要性とメリットを説明します。

本設計は、DDD(ドメイン駆動設計)の構造設計とADR(Action-Domain-Responder)パターンを組み合わせることで、アクション単位の独立性を実現しています。

このように、DDD(Clean Architecture)の構造設計とADRパターンを融合させ、アクション単位の独立性を高める本設計を、私は ARC(Action-Responder Clean)パターン と呼称したいと思います。

イメージ図

1. DDDにおける集約とファットリポジトリ化の問題

1.1. DDDにおける集約の重要性

ドメイン駆動設計では、「 集約(Aggregate) 」は関連するエンティティと値オブジェクトを1つの単位として扱う、重要な概念です。
集約は、ビジネスルールの整合性を保つための境界を定義し、データの一貫性を保証する役割を果たします。

例えば、注文(Order)と注文明細(OrderItem)は密接に関連しているため、1つの集約として扱うことが適切です。
この場合、OrderRepositoryが注文と注文明細の両方を管理することになります。

1.2. ファットリポジトリ化とは

しかし、集約にこだわりすぎると、以下のような問題が発生します。

// ファットリポジトリの例
class OrderRepository
{
    public function create(array $data): Order { }
    public function update(int $id, array $data): Order { }
    public function delete(int $id): void { }
    public function findById(int $id): ?Order { }
    public function findByUserId(int $userId): Collection { }
    public function findByStatus(string $status): Collection { }
    public function cancel(int $id): Order { }
    public function refund(int $id): Order { }
    public function ship(int $id): Order { }
    public function deliver(int $id): Order { }
    // ... さらに多くのメソッド
}

このように、1つのリポジトリに多くのメソッドが集約されると、以下の問題が発生します。

  1. 変更影響範囲の拡大
    • 1つのメソッドを変更する際、他のメソッドへの影響を考慮する必要がある
  2. テストの複雑化
    • 多くのメソッドを持つクラスのテストは複雑になる
  3. 責務の曖昧化
    • リポジトリが何を担当すべきかが不明確になる
  4. チーム開発での競合
    • 複数の開発者が同じファイルを編集する機会が増える

1.3. アクション単位設計の設計思想

これらの問題を解決するため、本記事では「 アクション単位設計(1プレゼンター-1ユースケース-1リポジトリ構成)」を提案します。

この設計では、各アクション(例:注文作成、注文キャンセル)に対して、専用のプレゼンター、ユースケース、リポジトリを用意します。

この設計により、各アクションが独立し、変更影響が局所化されます。
また、DDDの層構造とADR(Action-Domain-Responder)パターンを組み合わせることで、クリーンアーキテクチャの原則を実現します。

2. ADRパターンの紹介と説明

2.1. ADRパターンとは

Action-Domain-Responder(ADR)パターン 」は、Paul M. Jonesによって提唱された、MVCパターンの代替となるアーキテクチャパターンです。

ADRパターンは、HTTPリクエストの処理を3つの明確なコンポーネントに分離します。

  • Action
    • HTTPリクエストを処理するエントリーポイント(コントローラー)
  • Domain
    • ビジネスロジックとドメインモデル(エンティティ、ドメインサービス)
  • Responder
    • レスポンスデータの構築とHTTPレスポンスの生成(プレゼンター)

従来のMVCパターンとの違い

MVCパターンでは、ControllerがModelとViewの両方に依存し、責務が曖昧になりがちです。
一方、ADRパターンでは

  • Action はHTTPリクエストの受付のみを担当する
  • Domain はビジネスロジックのみを担当する
  • Responder はレスポンスの構築のみを担当する

この分離により、各コンポーネントの責務が明確になります。

2.2. ADRパターンのメリット

ADRパターンを採用することで、以下のメリットが得られます。

  1. 各コンポーネントの責務が明確
    • Action、Domain、Responderの役割が明確に分離される
  2. テストが容易
    • 各コンポーネントを独立してテストできる
  3. フレームワークに依存しない設計
    • ビジネスロジックがフレームワークから独立する
  4. ビジネスロジックとプレゼンテーションロジックの分離
    • Domain層がプレゼンテーション層に依存しない

2.3. DDDとの組み合わせ

ADRパターンをDDDの層構造にマッピングすることで、より強力な設計を実現できます。

  • Action
    • Presentation層のController(HTTPリクエストの受付)
  • Domain
    • Domain層(エンティティ、ドメインサービス)
  • Responder
    • Presentation層のPresenter(レスポンスデータの構築)

この組み合わせにより、以下のような構造が実現されます。

┌─────────────────────────────────────┐
│  Presentation層 (ADR: Action)       │
│  - Controller                       │
└──────────────┬──────────────────────┘
               │
┌──────────────▼──────────────────────┐
│  Application層                      │
│  - UseCase                          │
│  - Repository Interface             │
└──────────────┬──────────────────────┘
               │
┌──────────────▼──────────────────────┐
│  Domain層 (ADR: Domain)             │
│  - Entity                           │
│  - Domain Service                   │
└──────────────┬──────────────────────┘
               │
┌──────────────▼──────────────────────┐
│  Infrastructure層                   │
│  - Repository Implementation        │
└─────────────────────────────────────┘
               │
┌──────────────▼──────────────────────┐
│  Application層                      │
│  - UseCase                          │
│  - Repository Interface             │
└──────────────┬──────────────────────┘
               │
┌──────────────▼──────────────────────┐
│  Presentation層 (ADR: Responder)    │
│  - Presenter                        │
└─────────────────────────────────────┘

この構造により、クリーンアーキテクチャの原則(依存性の方向が内側に向かう)を実現しつつ、ADRパターンの利点も活用できます。

3. 従来の設計パターンとその問題点

3.1. 集約にこだわった設計例

DDDにおける従来の設計では、集約の概念に基づき、1つのリポジトリに複数の操作を集約することが一般的でした。

// 従来の設計:ファットリポジトリ
class OrderRepository
{
    public function create(array $data): Order
    {
        // 注文作成ロジック
    }
    
    public function update(int $id, array $data): Order
    {
        // 注文更新ロジック
    }
    
    public function cancel(int $id): Order
    {
        // 注文キャンセルロジック
    }
    
    public function refund(int $id): Order
    {
        // 返金処理ロジック
    }
    
    public function ship(int $id): Order
    {
        // 出荷処理ロジック
    }
    
    // ... さらに多くのメソッド
}

3.2. ファットリポジトリ化による問題点

この設計には、以下のような問題があります。

1. 変更影響範囲の拡大

1つのメソッドを変更する際、同じクラス内の他のメソッドへの影響を考慮する必要があります。
また、クラスが大きくなるほど、変更の影響範囲が不明確になります。

2. テストの複雑化

多くのメソッドを持つクラスは、テストが複雑になります。
各メソッドのテストを書く際、他のメソッドとの相互作用を考慮する必要があります。

3. 責務の曖昧化

リポジトリが何を担当すべきかが不明確になります。
データアクセスだけでなく、ビジネスロジックも含まれてしまう可能性があります。

4. チーム開発での競合

複数の開発者が同じファイルを編集する機会が増え、Gitのマージコンフリクトが発生しやすくなります。

4. 提案する設計パターン

4.1. アクション単位設計の詳細

本記事で提案する「 アクション単位設計(1プレゼンター-1ユースケース-1リポジトリ構成)」では、各アクション(注文作成、注文キャンセルなど)に対して、専用のコンポーネントを用意します。

  • 1プレゼンター: 各アクション専用のプレゼンター
  • 1ユースケース: 各アクション専用のユースケース
  • 1リポジトリ: 各アクション専用のリポジトリ

この設計は、各アクションごとにパーティションで区切るイメージです。

例えば、注文作成アクションと注文キャンセルアクションは、それぞれ独立したコンポーネントセット(プレゼンター、ユースケース、リポジトリ)を持ち、互いに影響を与えません。

この設計により、各アクションが独立し、変更影響が局所化されます。

4.2. 依存性逆転の法則

依存性逆転の法則(Dependency Inversion Principle)に従い、Application層にインターフェースを配置します。

  • Application層: インターフェース定義(Application/Interfaces/
  • Presentation層: インターフェースの実装(Presentation/Presenters/
  • Infrastructure層: インターフェースの実装(Infrastructure/Repositories/

これにより、Application層が外部層に依存せず、外部層がApplication層に依存する構造が実現されます。

4.3. ADRパターンとの対応

提案する設計では、ADRパターンの3つのコンポーネントが以下のように対応します。

  • Action = OrderController(HTTPリクエストの受付とルーティング)
  • Domain = Domain/Entities/Order(ビジネスルールとエンティティ)
  • Responder = CreateOrderPresenter(レスポンスデータの構築)

この対応により、ADRパターンの利点を活用しつつ、DDDの層構造を維持できます。

5. Laravel実装例(注文作成API)

5.1. ディレクトリ構造(DDD + ADRパターン)

提案する設計のディレクトリ構造は、以下の通りになります。

app/
├── Application/                    # アプリケーション層(依存性逆転の法則)
│   ├── Interfaces/                 # インターフェース定義
│   │   ├── Presenters/Order/
│   │   │   └── CreateOrderPresenterInterface.php
│   │   └── Repositories/Order/
│   │       └── CreateOrderRepositoryInterface.php
│   └── UseCases/Order/
│       └── CreateOrderUsecase.php
├── Domain/                         # ドメイン層(ADRのDomain)
│   └── Entities/Order/
│       └── Order.php
├── Infrastructure/                # インフラストラクチャ層
│   └── Repositories/Order/
│       └── CreateOrderRepository.php
├── Presentation/                  # プレゼンテーション層(ADRのResponder)
│   ├── Presenters/Order/          # ADRのResponder(レスポンスデータの構築)
│   │   └── CreateOrderPresenter.php
│   ├── Requests/Order/
│   │   └── CreateOrderRequest.php
│   └── Responses/Order/
│       └── CreateOrderResponse.php
└── Http/
    └── Controllers/                # プレゼンテーション層(ADRのAction)
        └── OrderController.php     # HTTPリクエストの受付とルーティング

5.2. 各レイヤーの役割とADRパターンとの対応

5.2.1. ADRパターンとの対応

  • Action
    • OrderController(HTTPリクエストの受付とルーティング)
  • Domain
    • Domain/Entities/Order(ビジネスルールとエンティティ)
  • Responder
    • CreateOrderPresenter(レスポンスデータの構築)

5.2.2. 依存性逆転の法則

  • Application層にインターフェースを配置する(Application/Interfaces/
  • Presentation層とInfrastructure層は、Application層のインターフェースに依存する
  • これにより、Application層が外部層に依存しない設計を実現する

依存性逆転の法則のメリット

  1. ビジネスロジックの独立性

    • Application層(ユースケース)が外部の実装詳細(データベース、フレームワーク)に依存しない
    • ビジネスロジックが純粋に保たれ、テストが容易になる
  2. 実装の交換が容易

    • リポジトリの実装を変更(例:Eloquentからクエリビルダーへ)しても、Application層のコードは変更不要
    • インターフェースが変わらない限り、実装の変更が他の層に影響しない
  3. テストの容易性

    • モックやスタブを使って、外部依存を簡単に置き換えられる
    • データベースや外部APIに依存せずに、ユースケースの単体テストが可能
  4. フレームワークからの独立性

    • Laravelから別のフレームワークに移行する際も、Application層とDomain層はそのまま再利用可能
    • ビジネスロジックがフレームワークの変更に強い

5.3. 各クラスの実装例

5.3.1. リクエストDTO

<?php

namespace App\Presentation\Requests\Order;

class CreateOrderRequest
{
    public function __construct(
        public readonly int $userId,
        public readonly array $items, // [['product_id' => 1, 'quantity' => 2], ...]
        public readonly string $shippingAddress,
        public readonly ?string $notes = null
    ) {}
}

5.3.2. レスポンスDTO

<?php

namespace App\Presentation\Responses\Order;

class CreateOrderResponse
{
    public function __construct(
        public readonly int $orderId,
        public readonly string $orderNumber,
        public readonly int $totalAmount,
        public readonly string $status,
        public readonly string $createdAt
    ) {}
}

5.3.3. プレゼンターインターフェース

アクション単位設計では、各アクションごとに専用のインターフェースを定義します。
リポジトリインターフェースが CreateOrderRepositoryInterface となっているのと同様に、プレゼンターインターフェースも CreateOrderPresenterInterface として、アクションごとに独立したインターフェースを定義します。

これにより、各アクションの責務が明確になり、変更の影響範囲が局所化されます。

<?php

```php
<?php

namespace App\Application\Interfaces\Presenters\Order;

use App\Presentation\Requests\Order\CreateOrderRequest;
use App\Presentation\Responses\Order\CreateOrderResponse;

interface CreateOrderPresenterInterface
{
    public function getRequest(): ?CreateOrderRequest;
    public function setRequest(CreateOrderRequest $request): void;
    public function getResponse(): ?CreateOrderResponse;
    public function setResponse(CreateOrderResponse $response): void;
}

5.3.4. プレゼンター実装

<?php

namespace App\Presentation\Presenters\Order;

use App\Application\Interfaces\Presenters\Order\CreateOrderPresenterInterface;
use App\Presentation\Requests\Order\CreateOrderRequest;
use App\Presentation\Responses\Order\CreateOrderResponse;

class CreateOrderPresenter implements CreateOrderPresenterInterface
{
    private ?CreateOrderRequest $request = null;
    private ?CreateOrderResponse $response = null;

    public function getRequest(): ?CreateOrderRequest
    {
        return $this->request;
    }

    public function setRequest(CreateOrderRequest $request): void
    {
        $this->request = $request;
    }

    public function getResponse(): ?CreateOrderResponse
    {
        return $this->response;
    }

    public function setResponse(CreateOrderResponse $response): void
    {
        $this->response = $response;
    }
}

5.3.5. リポジトリインターフェース

<?php

namespace App\Application\Interfaces\Repositories\Order;

use App\Domain\Entities\Order\Order;

interface CreateOrderRepositoryInterface
{
    public function createOrder(
        int $userId,
        array $items,
        string $shippingAddress,
        ?string $notes = null
    ): Order;
    
    public function findProductById(int $productId): ?object;
    public function checkStock(int $productId, int $quantity): bool;
}

5.3.6. リポジトリ実装

<?php

namespace App\Infrastructure\Repositories\Order;

use App\Application\Interfaces\Repositories\Order\CreateOrderRepositoryInterface;
use App\Domain\Entities\Order\Order;
use App\Models\Product;
use Illuminate\Support\Facades\DB;

class CreateOrderRepository implements CreateOrderRepositoryInterface
{
    public function createOrder(
        int $userId,
        array $items,
        string $shippingAddress,
        ?string $notes = null
    ): Order {
        return DB::transaction(function () use ($userId, $items, $shippingAddress, $notes) {
            // 注文エンティティの作成
            $order = new Order(
                userId: $userId,
                items: $items,
                shippingAddress: $shippingAddress,
                notes: $notes
            );
            
            // データベースへの保存処理
            // ... Eloquentを使用した保存処理
            
            return $order;
        });
    }
    
    public function findProductById(int $productId): ?object
    {
        return Product::find($productId);
    }
    
    public function checkStock(int $productId, int $quantity): bool
    {
        $product = Product::find($productId);
        return $product && $product->stock >= $quantity;
    }
}

5.3.7. ユースケース

<?php

namespace App\Application\UseCases\Order;

use App\Application\Interfaces\Presenters\Order\CreateOrderPresenterInterface;
use App\Application\Interfaces\Repositories\Order\CreateOrderRepositoryInterface;
use App\Domain\Entities\Order\Order;
use App\Presentation\Requests\Order\CreateOrderRequest;
use App\Presentation\Responses\Order\CreateOrderResponse;
use Illuminate\Support\Str;

class CreateOrderUsecase
{
    public function __construct(
        private CreateOrderRepositoryInterface $repository
    ) {}

    public function execute(CreateOrderPresenterInterface $presenter): void
    {
        $request = $presenter->getRequest();
        
        // 在庫確認
        foreach ($request->items as $item) {
            if (!$this->repository->checkStock($item['product_id'], $item['quantity'])) {
                throw new \Exception("在庫が不足しています: Product ID {$item['product_id']}");
            }
        }
        
        // 注文作成
        $order = $this->repository->createOrder(
            userId: $request->userId,
            items: $request->items,
            shippingAddress: $request->shippingAddress,
            notes: $request->notes
        );
        
        // レスポンスの構築
        $response = new CreateOrderResponse(
            orderId: $order->getId(),
            orderNumber: $order->getOrderNumber(),
            totalAmount: $order->getTotalAmount(),
            status: $order->getStatus(),
            createdAt: $order->getCreatedAt()->toDateTimeString()
        );
        
        $presenter->setResponse($response);
    }
}

5.3.8. コントローラー

<?php

namespace App\Http\Controllers;

use App\Application\Interfaces\Presenters\Order\CreateOrderPresenterInterface;
use App\Application\UseCases\Order\CreateOrderUsecase;
use App\Presentation\Requests\Order\CreateOrderRequest;
use App\Presentation\Responses\Order\CreateOrderResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class OrderController extends Controller
{
    public function create(
        Request $request,
        CreateOrderPresenterInterface $presenter,
        CreateOrderUsecase $usecase
    ): JsonResponse {
        // リクエストの検証とDTOへの変換
        $validated = $request->validate([
            'user_id' => 'required|integer',
            'items' => 'required|array',
            'items.*.product_id' => 'required|integer',
            'items.*.quantity' => 'required|integer|min:1',
            'shipping_address' => 'required|string',
            'notes' => 'nullable|string',
        ]);
        
        $createOrderRequest = new CreateOrderRequest(
            userId: $validated['user_id'],
            items: $validated['items'],
            shippingAddress: $validated['shipping_address'],
            notes: $validated['notes'] ?? null
        );
        
        // プレゼンターにリクエストを設定
        $presenter->setRequest($createOrderRequest);
        
        // ユースケースの実行
        $usecase->execute($presenter);
        
        // レスポンスの取得と返却
        $response = $presenter->getResponse();
        
        return response()->json([
            'order_id' => $response->orderId,
            'order_number' => $response->orderNumber,
            'total_amount' => $response->totalAmount,
            'status' => $response->status,
            'created_at' => $response->createdAt,
        ], 201);
    }
}

5.3.9. サービスプロバイダー(DI設定)

<?php

namespace App\Providers;

use App\Application\Interfaces\Presenters\Order\CreateOrderPresenterInterface;
use App\Application\Interfaces\Repositories\Order\CreateOrderRepositoryInterface;
use App\Infrastructure\Repositories\Order\CreateOrderRepository;
use App\Presentation\Presenters\Order\CreateOrderPresenter;
use Illuminate\Support\ServiceProvider;

class OrderServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // リポジトリのバインディング(singleton)
        $this->app->singleton(
            CreateOrderRepositoryInterface::class,
            CreateOrderRepository::class
        );
        
        // プレゼンターのバインディング
        $this->app->bind(
            CreateOrderPresenterInterface::class,
            CreateOrderPresenter::class
        );
    }
}

5.3.10. 設計選択:Responderの責務の範囲について

現在の実装では、コントローラーが CreateOrderResponse から各プロパティを取り出して JsonResponse を構築しています。
ADRパターンにおけるResponderの責務をより徹底するなら、CreateOrderPresenter が直接 JsonResponse を返すようにすることも検討できます。

現在のアプローチ(推奨)

  • Presenterは CreateOrderResponse を返す
  • コントローラーが JsonResponse を構築
  • メリット
    • Presenterがフレームワーク(Laravel)に依存しない、テストが容易になる
  • デメリット
    • コントローラーにレスポンス構築の責務が残る

代替アプローチ(より徹底したADRパターン)

  • Presenterが直接 JsonResponse を返す
  • コントローラーは $presenter->toResponse() を呼び出すだけ
  • メリット
    • Responderの責務が完全にPresenterに集約される、コントローラーがさらに薄くなる
  • デメリット
    • Presenterがフレームワークに依存する、テスト時にフレームワークのモックが必要になる

どちらのアプローチも有効ですが、フレームワークからの独立性を重視する場合は現在のアプローチを、ADRパターンの徹底を重視する場合は代替アプローチを選択できます。

代替アプローチの実装例

// CreateOrderPresenterInterface
interface CreateOrderPresenterInterface
{
    public function getRequest(): ?CreateOrderRequest;
    public function setRequest(CreateOrderRequest $request): void;
    public function setResponse(CreateOrderResponse $response): void;
    public function toResponse(): JsonResponse; // 追加
}

// CreateOrderPresenter
class CreateOrderPresenter implements CreateOrderPresenterInterface
{
    // ... 既存のコード ...
    
    public function toResponse(): JsonResponse
    {
        $response = $this->getResponse();
        return response()->json([
            'order_id' => $response->orderId,
            'order_number' => $response->orderNumber,
            'total_amount' => $response->totalAmount,
            'status' => $response->status,
            'created_at' => $response->createdAt,
        ], 201);
    }
}

// OrderController
public function create(
    Request $request,
    CreateOrderPresenterInterface $presenter,
    CreateOrderUsecase $usecase
): JsonResponse {
    // ... リクエストの検証とDTOへの変換 ...
    
    $presenter->setRequest($createOrderRequest);
    $usecase->execute($presenter);
    
    return $presenter->toResponse(); // シンプルに
}

6. アクション単位設計のメリット

提案する設計により、以下のメリットが得られます。

6.1. 独立性

各アクションが独立しており、変更影響が局所化されます。
注文作成のロジックを変更する際、注文キャンセルのロジックに影響を与えることはありません。

6.2. テスタビリティ

モックが容易で、単体テストが書きやすくなります。
各コンポーネントを独立してテストできるため、テストの保守性も向上します。

6.3. 保守性

機能追加・変更時の影響範囲が明確になります。
新機能を追加する際、既存のコードへの影響を最小限に抑えられます。

6.4. 可読性

各クラスの責務が明確で、コードの理解が容易になります。
アクション単位設計により、1クラスのステップ数が少なくなる(概ね200〜300ステップ程度)ため、コードを読み通しやすくなります。
新しいメンバーがプロジェクトに参加した際も、コードベースの理解が速やかに進みます。

6.5. スケーラビリティ

チーム開発での競合が減り、並行開発が容易になります。
複数の開発者が異なるアクションを並行して開発できます。

6.6. 依存性の明確化

依存性逆転の法則により、依存関係が明確で変更に強い設計になります。
インターフェースを変更しない限り、実装の変更が他の層に影響を与えません。

6.7. ADRパターンの利点

Action、Domain、Responderの分離により、各コンポーネントの責務が明確になります。
ビジネスロジックがフレームワークから独立するため、フレームワークの変更に対する耐性も向上します。

7. 実践的なTips

7.1. Laravelのサービスコンテナを活用したDI設定のベストプラクティス

  1. サービスプロバイダーでバインディング
    • 各機能ごとにサービスプロバイダーを作成し、依存関係を明確に管理する
  2. インターフェースの活用
    • 実装ではなくインターフェースに依存することで、テスト時のモック化が容易になる
  3. シングルトン vs 都度生成
    • プレゼンターは都度生成、リポジトリはシングルトンなど、適切なライフサイクルを選択する

7.2. テストコードの書き方(モックの活用)

アクション単位設計により、テストコードが書きやすくなります。
各コンポーネントが独立しているため、以下のメリットがあります。

  1. 依存関係が明確
    • 各アクションの依存関係が明確なため、必要なモックを特定しやすい
  2. テストの範囲が明確
    • 1つのアクションに焦点を当てたテストが書きやすい
  3. モックの作成が容易
    • インターフェースに依存しているため、モックの作成が簡単
  4. テストの実行が高速
    • データベースや外部APIに依存せず、単体テストが高速に実行できる

7.2.1. ユースケースのテスト例

<?php

namespace Tests\Unit\Application\UseCases\Order;

use App\Application\Interfaces\Repositories\Order\CreateOrderRepositoryInterface;
use App\Application\UseCases\Order\CreateOrderUsecase;
use App\Domain\Entities\Order\Order;
use App\Presentation\Presenters\Order\CreateOrderPresenter;
use App\Presentation\Requests\Order\CreateOrderRequest;
use App\Presentation\Responses\Order\CreateOrderResponse;
use Carbon\Carbon;
use Mockery;
use Tests\TestCase;

class CreateOrderUsecaseTest extends TestCase
{
    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }

    public function test_execute_creates_order_successfully(): void
    {
        // リポジトリのモック
        $repository = Mockery::mock(CreateOrderRepositoryInterface::class);
        $repository->shouldReceive('checkStock')
            ->with(1, 2)
            ->once()
            ->andReturn(true);
        $repository->shouldReceive('createOrder')
            ->with(
                1,
                [['product_id' => 1, 'quantity' => 2]],
                'Tokyo, Japan',
                null
            )
            ->once()
            ->andReturn($this->createMockOrder());
        
        // ユースケースの実行
        $usecase = new CreateOrderUsecase($repository);
        $presenter = new CreateOrderPresenter();
        $presenter->setRequest(new CreateOrderRequest(
            userId: 1,
            items: [['product_id' => 1, 'quantity' => 2]],
            shippingAddress: 'Tokyo, Japan'
        ));
        
        $usecase->execute($presenter);
        
        // アサーション
        $response = $presenter->getResponse();
        $this->assertInstanceOf(CreateOrderResponse::class, $response);
        $this->assertEquals(1, $response->orderId);
        $this->assertEquals('ORD-2024-001', $response->orderNumber);
        $this->assertEquals(10000, $response->totalAmount);
        $this->assertEquals('pending', $response->status);
    }

    public function test_execute_throws_exception_when_stock_is_insufficient(): void
    {
        // リポジトリのモック(在庫不足の場合)
        $repository = Mockery::mock(CreateOrderRepositoryInterface::class);
        $repository->shouldReceive('checkStock')
            ->with(1, 2)
            ->once()
            ->andReturn(false);
        $repository->shouldNotReceive('createOrder');
        
        // ユースケースの実行
        $usecase = new CreateOrderUsecase($repository);
        $presenter = new CreateOrderPresenter();
        $presenter->setRequest(new CreateOrderRequest(
            userId: 1,
            items: [['product_id' => 1, 'quantity' => 2]],
            shippingAddress: 'Tokyo, Japan'
        ));
        
        // 例外がスローされることを確認
        $this->expectException(\Exception::class);
        $this->expectExceptionMessage('在庫が不足しています: Product ID 1');
        
        $usecase->execute($presenter);
    }

    private function createMockOrder(): Order
    {
        return new Order(
            id: 1,
            orderNumber: 'ORD-2024-001',
            userId: 1,
            items: [['product_id' => 1, 'quantity' => 2]],
            totalAmount: 10000,
            status: 'pending',
            shippingAddress: 'Tokyo, Japan',
            createdAt: Carbon::now()
        );
    }
}

7.2.2. コントローラーのテスト例

<?php

namespace Tests\Unit\Http\Controllers;

use App\Application\UseCases\Order\CreateOrderUsecase;
use App\Presentation\Presenters\Order\CreateOrderPresenter;
use App\Presentation\Responses\Order\CreateOrderResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Mockery;
use Tests\TestCase;

class OrderControllerTest extends TestCase
{
    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }

    public function test_create_returns_success_response(): void
    {
        // モックの準備
        $presenter = Mockery::mock(CreateOrderPresenter::class);
        $usecase = Mockery::mock(CreateOrderUsecase::class);
        
        $request = new Request([
            'user_id' => 1,
            'items' => [['product_id' => 1, 'quantity' => 2]],
            'shipping_address' => 'Tokyo, Japan'
        ]);
        
        $response = new CreateOrderResponse(
            orderId: 1,
            orderNumber: 'ORD-2024-001',
            totalAmount: 10000,
            status: 'pending',
            createdAt: '2024-01-01 12:00:00'
        );
        
        // モックの設定
        $presenter->shouldReceive('setRequest')
            ->once();
        $presenter->shouldReceive('getResponse')
            ->once()
            ->andReturn($response);
        $usecase->shouldReceive('execute')
            ->with($presenter)
            ->once();
        
        // コントローラーの実行
        $controller = new \App\Http\Controllers\OrderController();
        $result = $controller->create($request, $presenter, $usecase);
        
        // アサーション
        $this->assertInstanceOf(JsonResponse::class, $result);
        $this->assertEquals(201, $result->getStatusCode());
        $data = json_decode($result->getContent(), true);
        $this->assertEquals(1, $data['order_id']);
        $this->assertEquals('ORD-2024-001', $data['order_number']);
        $this->assertEquals(10000, $data['total_amount']);
    }
}

7.2.3. リポジトリのテスト例(ユニットテスト)

リポジトリもモックを使ったユニットテストとして書くことができます。
リポジトリが依存しているEloquentモデルやデータベース接続をモック化することで、データベースに依存せずにテストできます。

<?php

namespace Tests\Unit\Infrastructure\Repositories\Order;

use App\Domain\Entities\Order\Order;
use App\Infrastructure\Repositories\Order\CreateOrderRepository;
use App\Models\Product;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Mockery;
use Tests\TestCase;

class CreateOrderRepositoryTest extends TestCase
{
    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }

    public function test_create_order_returns_order_entity(): void
    {
        // DBファサードのモック
        DB::shouldReceive('transaction')
            ->once()
            ->andReturnUsing(function ($callback) {
                return $callback();
            });
        
        // Productモデルのモック
        $product = Mockery::mock(Product::class);
        $product->id = 1;
        $product->price = 5000;
        $product->stock = 10;
        
        Product::shouldReceive('find')
            ->with(1)
            ->once()
            ->andReturn($product);
        
        // リポジトリの実行
        $repository = new CreateOrderRepository();
        $order = $repository->createOrder(
            userId: 1,
            items: [['product_id' => 1, 'quantity' => 2]],
            shippingAddress: 'Tokyo, Japan',
            notes: null
        );
        
        // アサーション
        $this->assertInstanceOf(Order::class, $order);
        $this->assertEquals(1, $order->getUserId());
        $this->assertEquals('Tokyo, Japan', $order->getShippingAddress());
    }

    public function test_find_product_by_id_returns_product(): void
    {
        // Productモデルのモック
        $product = Mockery::mock(Product::class);
        $product->id = 1;
        $product->name = 'Test Product';
        
        Product::shouldReceive('find')
            ->with(1)
            ->once()
            ->andReturn($product);
        
        // リポジトリの実行
        $repository = new CreateOrderRepository();
        $result = $repository->findProductById(1);
        
        // アサーション
        $this->assertNotNull($result);
        $this->assertEquals(1, $result->id);
    }

    public function test_find_product_by_id_returns_null_when_not_found(): void
    {
        // Productモデルのモック(見つからない場合)
        Product::shouldReceive('find')
            ->with(999)
            ->once()
            ->andReturn(null);
        
        // リポジトリの実行
        $repository = new CreateOrderRepository();
        $result = $repository->findProductById(999);
        
        // アサーション
        $this->assertNull($result);
    }

    public function test_check_stock_returns_true_when_stock_is_sufficient(): void
    {
        // Productモデルのモック
        $product = Mockery::mock(Product::class);
        $product->stock = 10;
        
        Product::shouldReceive('find')
            ->with(1)
            ->once()
            ->andReturn($product);
        
        // リポジトリの実行
        $repository = new CreateOrderRepository();
        $result = $repository->checkStock(1, 5);
        
        // アサーション
        $this->assertTrue($result);
    }

    public function test_check_stock_returns_false_when_stock_is_insufficient(): void
    {
        // Productモデルのモック
        $product = Mockery::mock(Product::class);
        $product->stock = 3;
        
        Product::shouldReceive('find')
            ->with(1)
            ->once()
            ->andReturn($product);
        
        // リポジトリの実行
        $repository = new CreateOrderRepository();
        $result = $repository->checkStock(1, 5);
        
        // アサーション
        $this->assertFalse($result);
    }

    public function test_check_stock_returns_false_when_product_not_found(): void
    {
        // Productモデルのモック(見つからない場合)
        Product::shouldReceive('find')
            ->with(999)
            ->once()
            ->andReturn(null);
        
        // リポジトリの実行
        $repository = new CreateOrderRepository();
        $result = $repository->checkStock(999, 5);
        
        // アサーション
        $this->assertFalse($result);
    }
}

このように、リポジトリもモックを使ったユニットテストとして書くことで、以下のメリットがあります。

  • テストの実行が高速
    • データベースにアクセスしないため、テストが高速に実行される
  • テストの独立性
    • データベースの状態に依存せず、テストが独立して実行できる
  • エッジケースのテストが容易
    • データベースエラーや存在しないデータなどのエッジケースを簡単にテストできる

これらのテスト例からも分かるように、アクション単位設計により、各コンポーネントを独立してテストでき、テストコードの作成と保守が容易になります。

7.3. 拡張時の注意点(新機能追加時の設計指針)

  1. 新しいアクションには新しいコンポーネント
    • 注文キャンセル機能を追加する際は、CancelOrderPresenterCancelOrderUsecaseCancelOrderRepositoryInterface を新規作成する
  2. 既存コンポーネントの変更を避ける
    • 既存のコンポーネントを変更せず、新しいコンポーネントを作成することで、影響範囲を最小化する
  3. 共通処理の抽出
    • 複数のアクションで共通する処理は、ドメインサービスやアプリケーションサービスとして抽出する

7.3.1. 実装例:共通処理の抽出

複数のアクションで共通する処理(例:価格計算、在庫チェック)は、サービスとして抽出します。

ドメインサービスの例(価格計算)

<?php

namespace App\Domain\Services\Order;

use App\Domain\Entities\Order\OrderItem;

class OrderPriceCalculationService
{
    /**
     * 注文アイテムの合計金額を計算する
     */
    public function calculateTotalAmount(array $items): int
    {
        $total = 0;
        foreach ($items as $item) {
            $product = $this->getProduct($item['product_id']);
            $subtotal = $product->price * $item['quantity'];
            $total += $subtotal;
        }
        return $total;
    }
    
    /**
     * 配送料を計算する
     */
    public function calculateShippingFee(int $totalAmount, string $shippingAddress): int
    {
        // 配送料計算ロジック
        if ($totalAmount >= 10000) {
            return 0; // 送料無料
        }
        return 500; // 一律500円
    }
}

アプリケーションサービスの例(在庫チェック)

<?php

namespace App\Application\Services\Order;

use App\Application\Interfaces\Repositories\Order\CreateOrderRepositoryInterface;

class StockCheckService
{
    public function __construct(
        private CreateOrderRepositoryInterface $repository
    ) {}
    
    /**
     * 複数の商品の在庫を一括チェックする
     */
    public function checkStocks(array $items): array
    {
        $results = [];
        foreach ($items as $item) {
            $results[] = [
                'product_id' => $item['product_id'],
                'available' => $this->repository->checkStock(
                    $item['product_id'],
                    $item['quantity']
                )
            ];
        }
        return $results;
    }
    
    /**
     * 在庫が不足している商品を取得する
     */
    public function getOutOfStockItems(array $items): array
    {
        $results = $this->checkStocks($items);
        return array_filter($results, fn($result) => !$result['available']);
    }
}

ユースケースでの利用例

<?php

namespace App\Application\UseCases\Order;

use App\Application\Interfaces\Presenters\Order\CreateOrderPresenterInterface;
use App\Application\Interfaces\Repositories\Order\CreateOrderRepositoryInterface;
use App\Application\Services\Order\StockCheckService;
use App\Domain\Services\Order\OrderPriceCalculationService;

class CreateOrderUsecase
{
    public function __construct(
        private CreateOrderRepositoryInterface $repository,
        private StockCheckService $stockCheckService,
        private OrderPriceCalculationService $priceCalculationService
    ) {}
    
    public function execute(CreateOrderPresenterInterface $presenter): void
    {
        $request = $presenter->getRequest();
        
        // 共通サービスを使用した在庫チェック
        $outOfStockItems = $this->stockCheckService->getOutOfStockItems($request->items);
        if (!empty($outOfStockItems)) {
            throw new \Exception("在庫が不足しています");
        }
        
        // 共通サービスを使用した価格計算
        $totalAmount = $this->priceCalculationService->calculateTotalAmount($request->items);
        $shippingFee = $this->priceCalculationService->calculateShippingFee(
            $totalAmount,
            $request->shippingAddress
        );
        
        // 注文作成処理...
    }
}

このように、共通処理をサービスとして抽出することで、複数のアクションで再利用でき、コードの重複を避けられます。

7.4. パフォーマンスへの影響と対策

  1. リポジトリの最適化
    • 各アクション専用のリポジトリにより、必要なデータのみを取得できる
  2. キャッシュの活用
    • 頻繁にアクセスされるデータは、リポジトリ層でキャッシュを実装する
  3. N+1問題の回避
    • Eloquentの with() メソッドなどを活用し、必要な関連データを事前に取得する

7.5. ADRパターンとDDDの組み合わせ方の実践的なガイドライン

  1. Action層の薄さを保つ
    • ControllerはHTTPリクエストの受付とルーティングのみを担当し、ビジネスロジックを含めない
  2. Domain層の純粋性
    • Domain層は外部の層に依存せず、純粋なビジネスロジックのみを含める
  3. Responder層の独立性
    • Presenterはレスポンスデータの構築のみを担当し、ビジネスロジックを含める

8. まとめ

本記事では、DDDにおける集約にこだわりすぎることのリスクと、アクション単位の独立性を重視した設計の重要性を説明しました。

本記事で紹介したアクション単位設計は、長期的な保守性とスケーラビリティを重視するプロジェクトにおいて、強く推奨される設計アプローチです。
特に、複数の開発者が並行して作業する大規模なプロジェクトや、長期間にわたって機能追加・変更が継続されるサービスにおいて、その真価を発揮します。

もし、現在のプロジェクトで設計方針に悩まれていたり、DDDの実践方法について迷いがある場合は、ぜひ本記事で紹介したアクション単位設計を取り入れて実践してみてください。

この設計は、理論的な理想論ではなく、実際のプロジェクトで直面する課題を解決するための実践的なアプローチです。
小さな機能から始めて、徐々に適用範囲を広げていくことで、その効果を実感していただけるかと思います。

8.1. 長期的なサービスにこそ適用すべき設計

アクション単位設計は、「 長く続けさせていくサービスにこそ適用すべき設計 」だと考えています。

サービスが長期間運用されるにつれて、以下のような課題が発生します。

  • 機能の追加・変更が頻繁に発生する
    • ビジネス要件の変化に応じて、新機能の追加や既存機能の変更が繰り返される
  • チームメンバーの入れ替わり
    • プロジェクトの長期化により、開発メンバーが入れ替わり、新しいメンバーがコードベースを理解する必要がある
  • 技術スタックの進化
    • フレームワークやライブラリのバージョンアップ、新しい技術の導入が必要になる
  • 複数の開発者が並行して作業する
    • 大規模なチームでは、複数の開発者が同時に異なる機能を開発する

アクション単位設計は、これらの課題に対して以下のように優位性を発揮します。

  • 機能追加時の影響範囲が明確
    • 新しいアクションを追加する際、既存のコードへの影響を最小限に抑えられる
  • コードの理解が容易
    • 各アクションが独立しているため、新しいメンバーも特定の機能に焦点を当てて理解できる
  • フレームワークからの独立性
    • ビジネスロジックがフレームワークから独立しているため、技術スタックの変更にも柔軟に対応できる
  • 並行開発の促進
    • 各アクションが独立しているため、複数の開発者が異なるアクションを同時に開発しても競合が発生しにくい

短期的なプロトタイプや小規模なプロジェクトでは、この設計のメリットを実感しにくいかもしれません。
しかし、「 数年、数十年と長く運用し続けるサービス 」においては、アクション単位設計の価値が時間とともに増大していきます。

8.2. 重要なポイント

  1. 集約にこだわりすぎることのリスク
    • ファットリポジトリ化により、保守性が低下し、チーム開発での競合が発生しやすくなる
  2. アクション単位設計の重要性
    • アクション単位設計(1プレゼンター-1ユースケース-1リポジトリ構成)により、各アクションが独立し、変更影響が局所化される
  3. DDDとADRパターンの組み合わせによる設計の優位性
    • ADRパターンとDDDの層構造を組み合わせることで、クリーンアーキテクチャの原則を実現しつつ、各コンポーネントの責務を明確にできる
  4. 依存性逆転の法則の実践的な活用
    • Application層にインターフェースを配置することで、外部層がApplication層に依存する構造を実現し、変更に強い設計を実現できる

8.3. 実践的な設計指針

  • アクション単位設計に従い、各アクションに対して専用のコンポーネントを作成する
  • 依存性逆転の法則に従い、Application層にインターフェースを配置する
  • ADRパターンとDDDの層構造を組み合わせる
  • テスト容易性と保守性を重視する

この設計により、スケーラブルで保守性の高いアプリケーションを構築できます。

Discussion

shunsukeshunsuke

ARDパターンを適用するというのは良いと思います。

ただこのリポジトリがファットになっているのは、リポジトリにドメイン知識が含まれており責務が混在しているからだと思います。

DDDのリポジトリは永続化が責務であるので、基本的にはsave、delete、findByXXXもしくはadd,update、delete、findByXXXだけになります。(insertとupdeteを区別するか否か
)

cancelやshipなどはビジネスロジックのため、集約が持つべきで、ユースケースで繋げて取得 → ビジネスロジック実行 → 保存とするべきだと思います。

t.matsudat.matsuda

ありがとうございます!
確かに、責務の分解が上手くできていないためですね。

今回はユースケース駆動+クリーンアーキテクチャを基本に、DDDの要素を必要に応じて取り入れた実践方法なので、集約に関してはとても緩くなっています。

ご教授いただいた内容を踏まえ、集約をしっかり理解した上で、純粋なDDDも実践してみようと思います。
大変勉強になりました。ありがとうございます。