スタイル・エッジ技術ブログ

士業集客支援/コンサルティングのスタイル・エッジのエンジニアによるブログです。

【モダン開発 #2】実践!DDD × レイヤードアーキテクチャ in Laravel

はじめに

こんにちは!スタイル・エッジの SHISO です。 弊社ではある業務システムの開発プロジェクトにて、ドメイン駆動設計(DDD)に挑戦しました。
※ 詳しい経緯についてはこちら

今回は、Laravelを利用するという条件の下、DDDとレイヤードアーキテクチャを実現していくにあたり、具体的にどのような設計方針で進めたのか、またどのように実装に落とし込んだのかなど、紹介していきたいと思います。

目次

アーキテクチャ

DIP(依存性逆転の原則)を用いたレイヤードアーキテクチャを採用しました。

レイヤードアーキテクチャ図
レイヤードアーキテクチャ

Laravelフレームワークの一部概念については、意図的にレイヤーを逸脱することを許容しつつも、 原則として、依存関係は上位から下位のみ許可する方針としています。

ディレクトリ構成

※本記事で言及しない部分は省略しています。

app/
├─┬ Application/ (アプリケーション層)
│ ├── Actions/ (ユースケース)
│ └── Dtos/
├─┬  Domains/ (ドメイン層)
│ └─┬ 境界づけれられたコンテキスト/
│   └── 集約/
├─┬ Http/ (UI層)
│ ├── Controllers/
│ ├── Requests/
│ └── Voos/ (ビュー用オブジェクト)
└─┬ Infrastructure/ (インフラ層)
  ├── Models/
  ├── QueryServices/
  └── Repositories/

各層の責務や実装方針、特徴

具体的に各層に持たせた責務と、実装方針をピックアップして紹介します。

Domain層

ビジネスの概念と、ビジネスが置かれた状況に関する情報、およびビジネスルールを表す層です。
主に下記ドメインオブジェクトのパターンで分類し表現しました。

  • エンティティ
  • 値オブジェクト
  • 区分オブジェクト
  • コレクションオブジェクト
  • ドメインサービス
  • 仕様
  • ファクトリ

Domain層は、境界づけられたコンテキストや関連性によってディレクトリを切っていきます。最小単位は集約ごとです。

エンティティ・値オブジェクト生成時のデータ整合性担保のタイミング

エンティティ・値オブジェクトの生成は、基本的に各オブジェクト内のファクトリメソッドで行うようにしました。
新規生成用ファクトリメソッドでは、データ整合性担保のバリデーションを行った上でオブジェクトを生成します。
DBからの再構成用ファクトリメソッドでは、パフォーマンスも考慮し、データの整合性は担保されている前提で、バリデーションは行わず生成しています。

また、ローカルエンティティや値オブジェクトを保持するエンティティ(集約)の生成では、ルートエンティティのcreate処理にて、集約内のデータ整合性担保を行いました。

<?php

// 受付エンティティ
class ReceptionEntity
{
    private function __construct(
        public readonly ?string $id,
        public readonly string $receptionAt, // 受付日時
        public readonly ReceptionType $receptionType, // 受付種別
        public readonly string $name, // 氏名
        public readonly ?Tel $tel, // 電話番号
    ){}

    // 新規生成用ファクトリメソッド
    public static function create(
        string $receptionAt,
        ReceptionType $receptionType,
        string $name,
        ?string $tel,
    ): ReceptionEntity
    {
        // データ整合性担保のバリデーションを行った上でオブジェクトを生成
        validator(
            ['receptionAt' => $receptionAt, 'name' => $name],
            self::rules()
        )->validate();

        // 集約内のデータ整合性担保はルートエンティティのcreate処理で行う
        if ($receptionType === ReceptionType::New && is_null($tel)) {
            throw new \Exception('新規受付の場合、電話番号は必須です。');
        }
        $tel = isset($tel) ? new Tel($tel) : $tel;

        return new self(null, $receptionAt, $receptionType, $name, $tel);
    }

    private static function rules(): array
    {
        return [
            'receptionAt' => ['date'],
            'name'        => ['max:10'],
        ];
    }

    // 再構成用ファクトリメソッド
    public static function reconstruct(
        string $id,
        string $receptionAt,
        ReceptionType $receptionType,
        string $name,
        ?Tel $tel,
    ): ReceptionEntity
    {
        // データは整合性担保されている前提で、バリデーションは行わない
        return new self($id, $receptionAt, $receptionType, $name, $tel);
    }
}

// 受付種別
enum ReceptionType
{
    case New; // 新規
    case Old; // 既存
}

ただ、集約内の情報だけでは生成ができない・データ整合性担保ができない場合は、ファクトリオブジェクトを別途作成し、 そこでエンティティの生成を行っています。

<?php

// 受付エンティティのファクトリ
class ReceptionEntityFactory
{
    // 集約内の情報だけでは生成ができない、またデータ整合性担保ができない場合は、ファクトリオブジェクトで生成
    public function create($name, ...): ReceptionEntity
    {
        // 例)同じ名前の受付が存在する場合、「既存」と判定
        $receptionType = isset($receptionEntityRepository->findByName($name)) ? ReceptionType::Old : ReceptionType::New;

        return ReceptionEntity::create($name, $receptionType, ...);
    }
}

エンティティ識別子はDBのAutoIncrementによる自動採番で生成

本来であれば、識別子はエンティティ生成時に採番した方が一意であることを担保できますが、今回はDBのAutoIncrementより自動採番で遅延生成することにしました。
理由としては、DBMSを今のところ変更する予定がないことと、一番は普段から馴染みのあるORM(Eloquentモデル)を使った時の挙動と近く、実装コストが格段に低くなるためです。
ただ、今後アプリケーション側で識別子を早期生成する場面は考えられるため、識別子の型はintegerではなくstringで定義しました。

バリデーションはLaravelのValidatorファサードを利用

今回開発した業務システムでは、管理する情報数や永続化において担保する必要のあるルールが多く、ドメインオブジェクトの属性ごとにバリデーション処理を記載すると、クラスが肥大化してしまう問題が起きました。

<?php

// before
class UserEntity
{
    private function __construct(
        public readonly ?string $id,
        public readonly string $name,
    ){}

    public static function create(string $name): UserEntity
    {
        // 下記のようなバリデーション処理がたくさんでき、クラスが肥大化
        if (mb_strlen($name) <= 10) {
            throw new \Exception('名前は10文字以下でしか登録できません。');
        }

        return new self(id: null, name: $name);
    }
}

そのため、属性ごとのバリデーションルールについては、LaravelのValidatorファサードを利用し、よりシンプルに記述できるようにしました。

<?php

// after(一部省略)
    public static function create(string $name): UserEntity
    {
        // LaravelのValidatorファサードを利用
        validator(['name' => $name], self::rules())->validate();
        return new self(id: null, name: $name);
    }

    private static function rules(): array
    {
        return ['name' => ['max:10']];
    }

※ 実際は基底クラスなどを作成し、より実装効率を上げる工夫をしています。

Application層

アプリケーションが提供するユースケースやサービスを、ドメインオブジェクトの操作の組み合わせで実現する層です。

パラメータが多い場合はDTOクラスを利用

ユースケースクラスの実行メソッドにて、パラメータが多い場合には、データ受け渡し専用のクラスとしてDTOを利用しました。
これにより、可読性や保守性が向上し、連想配列としてそのまま受け渡す場合に比べ、型により安全性が担保されました。
また、特定の条件ではありますが、ユースケースクラスから別ユースケースクラス呼び出し時にDTOを使い回すことで実装効率が向上する場面もありました。

DTOクラスについては、その他にも開発効率を上げるために様々な工夫をしましたので、 別の連載記事として紹介します。

UserInterface層

アプリケーション外部との入出力操作(データ変換含む)を行う層です。
主にコントローラやフォームリクエスト、ビューテンプレート(Blade)が当てはまります。

ドメインオブジェクトはビュー用のオブジェクトに変換

ビュー(Blade)を作成する際、アプリケーション層から取得したドメインオブジェクトの情報を使用する場面は多々あります。
その際、ドメインオブジェクトをそのままビューへ渡すと、実装効率は上がる反面、下記デメリットも発生します。

  • 予期せぬプロパティ操作や情報更新がされる恐れ
  • ドメインオブジェクト・集約の構造を意識したプロパティアクセスの考慮により実装効率が低下
  • 判定処理やIDとリストの照合処理など、表示に関わるコード以外の実装がビュー上に散見し可読性が低下

そのため、今回はドメインオブジェクトをそのまま渡さず、最小限の記述で必要な情報のみを参照できるように、ビュー用のオブジェクトに変換して渡すようにしました。

Infrastructure層

RDB等の具体的な外部リソース技術を使用し、ドメインオブジェクト(集約)の永続化・取得を行う層です。
主にリポジトリやクエリサービスの実装クラス、Eloquentモデルなどが当てはまります。

リポジトリパターンを採用

ドメインオブジェクトの永続化・取得にはリポジトリパターンを採用しました。
理由としては、抽象リポジトリに依存させ、容易にデータアクセス処理を書けなくすることで、ビジネスロジックがデータアクセス処理に漏れることを抑制するためです。
また、データアクセス処理の隠蔽や下位レイヤのテストが容易になるといった効果も期待できます。

集約内のローカルエンティティの更新処理は2通り

エンティティによっては、ネストしたエンティティ(ローカルエンティティ)を持つものもあります。
例えば、顧客管理システムにおいて、注文エンティティ内に注文アイテムエンティティを包含し、注文詳細情報を階層的に表現する場合、注文アイテムエンティティがローカルエンティティに当たります。

集約内のオブジェクトは、データ整合性担保のためにも、必ず集約単位で更新する必要があるので、ローカルエンティティはルートエンティティと同じタイミングで永続化処理が実行されます。
ただ、ルートエンティティとローカルエンティティは別テーブルで管理されることが多いため、ローカルエンティティの更新方法として、下記2通りの方法が候補に挙がります。

  • delete/insert
  • 一意な識別子による差分更新

差分更新の場合、selectで存在確認してからupdateかinsert、何もしないなど、いくつか分岐処理が必要になり、実装が複雑になるため、基本的にはdelete/insertにて実装しました。

<?php

// delete/insert  
class Repository
{
    public function updateOrCreate(RootEntity $rootEntity): void
    {
        $rootEntityEloquentModel = RootEntityEloquentModel::updateOrCreate(
            ['id' => $rootEntity->id, ...],
        );

        // ローカルエンティティの永続化
        $rootEntityEloquentModel->localEntityEloquentModel()->delete();
        LocalEntityEloquentModel::create(['root_entity_id' => $rootEntityEloquentModel->id, ...]);
    }
}

delete/insertでも集約内のオブジェクトは必ず集約単位で更新されるため、証跡データが必要な場合、ルートエンティティから取得できます。
しかし、厳密にローカルエンティティごとに更新日時や更新者など証跡データが必要な場合では、差分更新にて実装しました。

<?php

// 一意な識別子による差分更新  
class Repository
{
    public function updateOrCreate(RootEntity $rootEntity): void
    {
        $rootEntityEloquentModel = RootEntityEloquentModel::updateOrCreate(
            ['id' => $rootEntity->id, ...],
        );

        // ローカルエンティティの永続化
        if (isset($rootEntity->localEntity)) {
            // updateOrCreateメソッド内で更にデータが存在するかの分岐処理が走る
            LocalEntityEloquentModel::updateOrCreate([
                'id'             => $rootEntity->localEntity->id,
                'root_entity_id' => $rootEntityEloquentModel->id,
                ...]);
        } else {
            $rootEntityEloquentModel->localEntityEloquentModel()->delete();
        }
    }
}

複雑なデータアクセス処理にはクエリサービスを利用

リポジトリパターンを採用することでメンテナンス性は良くなりましたが、複数集約を取得したり、複雑な条件の検索処理などはパフォーマンスが悪化しました。
そのため、集約を跨ぐ取得や検索処理など特定の処理については、CQRSパターンを一部参考にし、リポジトリとは別にクエリサービス*1というクラスに切り出しました。
ただこのクエリサービスの利用については、ビジネスロジックの重複や設計指針の崩壊などが発生し得るため、本当に利用しなければならない理由がある時のみ利用しています。

Eloquentモデルはデータベース操作でのみ利用

基本的にEloquentモデルとドメインオブジェクト(主にエンティティ)は1対1になるため、設計当初はmpywさんの記事を参考にしつつ、Eloquentモデルでドメインオブジェクトを表現しようとしていました。
しかし、Eloquentモデルは各属性ごとに更新可能であるため、属性間のデータ整合性を担保することが難しかったり、またドメインロジックやビュー用処理、データベース設定などがEloquentモデル内に混在してわかりづらい、かつファットになりやすかったため、今回Eloquentモデルはリポジトリ・クエリサービスからのデータベース操作でのみ利用しました。

その他

バリデーション実装方針

各層の責務範囲でバリデーションを行う

バリデーションは、UI層・アプリケーション層・ドメイン層の各層にて、それぞれの責務範囲内で行いました。

まずUI層のフォームリクエストでは、必須チェックや入力形式チェックなど、アプリケーション層に渡すための最低限のバリデーションのみを行います。
次にアプリケーション層では、ユースケースの実現に必要なドメインオブジェクトを利用しバリデーションを行います。
(あくまでビジネスロジックドメインオブジェクトに記載し、アプリケーション層はそれらを使って返ってきた結果をみて例外を投げています。)
最後にドメイン層にて、エンティティや値オブジェクト生成のためのデータ整合性担保のバリデーションを行います。

これにより、ドメイン層以外にドメインルールが漏れるなどの逆方向への依存や、コードが重複することなどの事象を回避することができました。

バリデーションエラーは可能な限りまとめて返す

エラー内容はバリデーションに引っかかる度に1つずつ返すより、可能な限りまとめて返した方が、一般的にはユーザー体験が向上します。
アプリケーション層やドメイン層も、Laravelのフォームリクエストに倣って、一通りのバリデーションを実行した後にエラーをまとめて返却するように実装しました。

終わりに

今回は、連載2回目ということで、Laravelを利用するという条件の下、DDDとレイヤードアーキテクチャを実現していくにあたり、具体的にどのように落とし込んでいったのか、特に悩んだ部分についてまとめました。

DDDはいざ実践するとなると、大枠は各文献を参考にしながら進めることはできましたが、より詳細な部分については、自分たちで情報収集・試行錯誤・取捨選択して進める必要があり、とても苦労しました。
そのため、ぜひ一例として、これからDDDを始める方や今まさに実践している方の参考になれば幸いです。


このようにスタイル・エッジでは、DDDなどのモダンな開発にも積極的にチャレンジできる環境です!
ご興味ある方はぜひ採用サイト ↓をぜひご覧ください ♪
recruit.styleedge.co.jp

次回は「【モダン開発 #3】TDDに挑戦してみた!」を投稿します。お楽しみに~