25
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ミライトデザインAdvent Calendar 2024

Day 12

Laravel から一歩先へ。クリーンアーキテクチャによる柔軟な設計パターン

Last updated at Posted at 2024-12-12

Rectangle を使ってましたが、他にも知らないウィンドウマネージャーがあって勉強になりました。
時間ある時にデスクトップ環境を見直してみようと思います!

導入

この記事ではLaravelの便利で強力なシステムの恩恵を受けつつ、
クリーンアーキテクチャのWebシステムの構築に必要な概念を取り入れて
フレームワークに依存しない良いとこ取りしたパッケージ構成を紹介します。

私自身、クリーンアーキテクチャについて勉強した際にLaravelプロジェクトと合わせるにはどのようなパッケージ構成にしたら良いのだろうと悩んでいました。この記事が設計・開発する際のヒントになれば幸いです。

対象読者

  • クリーンアーキテクチャ、ドメイン駆動設計など設計に興味がある方
  • 設計した内容をどうLaravelとうまく付き合っていくか悩んでいる方

参考

参考サイト

参考書籍

背景

LaravelはMVCパターンを採用したフレームワークです。

MVCパターンとは

Model View Controller

MVCパターンは、アプリケーションを以下の3つの主要な役割に分割するデザインパターンです。

  • Model(モデル)
    • データやビジネスロジックを管理
    • データベースとのやり取りや、アプリケーションの状態を保持
  • View(ビュー)
    • ユーザーに表示する画面部分を管理
    • Modelから渡されたデータを表示するだけで、ロジックは最小限
  • Controller(コントローラー)
    • ユーザーの入力を受け取り、ModelとViewを調整
    • リクエストを処理し、適切なModelのデータを取得してViewに渡す

MVCパターンの課題とは

MVCパターンの課題として挙げられるのは次の課題です。

  • Fat Controller, Fat Model 問題
  • 整理整頓されていないビジネスロジック
  • テストの困難さ
  • フレームワークへの依存
  • 保守性

実際の開発ではプロジェクトが複雑化するにつれFat Model、Fat Controllerという責務の集中といった問題が発生します。

そうなるとユニットテストのコストが高くなりますし、そもそもテストコードを書けない事態に陥りやすいです。

また、各層がフラットなためロジックの再利用難しく、メンテナンス性も犠牲にしがちです。

小規模開発であったり、CRUDにちょっとした機能しかないようなビジネスロジックの少ないものであればそれでMVCパターンだけで十分な場合はもちろんあります。

そもそも我々がフレームワークを使う理由はなんだったのか?

ルーティング、認証、データベース操作、サービスコンテナといったWeb開発では必須となるような共通の機能がフレームワークに予め組み込まれています。
そのため開発者は業務ロジック(ビジネスロジック)に集中でき、開発効率が上がるからです。

また、フレームワークの機能を正しく使っていればセキュリティ面も保証されます。

ドキュメントがしっかり用意されているフレームワークであれば、学習コストが下がります。
逆にオレオレ自作フレームワークだとドキュメントがなく、メンテされてないとセキュリティ面も心配ですね。

あとは人気なフレームワークであれば利用者の数が多いはずなので、採用しやすそうな印象を持ちます。

フレームワークに依存した設計は問題があるのか?

フレームワークの寿命

フレームワークに依存するといくつか問題があります。

まずはフレームワーク自体の寿命が考えられます。
PHPフレームワークはLaravel以外にもSymfony, CakePHP, Zend Framework, Yii Framework, FuelPHP, CodeIgniter, Phalcon, Slim等数多くのフレームワークがあります。サポートが終了してしまったフレームワークもあります。

Laravelも現在は人気ですが、5年後10年後もサポートが続くか分かりません。しかし作成したサービスは5年後10年後も使用されていく可能性があります。

PHP自体が廃れてしまう可能性も考えられますが、フレームワークが廃れる可能性よりは確実に低いでしょう。

テスタビリティの低下

フレームワークに依存してしまうとユニットテストを行うことが困難です。
コントローラにビジネスロジックが書かれていれば、ビジネスロジックの部分だけユニットテストをすることは不可能ですし、諦めて機能テストするしかありません。

その場合は1つ1つのテストが重くなり、プロジェクトが進むたびに指数関数的にテストの実行に時間がかかってしまいます。
たとえば GitHub Actions で CI を行っている場合、テストの実行時間が長くなると無料枠の時間上限を超えてしまう可能性があります。

特にデータベースのテストは重たくなるため、データベースが必要となる機能テストは必要最小限にして、ユニットテストを厚くしてテストの負荷を下げて開発したいものです。

用語などのおさらい

ビジネスロジック

ビジネスロジックとは、アプリケーションが「何をするべきか」を決めるビジネスルールや振る舞いのことです。
例えば、「在庫がなければ購入できない」といったシステムの核心となる考え方をコードにした部分です。

ビジネスロジックはフレームワークに依存せず、独立して設計されるべきです。
フレームワークはビジネスロジックを動かす環境を提供するだけのサポート役に徹してもらうのが良いとされます。

具体的なビジネスロジックのコード

app/Http/Controllers/OrderController.php

app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;

use App\Service\PlaceOrderService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;

final class OrderController extends Controller
{
    public function placeOrder(Request $request, PlaceOrderService $service): JsonResponse
    {
        // HTTPリクエストのバリデーション(これはビジネスロジックではない)
        $validatedData = $request->validate([
            'product_id' => 'required|integer',
            'quantity' => 'required|integer|min:1',
        ]);

        // ビジネスロジックの呼び出し
        $orderResult = $service->placeOrder($validatedData['product_id'], $validatedData['quantity']);

        return new JsonResponse($orderResult);
    }
}
app/Service/OrderService.php
namespace App\Service;

use App\Models\Product;
use App\Models\Order;
use Exception;

final class PlaceOrderService
{
    /**
     * 注文する
     */
    public function placeOrder(int $productId, int $quantity): array
    {
        // 商品が存在するか確認(ビジネスロジック)
        $product = Product::find($productId);
        if (!$product) {
            throw new Exception("Product not found.");
        }

        // 在庫が足りるか確認(ビジネスロジック)
        if ($product->stock < $quantity) {
            throw new Exception("Not enough stock.");
        }

        // 在庫を減らして注文を作成(ビジネスロジック)
        $product->decrement('stock', $quantity);

        // 注文を保存(これはビジネスロジックに関連付けられる処理の一部。技術的実装)
        $order = Order::create([
            'product_id' => $productId,
            'quantity' => $quantity,
        ]);

        return [
            'success' => true,
            'order_id' => $order->id,
        ];
    }
}

DTO(Data Transfer Object)

  • データの受け渡し専用クラス
  • DTOにはビジネスロジックを含まない
  • オブジェクト指向プログラミングで用いられるデザインパターンの一つ

DTOの具体的なサンプルコード

app/DTO/PlaceOrderServiceInput.php

app/DTO/PlaceOrderServiceInput.php
namespace App\DTO;

final readonly class PlaceOrderServiceInput
{
    public function __construct(
        public int $productId,
        public int $quantity
    ) {
    }
}

app/DTO/PlaceOrderServiceOutput.php

app/DTO/PlaceOrderServiceOutput.php
namespace App\DTO;

final class PlaceOrderServiceOutput
{
    public function __construct(
        public int $orderId,
    ) {
    }
}

app/Service/PlaceOrderService.php

app/Service/PlaceOrderService.php
namespace App\Service;

use App\Models\Product;
use App\Models\Order;
use App\DTO\PlaceOrderServiceInput;
use App\DTO\PlaceOrderServiceOutput;
use Exception;

final class PlaceOrderService
{
    /**
     * 注文する
     */
    public function placeOrder(PlaceOrderServiceInput $input): PlaceOrderServiceOutput
    {
        // 商品が存在するか確認(ビジネスロジック)
        $product = Product::find($input->productId);

        if (!$product) {
            throw new Exception("Product not found.");
        }

        // 在庫が足りるか確認(ビジネスロジック)
        if ($product->stock < $input->quantity) {
            throw new Exception("Not enough stock.");
        }

        // 在庫を減らして注文を作成(ビジネスロジック)
        $product->decrement('stock', $input->quantity);

        // 注文を保存(技術的実装)
        $order = Order::create([
            'product_id' => $input->productId,
            'quantity' => $input->quantity,
        ]);

        return new PlaceOrderServiceOutput(orderId: $order->id);
    }
}
  • DTOのメリット
    • プロパティはコンストラクタでのみ設定する
    • 引数や戻り値の構造が明確になり、間違ったデータの渡し間違いを防ぐ
    • 入力や出力の項目が増減した際にDTOを拡張するだけで済む
  • PHPの補足
    • final クラスはPHP8.1以降で利用可能です
      • DTOはシンプルな構成にしたいので継承して拡張するのはやめましょう
    • readonly クラスはPHP8.2以降で利用可能です
      • インスタンスしたら値は変更されないことが保証されます

DTOを使うとコントローラのコードがどうなるか、見てみましょう。

app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;

use App\Service\PlaceOrderService;
use App\DTO\PlaceOrderServiceInput;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;

final class OrderController extends Controller
{
    public function placeOrder(Request $request, PlaceOrderService $service): JsonResponse
    {
        // HTTPリクエストのバリデーション(これはビジネスロジックではない)
        $validatedData = $request->validate([
            'product_id' => 'required|integer',
            'quantity' => 'required|integer|min:1',
        ]);

        // 入力DTOを作成
        $serviceInput = new PlaceOrderServiceInput(
            productId: $validatedData['product_id'],
            quantity: $validatedData['quantity']
        );

        // サービスを呼び出して出力DTOを取得
        try {
            $serviceOutput = $service->placeOrder($serviceInput);

            // JSON形式でレスポンスを返す
            return new JsonResponse([
                'success' => true,
                'order_id' => $serviceOutput->orderId,
                'error_message' => $serviceOutput->errorMessage,
            ]);
        } catch (Exception $e) {
            // JSON形式でレスポンスを返す
            return new JsonResponse([
                'success' => false,
                'order_id' => $serviceOutput->orderId,
                'error_message' => $e->getMessage(),
            ]);
        }
    }
}

入力DTO(PlaceOrderServiceInput)をコントローラーで生成し、サービスに渡すことで、明確な責任分担を実現しています。
サービスの結果である出力DTO(PlaceOrderServiceOutput)から適切なレスポンスデータを生成しています。

tests/Unit/PlaceOrderServiceTest.php
namespace Tests\Unit;

use Tests\TestCase;
use App\Models\Product;
use App\Models\Order;
use App\Service\PlaceOrderService;
use App\DTO\PlaceOrderServiceInput;
use App\DTO\PlaceOrderServiceOutput;
use Mockery;
use Mockery\MockInterface;

final class PlaceOrderServiceTest extends TestCase
{
    public function test商品が存在しない場合(): void
    {
        // テストデータの準備
        $productId = 999;
        $quantity = 1;

        // 商品が見つからないことをモックする
        Product::shouldReceive('find')
            ->with($productId)
            ->andReturnNull();

        // サービス
        $service = $this->app->make(PlaceOrderService::class);

        // テスト実行
        $input = new PlaceOrderServiceInput(
            productId: $productId,
            quantity: $quantity
        );
        $output = $service->placeOrder($input);

        // 検証
        $this->assertInstanceOf(PlaceOrderServiceOutput::class, $output);
        $this->assertFalse($output->success);
        $this->assertNull($output->orderId);
        $this->assertEquals('Product not found.', $output->errorMessage);
    }
}

テストコードの例ですが、サービスクラスのテストがとても簡潔になります。

// DTOなしの場合
$service->placeOrder($productId, $quantity, $userId);

// DTOありの場合
$input = new PlaceOrderServiceInput($productId, $quantity, $userId);
$service->placeOrder($input);

引数が複数ある場合でも DTO にまとめられるため、後から引数が増えた場合の変更範囲を最小化できます。

アクター(Actor)

アクターはシステムの外部からシステムを操作する存在です。
ユースケース駆動開発の主要な用語として使われます。

  • 購入者(Buyer): 商品を検索し、購入する
  • 販売者(Seller): 商品を登録し、販売状況を確認する
  • 運営者(Operator): サイト全体の管理や不正行為の監視を行う
  • 決済システム: 購入者が支払うときにシステムと連携する外部のサービス

Primary Actor(プライマリーアクター) 主にシステムを利用する存在、
Secondary Actor(セカンダリーアクター) システムを直接操作はしないが、システムが利用する外部の存在といった感じで扱われる。

依存とは

あるクラス A において、クラス B をインポートしていると
クラス A は クラス B に依存していると言えます。

コードで表すとこうなります。

class B
{
    public function doSomething()
    {
        echo "B is doing something.";
    }
}

class A
{
    private B $b;

    public function __construct()
    {
        $this->b = new B();
    }

    public function execute()
    {
        $this->b->doSomething();
    }
}

AB の実装に強く結びついており、B を変更すると A にも影響が及びます。
要はusenewしてクラスを使っていたらそのクラスに依存していると言えます。

依存性注入(DI: Dependency Injection)とは

依存するオブジェクト(上記の例では B)をクラス内部で生成するのではなく、クラスの外から渡す仕組みのことです。
クラス間の結びつきを弱め、再利用性やテストのしやすさを向上できます。

class B
{
    public function doSomething(): void
    {
        echo "B is doing something.";
    }
}

class A
{
    private $b;

    public function __construct(private B $b) // 外部からBを注入
    {
        $this->b = $b;
    }

    public function execute(): void
    {
        $this->b->doSomething();
    }
}

$b = new B();
$a = new A($b); // 依存性の注入
$a->execute();

ここでは AB を自分で生成するのではなく、外部から渡されるため、A の設計が柔軟になります。
たとえば、B をモックに差し替えることで、A の動作を簡単にテストできます。

依存性逆転の原則(DIP: Dependency Inversion Principle)とは

依存性逆転の原則は、SOLID原則の一つです。

  • 高レベルモジュール(ビジネスロジックを持つクラス)は低レベルモジュール(具体的な実装クラス)に依存してはいけない
  • 高レベルモジュールは低レベルモジュールの抽象に依存するべき
interface ServiceInterface
{
    public function doSomething();
}

class B implements ServiceInterface
{
    public function doSomething(): void
    {
        echo "B is doing something.";
    }
}

class A
{
    private $service;

    public function __construct(ServiceInterface $service) { // 抽象に依存
        $this->service = $service;
    }

    public function execute(): void
    {
        $this->service->doSomething();
    }
}

$b = new B();
$a = new A($b);
$a->execute();

この設計では、AServiceInterface に依存しており、B の実装に直接依存していません。
B を別の実装(例: MockService)に差し替えることも容易になります。

クリーンアーキテクチャ(Clean Architecture)

CleanArchitecture.jpg

よく見かけるクリーンアーキテクチャの図です。

  • 黄色: Enterprise Business Rules(企業のビジネスルール、エンティティ)
    • DDDにおけるドメイン層
    • システム全体に共通する普遍的なビジネスルールやロジック
    • 最も内側に位置し、他のすべての要素から依存される
    • エンティティは何者にも依存しません
    • 他の全層がエンティティに依存します
  • 赤色: Application Business Rules(アプリケーションのビジネスルール、ユースケース)
    • DDDにおけるアプリケーション層
    • ユースケースを実現するためのルールやロジック
    • エンティティに依存しますが、それ以外には依存しません
  • 緑色: Interface Adapters(インターフェースアダプター)
    • DDDにおけるプレゼンテーション層
    • データの入出力をアプリケーション内の形式に変換する仕組み
    • エンティティとアプリケーション層に依存します
  • 青色: Frameworks & Drivers(フレームワーク&ドライバ)
    • DDDにおけるインフラ層
    • アプリケーションを外部とつなぐ実装の土台
    • すべて内側の層に依存する
    • 唯一、フレームワークの依存が許される層

クリーンアーキテクチャの考え方は次の2つです。

  • 関心を分離すること
  • 分離した構成要素の依存関係を単純にすること

関心の分離として図では4つの層が描かれていますが、書籍「クリーンアーキテクチャ」の中でも「4つに限るものではない」と書かれてあります。

重要なことは数ではなく、関心を分離することです。

また注意点として、クリーンアーキテクチャの中央には「エンティティ」の名前がついてますが、これは「ドメイン駆動設計」の「エンティティ」と意味が異なるので注意してください。

  • エンティティ
    • クリーンアーキテクチャ: ビジネスロジックの置き場をエンティティクラスとしている
    • ドメイン駆動設計: 個体を識別するクラス
      • ビジネスロジックは値オブジェクトや集約に記述するため

クリーンアーキテクチャについてはこちらの記事が読みやすかったです。

クリーンアーキテクチャの依存関係の図

ドメイン駆動設計(Domain Driven Design)

ドメイン駆動設計(DDD)は、ソフトウェア開発においてドメイン(業務領域や問題領域)を強く意識し、その複雑なビジネスロジックやルールを明示的なモデルとしてコード化する設計手法です。
DDDは、次のような考え方や要素を重視します。

1. ドメインモデルを中心に据える

システムにとって最も重要な部分である「ドメイン」に焦点を当て、そこに存在する概念(商品、注文、顧客など)やルール、手続きを明確にし、コード上に表現します。
この際、ドメインに精通した業務担当者やドメインエキスパートと密にコミュニケーションを取り、共通言語(Ubiquitous Language)を構築することで、ビジネス側とエンジニア側の理解を揃えます。

2. モデルを反映したコード構造

DDDでは、ドメインモデルをコード上で忠実に表現するため、以下のような概念を用います。

  • 値オブジェクト(Value Object): 識別子を持たず、属性値の組み合わせによって等価性が判断されるオブジェクト
    • 通貨や数量、期間など、値が等しければ同一とみなされる概念を表現する
  • エンティティ(Entity): 識別子(ID)を持ち、ライフサイクルを通して同一性が維持されるオブジェクト
  • 集約(Aggregate): 一貫性のあるルールでまとめられたエンティティや値オブジェクトの集合
    • 集約の中で整合性が保たれ、集約ルート(Aggregate Root)と呼ばれるエンティティを通じて操作が行われます
  • ドメインサービス(Domain Service): エンティティや値オブジェクトでは表現しにくい、ドメインに固有の振る舞いを行う機能
    • 集約を跨ぐ複雑なビジネスルールなどがここに記述されます
  • リポジトリ(Repository): 集約の永続化・取得を担当する抽象化された仕組み
    • ドメインモデルはリポジトリを介して永続化層にアクセスします

3. 境界づけられたコンテキスト(Bounded Context)

大規模なシステムでは、すべてを単一のモデルで表すことは困難です。DDDでは、ドメインを複数の「境界づけられたコンテキスト」に分割します。
たとえば「注文管理」「在庫管理」「決済処理」など、問題領域ごとに明確な境界を持ったコンテキストを定義し、それぞれで独立したモデルを構築します。これにより、コンテキスト間の混乱を防ぎ、各領域をより理解しやすく、保守しやすくします。

4. ユースケースとの関係

クリーンアーキテクチャと同様、DDDもドメインモデルを中心としたアプリケーション設計を重視します。
ただし、DDDのドメイン層はより豊かで表現力が高く、ビジネスロジックが分散せずドメインモデルに集約されます。
一方、ユースケース(アプリケーション層)はドメインモデルを利用してワークフローを組み立てることで、アプリケーション全体の振る舞いを実現します。

5. テスタビリティと保守性の向上

DDDは、ドメインモデルを明確にし、ビジネスロジックを純粋なオブジェクトとして表現するため、ユニットテストが容易になります。依存関係を適切に分離し、ドメイン層が外部技術要素から独立していることで、機能追加や要件変更に柔軟に対応可能です。

お題

実際に開発するときは、どんなパッケージ構成にするとクリーンアーキテクチャとLaravelがうまく組み合わさるのか?
そこで、この記事では、オンラインショッピングサイトを想定した具体例を用いて、フレームワーク依存を減らしつつ、ドメイン駆動設計(DDD)やクリーンアーキテクチャの考え方を取り入れたディレクトリ構成やクラス配置を紹介します。
アクター(「購入者(Buyer)」「販売者(Seller)」「運営者(Operator)」)を設定し、各ユースケースに対応するコード例を示しながら、実際の開発現場で参考になるディレクトリ構造や依存関係の整理方法を解説していきます。

ディレクトリ構造(Laravel)

Laravelをインストールした直後の構成です。
執筆時点のLaravelのバージョンは11.32.0です。

.
├── app # フレームワークのアプリケーション
│  ├── Console # Artisanコマンドクラス
│  ├── Exceptions # 例外クラス
│  ├── Http
│  │  ├── Controllers # コントローラ
│  │  └── Middleware # HTTPミドルウェア
│  ├── Models # Eloquentモデル
│  │  └── User.php
│  └── Providers # サービスプロバイダー
├── bootstrap # フレームワークの初期起動処理
├── config # アプリケーションの設定ファイル
├── database # データベース関連
│  ├── factories # Eloquentモデルファクトリ
│  ├── migrations # マイグレーション
│  └── seeders # シーダー
├── public # 公開ディレクトリ、index.phpやトランスパイル済みのアセットファイル
├── resources # ビュー、CSS、JavaScriptのアセットファイル
├── routes # ルーティング
├── storage # ログ、キャッシュ、ファイル等
└── tests # テストコード
   ├── Feature
   └── Unit

ディレクトリ構造(大枠)

LaravelのMVCパターン+クリーンアーキテクチャを取り入れた構成です。

.
├── app # フレームワークのアプリケーション
│  ├── Console # プレゼンテーション層
│  ├── Http
│  │  └── Controllers # プレゼンテーション層
│  ├── Models # Eloquentモデル => インフラ層でのみ使用
│  └── Providers # サービスプロバイダー
│     └── ShopServiceProvider.php # サービスコンテナ設定に使用
├── contexts # プロジェクトのコンテキスト
│  └── Shop # オンラインショッピングのコンテキスト
│     ├── Application # アプリケーション層
│     ├── Domain # ドメイン層
│     └── Infra # インフラ層
└── tests # テストコード
   └── Shop # プロダクションコードと同じ構成で配置する
      ├── Application
      └── Domain

コンテキスト

  • contexts

こちらのディレクトリはフレームワークのコードではなく、フレームワークではないプロダクションコードを管理するために用意しています。
プロジェクトルートにディレクトリを作成することでフレームワークのコードと明確に分けています。

この contexts ディレクトリには境界づけられたコンテキストを置いてます。
境界づけられたコンテキストとはDDDの用語になりますが、特定のビジネスモデルが一貫して適用される明確な責任範囲です。

今回の例ではオンラインショッピングサイトなので contexts/Shop とコンテキスト名を付けています。
例えば、このプロジェクトの規模が大きくなったり、注文管理チームや在庫管理チームみたいに分かれて開発する場合です。

  • contexts/OrderManagement
  • contexts/StockManagement

上記のようにコンテキスト毎にディレクトリを分けて良いです。
コンテキストを分けるメリットとしては同じドメインだけど、コンテキストによって中身が異なる場合です。

例を挙げると...

注文管理チームにおける Product ドメイン

  • 注文を扱う際に必要な情報を保持する
  • 顧客が購入可能な状態の商品を管理する
class Product
{
    public string $id; // 商品ID
    public string $name; // 商品名
    public float $price; // 商品価格
    public int $availableStock; // 購入可能な在庫数
}

在庫管理チームにおける Product ドメイン

  • 倉庫での在庫管理に必要な情報を保持する
  • 商品の物理的な保管状況や状態を管理する
class Product
{
    public string $id; // 商品ID
    public string $name; // 商品名
    public int $totalStock; // 総在庫数
    public int $reservedStock; // 予約済み在庫数
    public string $warehouse; // 保管倉庫の場所
}

コンテキストを分けると各コンテキストの目的に応じて、ドメインを設定できます。

ドメイン層

  • contexts/Shop/Domain

ここはドメイン層でビジネスロジックの置き場です。
最上位(内側)に位置する層です。

ここは同じドメイン層以外の層に依存しては絶対にいけません。
ドメインモデル(エンティティ、値オブジェクト)、リポジトリのインターフェース、ドメインサービスドメインイベントを配置します。

アプリケーション層

  • contexts/Shop/Application

ここはアプリケーション層で、ユーザーのユースケースやアプリケーションの機能を実現するための調整役を行います。
この層は、システム全体の中で重要な橋渡しの役割を担います。

上から2番目に位置する層で、ドメイン層に依存します。
この層もフレームワークに依存してはいけません。

アプリケーション層の主な役割です。

  1. ユースケースを実現する
  2. ドメイン層とインフラ層の橋渡し
  3. トランザクション管理
  4. 外部APIとの依存の分離

この層にはアプリケーションサービス(ユースケース)の他に、ユースケースの入出力DTO、外部APIのインターフェース、CQRSにおけるクエリーモデル、クエリーサービスのインターフェース等を配置します。

プレゼンテーション層

  • app/Console
  • app/Http/Controllers

ここはプレゼンテーション層で、ユーザーからの入力を受け取り、アプリケーション層に渡し、その結果を整形してレスポンスとして返します。

この層はフレームワークにガッツリ依存して良いです。
アプリケーションのUIやWebリクエストやAPIレスポンスなどやり取りを担当する部分であり、フレームワークを利用して効率的に構築する役目があるからです。

そのため、Laravel標準のディレクトリ構成のままにしています。

  • contexts/Shop/Presentaion

とプレゼンテーション層のディレクトリを生やすことも考えましたが、フレームワークのカスタマイズが必要になるので止めました。

インフラ層

  • contexts/Shop/Infra

ここはインフラ層で、アプリケーションがデータベースや外部システムとやり取りするための技術的な詳細を提供する層です。

この層はフレームワークにガッツリ依存して良いです。
リポジトリや外部システムのインターフェースの実装クラスなど置きます。

リポジトリの中ではEloquentモデルを使用してフレームワークに依存して問題ないです。
注意点としては引数や返り値にフレームワークの情報が漏れ出ないようにしてください。

実装サンプル

購入者が商品を購入するユースケースを実装する例を紹介します。

.
└── contexts # プロジェクトのコンテキスト
   └── Shop # オンラインショッピングのコンテキスト
      ├── Application
      │  └── Service
      │     └── Buyer
      │        └── PurchaseProductUseCase
      │           ├── PurchaseProductUseCase.php # ユースケースインターフェース
      │           ├── PurchaseProductUseCaseInput.php # ユースケース入力DTO
      │           ├── PurchaseProductUseCaseOutput.php # ユースケース出力DTO
      │           └── PurchaseProductUseCaseInteractor.php # PurchaseProductUseCaseインターフェースの実装クラス
      ├── Domain # ドメイン層
      │  ├── Exception # 例外
      │  │  ├── DomainException # 抽象クラス
      │  │  ├── ProductNotFoundException # ドメイン層の例外
      │  │  └── ProductOutOfStockException # ドメイン層の例外
      │  └── Model # ドメインモデル
      │     └── Product # 商品モデル
      │        ├── Product.php # エンティティ
      │        ├── ProductId.php # エンティティの識別子
      │        ├── Name.php # 値オブジェクト
      │        ├── Price.php # 値オブジェクト
      │        ├── Stock.php # 値オブジェクト
      │        └── ProductRepository.php # リポジトリのインターフェース
      └── Infra # インフラ層
         └── Persistence # 永続化
            └── Eloquent # Eloquent
               └── EloquentProductRepository.php # ProductRepositoryインターフェースの実装クラス

PurchaseProductUseCase.php (ユースケースインターフェース)

contexts/Shop/Application/Service/Buyer/PurchaseProductUseCase/PurchaseProductUseCase.php
namespace Shop\Application\Service\Buyer\PurchaseProductUseCase;

interface PurchaseProductUseCase
{
    public function purchaseProduct(PurchaseProductUseCaseInput $input): PurchaseProductUseCaseOutput;
}

UseCaseはインターフェースで定義しています。
Controllerでモックの差し替えが可能になり、異常系のレスポンス等の自動テストをしやすくなります。
先にモック用のAPIだけ用意したい場合にも便利です。

PurchaseProductUseCaseInput.php (ユースケース入力DTO)

contexts/Shop/Application/Service/Buyer/PurchaseProductUseCase/PurchaseProductUseCaseInput.php
namespace Shop\Application\Service\Buyer\PurchaseProductUseCase;

final readonly class PurchaseProductUseCaseInput
{
    public function __construct(
        public string $productId,
        public int $quantity,
    ) {
    }
}

PurchaseProductUseCaseOutput.php (ユースケース出力DTO)

contexts/Shop/Application/Service/Buyer/PurchaseProductUseCase/PurchaseProductUseCaseOutput.php
namespace Shop\Application\Service\Buyer\PurchaseProductUseCase;

final readonly class PurchaseProductUseCaseOutput
{
}

PurchaseProductUseCaseInteractor.php (PurchaseProductUseCaseインターフェースの実装クラス)

contexts/Shop/Application/Service/Buyer/PurchaseProductUseCase/PurchaseProductUseCaseInteractor.php
namespace Shop\Application\Service\Buyer\PurchaseProductUseCase;

use Shop\Domain\Exception\ProductOutOfStockException;
use Shop\Domain\Model\Product\ProductId;
use Shop\Domain\Model\Product\ProductRepository;

final readonly class PurchaseProductUseCaseInteractor implements PurchaseProductUseCase
{
    public function __construct(
        private ProductRepository $productRepository,
    ) {
    }

    /**
     * @throws ProductOutOfStockException|ProductNotFoundException
     */
    public function purchaseProduct(PurchaseProductUseCaseInput $input): PurchaseProductUseCaseOutput
    {
        $productId = new ProductId($input->productId);
        $product = $this->productRepository->find($productId);

        if ($product === null) {
            throw new ProductNotFoundException('Product not found');
        }

        $reduceProduct = $product->reduceStock($input->quantity);
        $this->productRepository->save($reduceProduct);

        return new PurchaseProductUseCaseOutput();
    }
}

UseCaseの実装クラスです。
インターフェースで定義された入力DTO、出力DTOのパラメーターオブジェクトを受け取ります。

UseCase内でドメインモデルを組み立て、実行し結果を返します。

ProductRepository.php (インターフェース)

contexts/Shop/Domain/Model/Product/ProductRepository.php
namespace Shop\Domain\Model\Product;

interface ProductRepository
{
    public function find(ProductId $id): ?Product;
    public function save(Product $product): void;
}

ProductRepositoryのインターフェースです。
リポジトリは集約ルート(Aggregate Root)と呼ばれるエンティティ単位でデータを永続化層(DBなど)から取得・保存する役割を担います。

このインターフェースはドメイン層に属するため、実装の詳細(DBやORM、外部APIなど)には依存しません。
実際の実装はインフラ層で行い、ドメインモデルに依存する形を守ることで、ビジネスロジックをインフラ技術変更から独立させることができます。

EloquentProductRepository.php (ProductRepositoryの実装クラス)

contexts/Shop/Infra/Persistence/Eloquent/EloquentProductRepository.php
namespace Shop\Infra\Persistence\Eloquent;

use App\Models\Product as EloquentProduct;
use Shop\Domain\Exception\ProductNotFoundException;
use Shop\Domain\Model\Product\Product;
use Shop\Domain\Model\Product\ProductId;
use Shop\Domain\Model\Product\ProductRepository;

final readonly class EloquentProductRepository implements ProductRepository
{
    /**
     * @param ProductId $id
     * @return Product
     * @throws ProductNotFoundException
     */
    public function find(ProductId $id): ?Product
    {
        $eloquentProduct = EloquentProduct::find($id->id());

        if ($eloquentProduct) {
            return Product::create(
                id: $eloquentProduct->id,
                name: $eloquentProduct->name,
                price: $eloquentProduct->price,
                stock: $eloquentProduct->stock,
            );
        }

        return null;
    }

    public function save(Product $product): void
    {
        EloquentProduct::create([
            'id' => $product->idValue(),
            'name' => $product->nameValue(),
            'price' => $product->priceValue(),
            'stock' => $product->stockValue(),
        ]);
    }
}

EloquentProductRepositoryは、ドメイン層のProductRepositoryインターフェースを実装するインフラ層のクラスです。
EloquentのProductモデル(ORMによるDBマッピング用クラス)とドメインモデル(Productエンティティ)を相互変換し、ドメイン側にはフレームワークやDBに依存しないProductを返します。
これにより、上位層(アプリケーション層、ドメイン層)はデータアクセス技術への依存を避け、インフラ層が自由に実装を変更できます。(例: Eloquentから他のORMや外部APIへの切り替えなど)

※EloquentのProductモデルはインフラ層より外へ漏れてはいけません。

Product.php (エンティティ)

contexts/Shop/Domain/Model/Product.php
namespace Shop\Domain\Model\Product;

use Shop\Domain\Exception\ProductOutOfStockException;

final readonly class Product
{
    private function __construct(
        private ProductId $id,
        private Name $name,
        private Price $price,
        private Stock $stock,
    ) {
    }

    public static function create(
        string $id,
        string $name,
        int $price,
        int $stock,
    ): self {
        return new self (
            id: new ProductId($id),
            name: new Name($name),
            price: new Price($price),
            stock: new Stock($stock),
        );
    }

    public function idValue(): string
    {
        return $this->id->id();
    }

    public function nameValue(): string
    {
        return $this->name->name();
    }

    public function priceValue(): int
    {
        return $this->price->price();
    }

    public function stockValue(): int
    {
        return $this->stock->stock();
    }

    /**
     * @throws ProductOutOfStockException
     */
    public function reduceStock(int $quantity): Product
    {
        if ($this->stock->canReduce($quantity)) {
            $reducedStock = $this->stock->reduce($quantity);

            return new self(
                id: $this->id,
                name: $this->name,
                price: $this->price,
                stock: $reducedStock,
            );
        }

        throw new ProductOutOfStockException("Not enough stock for product: {$this->name->name()}");
    }
}

ProductId.php (値オブジェクト、Productエンティティの識別子)

contexts/Shop/Domain/Model/Product/ProductId.php
namespace Shop\Domain\Model\Product;

final readonly class ProductId
{
    public function __construct(
        private string $id,
    ) {
    }

    public function id(): string
    {
        return $this->id;
    }
}

Stock.php (値オブジェクト)

contexts/Shop/Domain/Model/Product/Stock.php
namespace Shop\Domain\Model\Product;

use InvalidArgumentException;

final readonly class Stock
{
    public function __construct(
        private int $stock,
    ) {
        if ($stock < 0) {
            throw new InvalidArgumentException('Stock cannot be negative');
        }
    }

    public function stock(): int
    {
        return $this->stock;
    }

    public function canReduce(int $quantity): bool
    {
        return $this->stock >= $quantity;
    }

    public function reduce(int $quantity): self
    {
        return new self($this->stock - $quantity);
    }
}

実戦時の疑問点まとめ

Laravelにクリーンアーキテクチャを組み合わせる上で悩んだことをまとめます。

認証

認証ロジックはプレゼンテーション層に実装します。
ドメイン層やアプリケーション層はインフラに依存しないという原則を守るべきだからです。

プレゼンテーション層のコントローラで認証を行い、結果(ユーザーIDやロールID等)をアプリケーション層に渡します。

プレゼンテーション層で認証を行うことを推奨しますが、
アプリケーション層にインターフェースを置き実装クラスをインフラ層に配置する設計も良いと思います。

app/Http/Controllers/AuthController.php

login, logout など認証に必要なロジックは認証コントローラに記述します。

ログ出力

Laravelのログが便利過ぎるので、logger() ヘルパーに頼ってもいいのかなという誘惑がありますが...
ちゃんとやるならアプリケーション層にインターフェースを置いてインフラ層に実装します。

contexts/Shop/Application/Contract/Logger.php # Logger(ログ出力)のインターフェース
contexts/Shop/Infra/Logging/LaravelLogger.php # Logger(ログ出力)の実装クラス
contexts/Shop/Application/Contract/Logger.php
<?php

declare(strict_types=1);

namespace Shop\Application\Contract;

interface Logger
{
    public function info(string $message, array $context = []): void;
    public function warning(string $message, array $context = []): void;
    public function error(string $message, array $context = []): void;
}
contexts/Shop/Infra/Logging/LaravelLogger.php
<?php

declare(strict_types=1);

namespace Shop\Infra\Persistence\Logging;

use Illuminate\Support\Facades\Log;
use Shop\Application\Contract\Logger;

final readonly class LaravelLogger implements Logger
{
    public function info(string $message, array $context = []): void
    {
        Log::info($message, $context);
    }

    public function warning(string $message, array $context = []): void
    {
        Log::warning($message, $context);
    }

    public function error(string $message, array $context = []): void
    {
        Log::error($message, $context);
    }
}

データベーストランザクション

トランザクションの管理はユースケースの役割なのでアプリケーション層として実装します。
UnitOfWorkパターンとも呼ばれます。

contexts/Shop/Application/Contract/UnitOfWork.php # Unit of Work(トランザクション)のインターフェース
contexts/Shop/Infra/Persistence/Eloquent/EloquentUnitOfWork.php # Unit of Work(トランザクション)の実装クラス
contexts/Shop/Application/Contract/UnitOfWork.php
<?php

declare(strict_types=1);

namespace Shop\Application\Contract;

interface UnitOfWork
{
    public function beginTransaction(): void;
    public function commit(): void;
    public function rollback(): void;
}
contexts/Shop/Infra/Persistence/Eloquent/EloquentUnitOfWork.php
<?php

declare(strict_types=1);

namespace Shop\Infra\Persistence\Eloquent;

use Illuminate\Support\Facades\DB;
use Shop\Application\Contract\UnitOfWork;

final readonly class EloquentUnitOfWork implements UnitOfWork
{
    public function beginTransaction(): void
    {
        DB::beginTransaction();
    }

    public function commit(): void
    {
        DB::commit();
    }

    public function rollback(): void
    {
        DB::rollBack();
    }
}

参照系処理の課題

基本的にデータベースのやりとりはRepositoryで行われます。
Repositoryの役割は集約の永続化と復元のみで複雑なビジネスロジックは実装しません。

一覧画面や検索機能など複数集約の値を組み合わせた結果を画面に返すことが多いです。
そのような場合はクエリーモデル(軽量CQRS)の考え方を導入します。

アプリケーション層にクエリーサービスのインターフェースとクエリーモデルのDTOを置き、インフラ層にクエリーサービスの実装クラスを配置します。

contexts/Shop/Application/Query/OrderList/OrderListQueryModel.php # 注文一覧クエリー用のDTO
contexts/Shop/Application/Query/OrderList/OrderListFilter.php # 注文一覧クエリーの引数用のDTO
contexts/Shop/Application/Query/OrderList/OrderListQueryService.php # 注文一覧クエリーのインターフェース
contexts/Shop/Infra/Query/OrderList/EloquentOrderListQueryService.php # 注文一覧クエリーの実装クラス
contexts/Shop/Application/Query/OrderList/OrderListQueryService.php
<?php

declare(strict_types=1);

namespace Shop\Application\Query\OrderList;

interface OrderListQueryService
{
    /**
     * @param OrderListFilter $filter フィルタ条件
     * @return OrderListQueryModel[] クエリモデルの配列
     */
    public function findOrderList(OrderListFilter $filter): array;
}
contexts/Shop/Application/Query/OrderList/OrderListFilter.php
<?php

declare(strict_types=1);

namespace Shop\Application\Query\OrderList;

final readonly class OrderListFilter
{
    public function __construct(
        public ?string $status = null,
        public ?string $buyerName = null,
        public ?string $dateFrom = null,
        public ?string $dateTo = null
    ) {}
}
contexts/Shop/Infra/Query/OrderList/EloquentOrderListQueryService.php
<?php

declare(strict_types=1);

namespace Shop\Infra\Query\OrderList;

use Shop\Application\Query\OrderList\OrderListQueryService;
use Shop\Application\Query\OrderList\OrderListQueryModel;
use Shop\Application\Query\OrderList\OrderListFilter;
use Illuminate\Support\Facades\DB;

final readonly class EloquentOrderListQueryService implements OrderListQueryService
{
    public function findOrderList(OrderListFilter $filter): array
    {
        $query = DB::table('orders')
            ->join('users', 'orders.user_id', '=', 'users.id')
            ->select('orders.id as orderId', 'users.name as buyerName', 'orders.created_at as orderDate', 'orders.total as totalAmount');

        // フィルタ条件の適用
        $query->when($filter->status, fn ($query, $status) => $query->where('orders.status', $status));
        $query->when($filter->buyerName, fn ($query, $buyerName) => $query->where('orders.name', 'like', '%' . $buyerName . '%'));
        $query->when($filter->dateFrom, fn ($query, $dateFrom) => $query->where('orders.created_at', '>=', $dateFrom));
        $query->when($filter->dateTo, fn ($query, $dateTo) => $query->where('orders.created_at', '<=', $dateTo));

        // 結果を取得し、クエリモデルの配列に変換
        return $query->get()->map(fn ($row) => new OrderListQueryModel(
            $row->orderId,
            $row->buyerName,
            $row->orderDate,
            (float) $row->totalAmount,
        ))->toArray();
    }
}

非同期処理(ドメインイベント)

長時間実行される処理、サードパーティサービスとの連携、再試行が必要な処理、並列処理で効率化できる処理等を実装する際に非同期なものを実装する時にDDDにおけるドメインイベントという概念を実装します。

ドメインイベントは、ドメインモデルの一部であり、ドメイン内で発生する何かの出来事を表します。

また、Laravelにも イベント の機能があります。
ドメイン層はフレームワークに依存しないという原則を守るべきです。

ドメインイベントについては長くなりそうなので別記事でご紹介したいと思います。

エンティティのID発番

Laravelを使うと自然にDBの自動採番を使ってIDを生成してると思います。
DBの自動採番の値をそのままエンティティの識別子として使用すると困った問題が発生します。

何らかの登録処理を行う場合はエンティティ生成してリポジトリに格納する流れですが、DBの自動採番を利用するとリポジトリに格納されるまでエンティティの識別子がない状態を考慮する必要が出てきます。

DBの自動採番のような遅延生成を用いるより早期生成を行うと良いです。
UUIDやULIDを利用してエンティティ生成時に識別子を用意します。

DBに依存しないコードが書けるようになり、ユニットテストをシンプルに書けるようになります。

contexts/Shop/Application/Contract/IdGenerator.php
namespace Shop\Application\Contract;

interface IdGenerator
{
    public function generate(): string;
}
contexts/Shop/Infra/IdGenerator/UlidGenerator.php
namespace Shop\Infra\IdGenerator;

use Illuminate\Support\Str;
use Shop\Application\Contract\IdGenerator;

final readonly class UlidGenerator implements IdGenerator
{
    public function generate(): string
    {
        return (string) Str::ulid();
    }
}

より詳細なディレクトリ構造

.
├── app # フレームワークのアプリケーション
│  ├── Console # プレゼンテーション層
│  ├── Exceptions
│  ├── Http
│  │  ├── Controllers # プレゼンテーション層
│  │  │  ├── Api
│  │  │  └── Web
│  │  │     ├── AuthController.php # 認証用のコントローラ
│  │  │     ├── OrderController.php # 注文管理用のコントローラ
│  │  │     ├── ProductController.php # 商品管理用のコントローラ
│  │  │     └── StockController.php # 在庫管理用のコントローラ
│  │  └── Middleware
│  ├── Models
│  │  └── User.php # Eloquentモデル => インフラ層でのみ使用
│  └── Providers
│     └── ShopServiceProvider.php # サービスコンテナ設定に使用
├── contexts # プロジェクトのコンテキスト
│  └── Shop # オンラインショッピングのコンテキスト
│     ├── Application # アプリケーション層
│     │  ├── Service # 購入者向けユースケース
│     │  │  ├── Buyer # 購入者向けユースケース
│     │  │  │  ├── SearchProductUseCase.php # 商品を検索するユースケース
│     │  │  │  │  ├── SearchProductUseCase.php # 商品を検索するユースケースのインターフェース
│     │  │  │  │  ├── SearchProductUseCaseInteractor.php # 商品を検索するユースケースの実装クラス
│     │  │  │  │  ├── SearchProductUseCaseInput.php # 商品を検索するユースケースの入力DTO
│     │  │  │  │  └── SearchProductUseCaseOutput.php # 商品を検索するユースケースの出力DTO
│     │  │  │  ├── GetProductUseCase.php # 商品を取得するユースケース
│     │  │  │  │  ├── GetProductUseCase.php # 商品を取得するユースケースのインターフェース
│     │  │  │  │  ├── GetProductUseCaseInteractor.php # 商品を取得するユースケースの実装クラス
│     │  │  │  │  ├── GetProductUseCaseInput.php # 商品を取得するユースケースの入力DTO
│     │  │  │  │  └── GetProductUseCaseOutput.php # 商品を取得するユースケースの出力DTO
│     │  │  │  ├── AddToCartUseCase.php # カートに商品を追加するユースケース
│     │  │  │  │  ├── AddToCartUseCase.php # カートに商品を追加するユースケースのインターフェース
│     │  │  │  │  ├── AddToCartUseCaseInteractor.php # カートに商品を追加するユースケースの実装クラス
│     │  │  │  │  ├── AddToCartUseCaseInput.php # カートに商品を追加するユースケースの入力DTO
│     │  │  │  │  └── AddToCartUseCaseOutput.php # カートに商品を追加するユースケースの出力DTO
│     │  │  │  └── PurchaseProductUseCase.php # 注文を確定するユースケース
│     │  │  │     ├── PurchaseProductUseCase.php # 注文を確定するユースケースのインターフェース
│     │  │  │     ├── PurchaseProductUseCaseInteractor.php # カートに商品を追加するユースケースの実装クラス
│     │  │  │     ├── PurchaseProductUseCaseInput.php # 注文を確定するユースケースの入力DTO
│     │  │  │     └── PurchaseProductUseCaseOutput.php # 注文を確定するユースケースの出力DTO
│     │  │  ├── Seller # 販売者向けユースケース
│     │  │  │  ├── AddProductUseCase.php # 商品を登録するユースケース
│     │  │  │  │  ├── AddProductUseCase.php # 商品を登録するユースケースのインターフェース
│     │  │  │  │  ├── AddProductUseCaseInteractor.php # 商品を登録するユースケースの実装クラス
│     │  │  │  │  ├── AddProductUseCaseInput.php # 商品を登録するユースケースの入力DTO
│     │  │  │  │  └── AddProductUseCaseOutput.php # 商品を登録するユースケースの出力DTO
│     │  │  │  ├── UpdateProductStockUseCase.php # 商品の在庫を更新するユースケース
│     │  │  │  │  ├── UpdateProductStockUseCase.php # 商品の在庫を更新するユースケースのインターフェース
│     │  │  │  │  ├── UpdateProductStockUseCaseInteractor.php # 商品の在庫を更新するユースケースの実装クラス
│     │  │  │  │  ├── UpdateProductStockUseCaseInput.php # 商品の在庫を更新するユースケースの入力DTO
│     │  │  │  │  └── UpdateProductStockUseCaseOutput.php # 商品の在庫を更新するユースケースの出力DTO
│     │  │  │  └── ShipppingOrderUseCase.php # 注文を出荷するユースケース
│     │  │  │     ├── ShipppingOrderUseCase.php # 注文を出荷するユースケースのインターフェース
│     │  │  │     ├── ShipppingOrderUseCaseInteractor.php # 注文を出荷するユースケースの実装クラス
│     │  │  │     ├── ShipppingOrderUseCaseInput.php # 注文を出荷するユースケースの入力DTO
│     │  │  │     └── ShipppingOrderUseCaseOutput.php # 注文を出荷するユースケースの出力DTO
│     │  │  └── Operator # 運営者向けユースケース
│     │  │     ├── AddShopUseCase.php # 店を追加するユースケース
│     │  │     │  ├── AddShopUseCase.php # 店を追加するユースケースのインターフェース
│     │  │     │  ├── AddShopUseCaseInteractor.php # 店を追加するユースケースの実装クラス
│     │  │     │  ├── AddShopUseCaseInput.php # 店を追加するユースケースの入力DTO
│     │  │     │  └── AddShopUseCaseOutput.php # 店を追加するユースケースの出力DTO
│     │  │     └── AddSellerUseCase.php # 販売者を追加する
│     │  │        ├── AddSellerUseCase.php # 販売者を追加するユースケースのインターフェース
│     │  │        ├── AddSellerUseCaseInteractor.php # 販売者を追加するユースケースの実装クラス
│     │  │        ├── AddSellerUseCaseInput.php # 販売者を追加するユースケースの入力DTO
│     │  │        └── AddSellerUseCaseOutput.php # 販売者を追加するユースケースの出力DTO
│     │  ├── Contract
│     │  │  ├── UnitOfWork.php # Unit of Work(トランザクション)のインターフェース
│     │  │  ├── Logger.php # Logger(ログ出力)のインターフェース
│     │  │  ├── PaymentService.php # 支払いサービスのインターフェース
│     │  │  └── FileStorage.php # ファイルストレージのインターフェース
│     │  └── Query
│     │     └── Order # クエリーモデル、クエリーサービス
│     │        ├── OrderListQueryModel.php # 注文一覧クエリ用のDTO
│     │        └── OrderListQueryService.php # 注文一覧クエリのインターフェース
│     ├── Domain # ドメイン層
│     │  ├── Exception # ドメイン固有の例外
│     │  │  ├── OrderNotFoundException.php # 注文(エンティティ)
│     │  │  └── ProductOutOfStockException.php
│     │  └── Model # ドメインモデル(エンティティ、値オブジェクト)、ドメインサービス
│     │     ├── Order # 注文
│     │     │  ├── Order.php # 注文(エンティティ)
│     │     │  ├── OrderId.php # 注文ID
│     │     │  ├── BuyerId.php # 購入者ID
│     │     │  ├── OrderDateTime.php # 注文日付
│     │     │  ├── OrderStatus.php # 注文状況(列挙型や値オブジェクトとして実装可能)
│     │     │  ├── OrderService.php # 注文のドメインサービス
│     │     │  └── OrderRepository.php # 注文リポジトリのインターフェース
│     │     ├── Cart # カート
│     │     │  ├── Cart.php # 注文(エンティティ)
│     │     │  ├── CartId.php # 注文ID
│     │     │  ├── CartService.php # 注文のドメインサービス
│     │     │  └── CartRepository.php # 注文リポジトリのインターフェース
│     │     ├── Buyer # 購入者
│     │     │  ├── Buyer.php # エンティティ
│     │     │  ├── BuyerId.php # 値オブジェクト
│     │     │  └── Name.php # 値オブジェクト
│     │     ├── Shop # 店
│     │     │  ├── Shop.php # エンティティ
│     │     │  ├── ShopId.php # 値オブジェクト
│     │     │  └── Name.php # 値オブジェクト
│     │     └── Product # 商品
│     │        ├── Product.php # エンティティ
│     │        ├── ProductId.php # 値オブジェクト
│     │        ├── Price.php # 値オブジェクト
│     │        ├── Name.php # 値オブジェクト
│     │        └── Stock.php # 値オブジェクト
│     └── Infra # インフラ層
│        ├── Persistence
│        │  ├── Eloquent
│        │  │  ├── EloquentUnitOfWork.php # Unit of Work(トランザクション)の実装クラス
│        │  │  ├── EloquentProductRepository.php # 注文リポジトリの実装クラス
│        │  │  └── EloquentOrderRepository.php # 注文リポジトリの実装クラス
│        │  ├── InMemory
│        │  │  ├── InMemoryProductRepository.php # 注文リポジトリの実装クラス
│        │  │  ├── InMemoryOrderRepository.php # 注文リポジトリの実装クラス
│        │  │  └── InMemoryCartRepository.php # 注文リポジトリの実装クラス
│        │  ├── Logging
│        │  │  └── LaravelLogger.php # Logger(ログ出力)の実装クラス
│        │  └── Redis
│        │     └── RedisCartRepository.php # 注文リポジトリの実装クラス
│        ├── FileStorage
│        │  ├── LocalFileStorage.php
│        │  └── S3FileStorage.php
│        └── Api
│           └── Payment
│              ├── LocalPaymentService.php
│              ├── StripePaymentService.php
│              └── PayPalPaymentService.php
├── bootstrap # フレームワークの初期起動処理
├── config # アプリケーションの設定ファイル
├── database # データベース関連
│  ├── factories # Eloquentモデルファクトリ
│  ├── migrations # マイグレーション
│  └── seeders # シーダー
├── public # 公開ディレクトリ、index.phpやトランスパイル済みのアセットファイル
├── resources # ビュー、CSS、JavaScriptのアセットファイル
├── routes # ルーティング
├── storage # ログ、キャッシュ、ファイル等
└── tests # テストコード
   └── Shop
      ├── Application
      └── Domain

補足

ShopServiceProvider.php (サービスプロバイダー)

サービスプロバイダーでは主にサービスコンテナの設定を行います。

app/Providers/ShopServiceProvider.php
namespace App\Providers;

use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;
use Shop\Application\Contract\IdGenerator;
use Shop\Application\Contract\Logger;
use Shop\Application\Contract\UnitOfWork;
use Shop\Application\Service\Buyer\AddToCartUseCase\AddToCartUseCase;
use Shop\Application\Service\Buyer\AddToCartUseCase\AddToCartUseCaseInteractor;
use Shop\Application\Service\Buyer\PurchaseProductUseCase\PurchaseProductUseCase;
use Shop\Application\Service\Buyer\PurchaseProductUseCase\PurchaseProductUseCaseInteractor;
use Shop\Application\Service\Operator\AddSellerUseCase\AddSellerUseCase;
use Shop\Application\Service\Operator\AddSellerUseCase\AddSellerUseCaseInteractor;
use Shop\Application\Service\Operator\AddShopUseCase\AddShopUseCase;
use Shop\Application\Service\Operator\AddShopUseCase\AddShopUseCaseInteractor;
use Shop\Application\Service\Seller\AddProductUseCase\AddProductUseCase;
use Shop\Application\Service\Seller\AddProductUseCase\AddProductUseCaseInteractor;
use Shop\Domain\Model\Product\ProductRepository;
use Shop\Infra\IdGenerator\UlidGenerator;
use Shop\Infra\Persistence\Eloquent\EloquentProductRepository;
use Shop\Infra\Persistence\Eloquent\EloquentUnitOfWork;
use Shop\Infra\Persistence\InMemory\InMemoryProductRepository;
use Shop\Infra\Persistence\Logging\LaravelLogger;
use Todo\Infra\Domain\Model\ActivityReport\LogActivityReportRepository;

final class ShopServiceProvider extends ServiceProvider implements DeferrableProvider
{
    public array $bindings = [
        IdGenerator::class => UlidGenerator::class,
        Logger::class => LaravelLogger::class,
        UnitOfWork::class => EloquentUnitOfWork::class,
        ProductRepository::class => EloquentProductRepository::class,
        AddToCartUseCase::class => AddToCartUseCaseInteractor::class,
        AddSellerUseCase::class => AddSellerUseCaseInteractor::class,
        AddShopUseCase::class => AddShopUseCaseInteractor::class,
        AddProductUseCase::class => AddProductUseCaseInteractor::class,
        PurchaseProductUseCase::class => PurchaseProductUseCaseInteractor::class,
    ];

    public function provides(): array
    {
        return array_keys($this->bindings);
    }

    public function register(): void
    {
        if ($this->app->runningUnitTests()) {
            $this->app->bind(ProductRepository::class, InMemoryProductRepository::class);
        }
    }
}

インターフェースに対応する実装クラスを $bindings 指定します。
$singletons プロパティにシングルトン登録も行えますが、特に理由なければバインド登録で良いでしょう。

DeferrableProvider と契約したサービスプロバイダは登録されているサービスのどれか一つを依存解決する必要が起きた時のみ、Laravelはそのサービスプロバイダを読み込むようになります。

その場合は provides() を定義する必要があります。

複雑な依存解決、環境によって実装クラスを切り替えたい時は register() に定義します。
特定の環境でユースケースをモックと差し替えたり、テストの時はリポジトリをDBからインメモリに切り替えたりできます。

テスト

アプリケーション層のテスト例とドメイン層のテスト例を分けて紹介します。

  • ビジネスロジックは純粋にドメイン層のテストで実行し、外部依存を持たないためユニットテストが容易であること
  • アプリケーション層はインフラへの依存を抽象化しているため、モックを用いて簡単に外部依存を排除できること

Pest フレームワークで紹介します。

ドメイン層のテスト

ドメイン層では、リポジトリやフレームワーク依存はなく、純粋なビジネスロジックをテストします。

Product エンティティのテスト例です。

tests/Shop/Domain/Model/Product/ProductTest.php
<?php

declare(strict_types=1);

use Shop\Domain\Exception\ProductOutOfStockException;
use Shop\Domain\Model\Product\Product;

it('在庫を正しく減らせる', function () {
    $product = Product::create(
        id: 'P001',
        name: 'Test Product',
        price: 1000,
        stock: 10,
    );

    $updatedProduct = $product->reduceStock(3);

    expect($updatedProduct->stockValue())->toBe(7);
});

it('在庫が足りない場合は例外が発生する', function () {
    $product = Product::create(
        id: 'P002',
        name: 'Low Stock Product',
        price: 500,
        stock: 2,
    );

    $product->reduceStock(3); // 2しかないのに3を減らそうとするとエラー
})->throws(ProductOutOfStockException::class);

このテストでは Product エンティティと関連する値オブジェクトのみ使用しています。
ProductOutOfStockException はドメイン固有の例外であり、明確なビジネスルール違反のシナリオをテストできます。
ドメイン層はどの層にも依存しないためモックは一切使用しません。

ドメイン層テストでは、コード中のモデル名やメソッド名が業務用語に即しているか、ビジネスルールを自然言語通りに実現しているかも再確認できます。これにより、エンジニアとビジネス側との共通言語の整合性を維持しやすくなります。

細かい補足ですが、IDはULIDの代わりに適当な固定値を入れています。
ドメインテストにおいてIDは不変な識別子であることが重要でランダム性は必要ないからです。

ドメイン層のテストはビジネスロジックの正しさを確認する場であり、ユニーク性の保証はインフラ依存だからです。
テスト時にわざわざULIDを生成する処理を書かずに済むため、テストコードがよりシンプルになります。

アプリケーション層のテスト

PurchaseProductUseCase のテストを例にします。
アプリケーション層はドメイン層に依存しますが、データ取得にはリポジトリ(ProductRepository)を利用します。
テスト時にはこれをモック化し、ドメイン層は「中身が正しいと仮定」した上で、アプリケーション層のロジック(入出力DTOの処理やドメイン呼び出しの流れ)を検証します。

tests/Shop/Application/Service/Buyer/PurchaseProductUseCaseTest.php
<?php

declare(strict_types=1);

use Shop\Application\Service\Buyer\PurchaseProductUseCase\PurchaseProductUseCase;
use Shop\Application\Service\Buyer\PurchaseProductUseCase\PurchaseProductUseCaseInput;
use Shop\Application\Service\Buyer\PurchaseProductUseCase\PurchaseProductUseCaseOutput;
use Shop\Domain\Model\Product\Product;
use Shop\Domain\Model\Product\ProductRepository;
use Shop\Infra\IdGenerator\UlidGenerator;
use Shop\Infra\Persistence\InMemory\InMemoryProductRepository;

it('商品を購入すると正常に出力DTOが返る', function () {
    $id = $this->app->make(UlidGenerator::class)->generate();

    $product = Product::create(
        id: $id,
        name: 'Some Product',
        price: 1000,
        stock: 10,
    );

    $this->app->bind(ProductRepository::class, fn () => new InMemoryProductRepository(
        [$id => $product],
    ));

    $input = new PurchaseProductUseCaseInput($id, 3);
    $useCase = $this->app->make(PurchaseProductUseCase::class);
    $output = $useCase->purchaseProduct($input);

    expect($output)->toBeInstanceOf(PurchaseProductUseCaseOutput::class)
        ->and($output->productId)->toBe($id)
        ->and($output->stock)->toBe(7);
});

本例ではInMemoryリポジトリを利用していますが、Mockライブラリを用いることも可能です。
Mockライブラリは依存オブジェクトの振る舞いを細かくコントロールでき、特定の異常系(DB接続失敗など)をシミュレートする際に役立ちます。
一方、InMemory実装は実行時に状態を保持できるため、シンプルなインテグレーションテストにも向いています。

ユースケース出力DTOは、APIレスポンスやBladeへの受け渡しを容易にします。
DTOとして結果を定義することで、後から項目を追加・変更しやすくなり、コントローラ層(プレゼンテーション層)での対応がシンプルになります。

まとめ

本記事では、Laravelを活用した開発において、クリーンアーキテクチャとドメイン駆動設計(DDD)を組み合わせることで、フレームワーク依存を避けつつテスタビリティと保守性を高める手法を紹介しました。
ドメイン層ではドメインロジックを純粋なオブジェクトとして表現し、アプリケーション層ではユースケース単位でロジックを組み立てることで、外部依存の影響を最小限に抑えられます。
プレゼンテーション層やインフラ層と明確に責務を分離することで、肥大化したコントローラやモデルの問題を回避し、長期的な開発・運用でのリスク軽減が可能です。

要約すると、クリーンアーキテクチャによって「関心の分離」と「依存方向の明確化」を行い、DDDで「ドメインモデルを中心」に据えることで、Laravelの利便性を享受しながら、柔軟な設計パターンを実現できます。
これにより、複雑なプロジェクトでもテストしやすく、変更に強いアプリケーションを構築できるようになるでしょう。

明日のアドベントカレンダー

明日のミライトデザインのアドカレは ブッチ さんの「Perplexityで学ぶ次世代の情報収集術」という記事です。Perplexity初めて聞いたのでとても楽しみです!

25
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?