🙆

「ドメイン駆動設計入門」を読んでアプリを作ったので学んだことと考えをまとめた記事

に公開

はじめに

DDDキャッチアップの一環で以下書籍を読み、簡単なアプリケーションを作ったので理解した内容をまとめておく

https://www.shoeisha.co.jp/book/detail/9784798150727

https://asciidwango.jp/post/754242099814268928/関数型ドメインモデリング

※あくまで自分の理解・認識をまとめたものなので、間違ってるってところがあればコメントをいただけると嬉しいです🙏

書籍を読んだ上で個人開発したアプリは以下

https://github.com/mocchann/to-camp-cms

ドメインとは

例えば、会計システムの中で「消費税の計算」という問題を解決したいのであれば、消費税計算に関わる領域をドメインと呼ぶことになる

ドメイン駆動設計とは

また、ソフトウェアが責務を全うするために必要な知識を元に、事象や概念を抽象化する作業をモデリングと言う
モデリングの結果できた成果物をドメインモデルという

  • ドメインモデルは、ドメインのある特定の側面を表現したもので、現実世界のドメインをそのまま表したものではない
    • ドメインモデルを作成してすぐは「仮」の状態であり、実際に使ってみるまではドメインモデルが正しいかどうかは判断できない
      • そのため、ドメインモデリングしたあとにコードに落とし込み実際に使ってみる必要がある

そこで出た改善点を元にドメインモデルを更新していくというサイクルを回し、ソフトウェアの利用者を取り巻く世界と実装を結びつけることで、アプリケーションの機能性と保守性を高めることがドメイン駆動設計の目的である

戦略的DDDとは

設計の流れとしては以下のような手順で進める

  1. 開発者とドメインエキスパートが集まり、イベントストーミングによってドメインを可視化する
  2. ドメインをより小さいサブドメインに分割する
  3. 分割したサブドメインをもとに境界づけられたコンテキストを定義する
  4. コンテキストマップを作成して、境界づけられたコンテキストがどのように相互作用するかの全体像を把握する
  5. 開発者とドメインエキスパートの認識のズレを防ぐために、ユビキタス言語(関係者全員が共通して使用する言語)を定義する
  6. できあがった全体像(ワークフロー)を元に詳細をドメインエキスパートと一緒に詰めていく
  7. 複雑さをドメインモデルで表現する
  8. アーキテクチャ設計をして、実装に入っていく

戦術的DDDとは

主に以下の概念を用いる

値オブジェクト

ドメインにおけるを表すオブジェクトのこと

e.g. キャンプ場の立地条件(海・山・川などの立地条件のenumを持つ値オブジェクト)

<?php

namespace App\Domain\Models\CampGrounds;

use App\Domain\Enums\CampGroundLocations as EnumsCampGroundLocations;
use InvalidArgumentException;
use ValueError;

class CampGroundLocation
{
    private EnumsCampGroundLocations $camp_ground_location;

    public function __construct(
        private string $location
    ) {
        try {
            $this->camp_ground_location = EnumsCampGroundLocations::from($location);
        } catch (ValueError $e) {
            throw new InvalidArgumentException('Invalid location', $e->getMessage());
        }
    }

    public function getValue(): EnumsCampGroundLocations
    {
        return $this->camp_ground_location;
    }
}

識別子を持たない

  • エンティティは属性が変わっても同一性を保つために識別子(ID)を持つが、値オブジェクトはエンティティとは対照的で識別子を持たない
  • オブジェクトが持つ属性の値の組み合わせ自体が値オブジェクトを定義する

不変であること

  • 一度作成された値オブジェクトの状態は変更できない
  • もし、値を変える必要がある場合は、新しい値を持つ別のインスタンスを作成する

こうすることで意図しない副作用を防ぎ、オブジェクトの状態を予測可能で安全に保つことができる

置き換えが可能

  • 値オブジェクトの状態を更新する場合は、古いインスタンスを破棄し新しい値を持つインスタンスで「置き換える」ことになる
  • 値そのものが変われば別物として扱う

独自ライフサイクルを持たない

  • 必要に応じて作成され、不要になれば参照が外れてガベージコレクションの対象となる
  • エンティティは生成から変更、削除までのライフサイクルを持つため、エンティティのようにそれ自体が追跡・管理される対象にはならない

自己検証ロジックを持つ

  • 生成される際に自身の属性値が有効であるかを検証するロジックを持つことが推奨される

なぜ値オブジェクトを使うのか

  • ドメインの表現力向上
    • プリミティブ型をそのまま使うよりも、ドメインの概念を明確にコード場で表現できる
  • コードの明確化・凝集度向上
    • 値に関するロジック(検証、比較、計算など)を値オブジェクト内にカプセル化することで関連するコードがまとまる
  • 不変性による安全性
    • 状態が変わらないため、予期しない副作用が起こりにくく、安全に扱うことができる
  • 再利用性
    • ドメイン内で共通の「値」の概念を複数箇所で再利用できる

エンティティ

値オブジェクトがによって定義されるのに対して、エンティティはそれが何であるかという存在そのものによって定義される

e.g. キャンプ場自体はエンティティとして表現できる

<?php

namespace App\Domain\Models\CampGrounds;

class CampGround
{
    public function __construct(
        private CampGroundId $id,
        private CampGroundName $name,
        private CampGroundAddress $address,
        private CampGroundPrice $price,
        private CampGroundImage $image,
        private CampGroundStatus $status,
        private CampGroundLocation $location,
        private CampGroundElevation $elevation
    ) {
        $this->id = $id;
        $this->name = $name;
        $this->address = $address;
        $this->price = $price;
        $this->image = $image;
        $this->status = $status;
        $this->location = $location;
        $this->elevation = $elevation;
    }

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

    public function getName(): CampGroundName
    {
        return $this->name;
    }

    public function getAddress(): CampGroundAddress
    {
        return $this->address;
    }

    public function getPrice(): CampGroundPrice
    {
        return $this->price;
    }

    public function getImage(): CampGroundImage
    {
        return $this->image;
    }

    public function getStatus(): CampGroundStatus
    {
        return $this->status;
    }

    public function getLocation(): CampGroundLocation
    {
        return $this->location;
    }

    public function getElevation(): CampGroundElevation
    {
        return $this->elevation;
    }
}

識別子(Identity)を持つ

  • エンティティの最も重要な特徴は、そのライフサイクルを通じて一意に識別するための識別子(ID)を持つ
  • このIDはエンティティが破棄されない限り変わらない

可変であること

  • キャンプ場の名前が変わったり、価格が変わったりする
    • エンティティはこれらの変更を自身の状態として保持する

ライフサイクルを持つ

  • 生成 or 復元 → 使用 → 永続化 or 削除といった明確なライフサイクルを持つ
    • このライフサイクルはリポジトリパターンなどを介してDBなどのデータストアと連携して管理される

なぜエンティティを使うのか

  • ドメインの中心的な概念のモデル化
    • 業務上、個別に追跡・管理する必要があるモノやコト(顧客、注文など)を表現する
  • 状態変化と振る舞いのカプセル化
    • エンティティに関連するビジネスルールや状態遷移のロジックを、そのエンティティのメソッドとして実装することで、関心事をまとめ、凝集度を高める
  • 集約ルート
    • 多くのエンティティは、関連するオブジェクト(他のエンティティや値オブジェクト)をまとめた「集約」のルートとしての役割を果たし、整合性を保つ単位となる

ドメインサービス

ドメインサービス自体は状態を持たないため、再利用しやすくテストも容易である

e.g. ユーザーの重複確認ロジック

<?php

namespace App\Domain\Models\Users;

class UserService
{
    public function __construct(private IUserRepository $repository)
    {
        $this->repository = $repository;
    }

    public function exists(User $user): bool
    {
        return $this->repository->findByEmail($user->getEmail()) !== null;
    }
}

ドメインサービスはいつ使うのか

DDDではドメインロジックを可能な限りエンティティや値オブジェクト自身の振る舞いとして実装することが推奨される
しかし、以下のような場合にはドメインサービスの導入を検討する

複数のドメインオブジェクトが関与するロジック
  • ある操作が、複数のエンティティや値オブジェクトにまたがって行われる場合
    • 例えば、銀行口座間(AccountエンティティAからAccountエンティティBへ)の資金移動
      • この場合、A、Bどちらか一方の責務とするには不自然になるため、ドメインサービスに記述する
特定のオブジェクトに属さないドメイン固有の計算や変換ロジック
  • ドメインにおける重要な操作を表し、特定のエンティティや値オブジェクトの責務とは言い難い処理の場合
    • ある注文情報と顧客情報から請求書を生成する処理など
人工的なエンティティ/値オブジェクトの回避
  • 上記のようなロジックを無理やり既存のエンティティや値オブジェクトに押し込んだり、そのロジックのためだけに不自然なエンティティや値オブジェクトを作成したりするのを避けるため

ドメインモデル貧血症

ドメインに関連する振る舞いをなんでもドメインサービスに書いてしまうと、エンティティや値オブジェクトにとって重要なふるまいがドメインサービスに漏れ出てしまう

ドメインサービスのメリット

  • 関心の分離
    • ドメインロジックを、それが属さないエンティティ/値オブジェクトから分離し、モデルをクリーンに保つ
  • 凝集度の向上
    • 関連するドメイン操作を一つのサービスにまとめることができる
  • 明示的なプロセス表現
    • ドメイン内の重要なプロセスを、モデルの第一級の要素として明確に表現できる

リポジトリ

e.g. キャンプ場データを更新するリポジトリ

<?php

namespace App\Domain\Models\CampGrounds;

interface ICampGroundRepository
{
    public function get(GetCampGroundsFilter $filter): array;

    public function findById(CampGroundId $id): ?CampGround;

    public function update(CampGround $camp_ground): CampGround;

    public function delete(CampGroundId $id): void;
}

CampGroundRepository
<?php

namespace App\Repositories\CampGrounds;

use App\Domain\Models\CampGrounds\CampGround;
use App\Domain\Models\CampGrounds\CampGroundAddress;
use App\Domain\Models\CampGrounds\CampGroundElevation;
use App\Domain\Models\CampGrounds\CampGroundId;
use App\Domain\Models\CampGrounds\CampGroundImage;
use App\Domain\Models\CampGrounds\CampGroundLocation;
use App\Domain\Models\CampGrounds\CampGroundName;
use App\Domain\Models\CampGrounds\CampGroundPrice;
use App\Domain\Models\CampGrounds\CampGroundStatus;
use App\Domain\Models\CampGrounds\GetCampGroundsFilter;
use App\Domain\Models\CampGrounds\ICampGroundRepository;
use App\Models\CampGround as ModelsCampGround;
use App\Models\Location;
use App\Models\Status;
use Illuminate\Support\Facades\DB;

class CampGroundRepository implements ICampGroundRepository
{
    /**
     * @return array<CampGround>
     */
    public function get(GetCampGroundsFilter $filter): array
    {
        $query = ModelsCampGround::query();

        if ($filter->getId()) {
            $query->where('id', $filter->getId());
        }

        if ($filter->getName()) {
            $query->where('name', 'like', "%{$filter->getName()}%");
        }

        if ($filter->getAddress()) {
            $query->where('address', 'like', "%{$filter->getAddress()}%");
        }

        if ($filter->getPrice()) {
            $query->where('price', $filter->getPrice());
        }

        if ($filter->getImage()) {
            $query->where('image_url', 'like', "%{$filter->getImage()}%");
        }

        if ($filter->getStatus()) {
            $query->whereHas('statuses', function ($query) use ($filter) {
                $query->where('name', $filter->getStatus());
            });
        }

        if ($filter->getLocation()) {
            $query->whereHas('locations', function ($query) use ($filter) {
                $query->where('name', $filter->getLocation());
            });
        }

        if ($filter->getElevation()) {
            $query->where('elevation', $filter->getElevation());
        }

        $camp_grounds = $query->with('statuses', 'locations')->get();

        return $camp_grounds->map(
            fn($camp_ground) => new CampGround(
                new CampGroundId($camp_ground->id),
                new CampGroundName($camp_ground->name),
                new CampGroundAddress($camp_ground->address),
                new CampGroundPrice($camp_ground->price),
                new CampGroundImage($camp_ground->image_url),
                new CampGroundStatus($camp_ground->statuses->first()->name),
                new CampGroundLocation($camp_ground->locations->first()->name),
                new CampGroundElevation($camp_ground->elevation)
            )
        )->toArray();
    }

    public function findById(CampGroundId $id): ?CampGround
    {
        $camp_ground = ModelsCampGround::with('statuses', 'locations')->find($id->getValue());

        if (is_null($camp_ground)) {
            return null;
        }

        return new CampGround(
            new CampGroundId($camp_ground->id),
            new CampGroundName($camp_ground->name),
            new CampGroundAddress($camp_ground->address),
            new CampGroundPrice($camp_ground->price),
            new CampGroundImage($camp_ground->image_url),
            new CampGroundStatus($camp_ground->statuses->first()->name),
            new CampGroundLocation($camp_ground->locations->first()->name),
            new CampGroundElevation($camp_ground->elevation)
        );
    }

    public function update(CampGround $camp_ground): CampGround
    {
        return DB::transaction(function () use ($camp_ground) {
            $models_camp_ground = ModelsCampGround::updateOrCreate(
                ['id' => $camp_ground->getId()->getValue()],
                [
                    'name' => $camp_ground->getName()->getValue(),
                    'address' => $camp_ground->getAddress()->getValue(),
                    'price' => $camp_ground->getPrice()->getValue(),
                    'image_url' => $camp_ground->getImage()->getValue(),
                    'elevation' => $camp_ground->getElevation()->getValue(),
                ]
            );

            $camp_ground_status_value = $camp_ground->getStatus()->getValue()->value;
            $status_id = Status::where('name', $camp_ground_status_value)->first()->id;
            $status = $models_camp_ground->statuses->first();
            if (is_null($status)) {
                $models_camp_ground->statuses()->attach($status_id);
                $models_camp_ground->load('statuses');
            }
            if ($status && $status->name !== $camp_ground_status_value) {
                $models_camp_ground->statuses()->updateExistingPivot($status->id, ['status_id' => $status_id]);
                $models_camp_ground->load('statuses');
            }

            $camp_ground_location_value = $camp_ground->getLocation()->getValue()->value;
            $location_id = Location::where('name', $camp_ground_location_value)->first()->id;
            $location = $models_camp_ground->locations->first();
            if (is_null($location)) {
                $models_camp_ground->locations()->attach($location_id);
                $models_camp_ground->load('locations');
            }
            if ($location && $location->name !== $camp_ground_location_value) {
                $models_camp_ground->locations()->updateExistingPivot($location->id, ['location_id' => $location_id]);
                $models_camp_ground->load('locations');
            }

            return new CampGround(
                new CampGroundId($models_camp_ground->id),
                new CampGroundName($models_camp_ground->name),
                new CampGroundAddress($models_camp_ground->address),
                new CampGroundPrice($models_camp_ground->price),
                new CampGroundImage($models_camp_ground->image_url),
                new CampGroundStatus($models_camp_ground->statuses->first()->name),
                new CampGroundLocation($models_camp_ground->locations->first()->name),
                new CampGroundElevation($models_camp_ground->elevation)
            );
        });
    }

    public function delete(CampGroundId $id): void
    {
        $models_camp_ground = ModelsCampGround::findOrFail($id->getValue());

        $models_camp_ground->delete();
    }
}

ドメイン層と永続化層の分離

  • 最大の目的は、エンティティ・値オブジェクト・ドメインサービスが、どのようにデータを永続化するかの技術的な詳細を知らなくて良いようにすること
  • エンティティなどはドメインの関心ごとに集中するべきであるため、DBテーブルの構造などに引きずられるべきでない

永続化ロジックの抽象化

  • データアクセスに関する複雑なコード(SQLクエリ、ORMの設定、接続管理など)をリポジトリの実装クラスにカプセル化する
  • ドメイン層やアプリケーション層のクライアントコードは、シンプルなインターフェース(例:findById, save)を通じてドメインオブジェクトにアクセスできる

テスト容易性の向上

  • ドメイン層やアプリケーション層のテストを行う際に、実際のリポジトリ実装(データベースにアクセスするもの等)を、インメモリのスタブやモックに簡単に差し替えることができる
  • これにより、データベース接続なしにビジネスロジックの単体テストや結合テストが可能になる

データアクセスロジックの集約

  • 特定の集約に対するデータアクセスロジックを一箇所にまとめることでコードの重複を防ぎ管理しやすくなる
  • 基本的には集約ルートごとにリポジトリを定義する
    • 例えば、キャンプ場エンティティとユーザーエンティティという2つの集約ルートがあれば、リポジトリもキャンプ場リポジトリとユーザーリポジトリの2つを定義する

永続化技術の詳細を隠蔽

  • リポジトリのインターフェースはドメイン層に属し、リポジトリの実装クラスはインフラストラクチャ層に属す(ここでは依存性逆転の原則が適用される)

完全に構成された集約を返す

  • リポジトリがデータを取得する際には、必要な子エンティティや値オブジェクトを含んだ、完全な状態の集約ルートを再構築して返す責務を持つ

アプリケーションサービス

ユーザーからの要求を達成するために必要なドメイン層のオブジェクト(エンティティ・値オブジェクト・ドメインサービス)やインフラストラクチャ層のコンポーネント(リポジトリ)を協調して動作させる

e.g. キャンプ場データ削除のアプリケーションサービス

<?php

namespace App\UseCase\CampGrounds;

use App\Domain\Models\CampGrounds\CampGroundId;
use App\Domain\Models\CampGrounds\ICampGroundRepository;

class DeleteCampGround
{
    public function __construct(private ICampGroundRepository $repository)
    {
        $this->repository = $repository;
    }

    public function execute(string $id): void
    {
        $camp_ground_id = new CampGroundId($id);

        $this->repository->delete($camp_ground_id);
    }
}

ドメイン層への入口

  • プレゼンテーション層(UI、APIコントローラーなど)や他の外部クライアントからドメイン層への主要な入口(窓口)となる
  • クライアントはドメイン内部の処理を意識することなく、アプリケーションサービスを通じての目的の操作を依頼できる

ドメインロジックの委譲

  • アプリケーションサービス自体は、ドメイン固有のビジネスルール(ドメインロジック)をほとんど含まない
  • ドメインロジックとアプリケーションのワークフローや技術的関心事を分離できる
  • 複雑なビジネスルールは、エンティティ・値オブジェクト・ドメインサービスに委譲し、アプリケーションサービスはそれらを適切な順序で呼び出す指揮者の役割に徹する
  • 「指揮者」に徹するため、薄く(コード量が少なく)作られることが推奨される

アプリケーション固有の関心ごとの処理

  • ドメインロジックではなく、ユースケース実現のために必要な処理、リポジトリを使ったデータの取得・保存、メール送信やセキュリティチェックなどを担当する

ファクトリ

e.g. 注文エンティティの生成時に、割引や配送方法の計算など、複数のドメインサービスと連携するケース

class OrderFactory
{
    private DiscountService $discountService;
    private ShippingService $shippingService;

    public function __construct(DiscountService $discountService, ShippingService $shippingService)
    {
        $this->discountService = $discountService;
        $this->shippingService = $shippingService;
    }

    public function createOrder(CustomerId $customerId, array $orderItems, string $shippingAddress): Order
    {
        $orderId = new OrderId(uniqid());
        $status = OrderStatus::ORDERED;

        // 割引額と配送料をドメインサービスで計算
        $discountAmount = $this->discountService->calculateDiscount($orderItems);
        $shippingFee = $this->shippingService->calculateShippingFee($shippingAddress);

        return new Order($orderId, $customerId, $orderItems, $status, $discountAmount, $shippingFee);
    }
}

複雑な生成ロジックのカプセル化

  • オブジェクトの生成に複数のステップが必要だったり、依存関係の解決、初期値の設定、特定の計算やルール適用が必要だったりする場合、その複雑な手順をファクトリ内に隠蔽する

クライアントコードの単純化

  • オブジェクトを必要とするクライアント(アプリケーションサービス)は、複雑な生成手順を知る必要がなくなるので、単にファクトリに必要な情報を渡すだけで、完成した有効なオブジェクトを得ることができる

関心の分離

  • オブジェクトを「生成」する責務と、そのオブジェクトを「利用」する責務を分離することで各コンポーネントの責務が明確になり、コードの見通しが良くなる

DDDにおけるファクトリの定義基準

  • 生成ロジックが比較的単純で、そのオブジェクト自身の知識と密接に関連している場合に、オブジェクト自身のクラス内に静的メソッドとして実装する
    • Order::createNew(...), Product::register(...)など
  • 生成ロジックが複雑な場合や生成に他のコンポーネントへの依存が必要な場合は、専用のファクトリクラスを定義する

コマンドオブジェクト

e.g. キャンプ場データを更新する場合のコマンドオブジェクト

<?php

namespace App\UseCase\CampGrounds;

class UpdateCampGroundCommand
{
    public function __construct(
        private string $id,
        private string $name,
        private string $address,
        private int $price,
        private string $image,
        private string $status,
        private string $location,
        private int $elevation
    ) {
        $this->id = $id;
        $this->name = $name;
        $this->address = $address;
        $this->price = $price;
        $this->image = $image;
        $this->status = $status;
        $this->location = $location;
        $this->elevation = $elevation;
    }

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

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

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

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

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

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

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

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

コマンドオブジェクトの使用例
<?php

namespace App\UseCase\CampGrounds;

use App\Domain\Models\CampGrounds\CampGround;
use App\Domain\Models\CampGrounds\CampGroundAddress;
use App\Domain\Models\CampGrounds\CampGroundElevation;
use App\Domain\Models\CampGrounds\CampGroundId;
use App\Domain\Models\CampGrounds\CampGroundImage;
use App\Domain\Models\CampGrounds\CampGroundLocation;
use App\Domain\Models\CampGrounds\CampGroundName;
use App\Domain\Models\CampGrounds\CampGroundPrice;
use App\Domain\Models\CampGrounds\CampGroundStatus;
use App\Domain\Models\CampGrounds\ICampGroundRepository;

class UpdateCampGround
{
    public function __construct(
        private ICampGroundRepository $repository
    ) {
        $this->repository = $repository;
    }

    public function execute(UpdateCampGroundCommand $command): CampGround
    {
        $camp_ground = new CampGround(
            new CampGroundId($command->getId()),
            new CampGroundName($command->getName()),
            new CampGroundAddress($command->getAddress()),
            new CampGroundPrice($command->getPrice()),
            new CampGroundImage($command->getImage()),
            new CampGroundStatus($command->getStatus()),
            new CampGroundLocation($command->getLocation()),
            new CampGroundElevation($command->getElevation())
        );

        return $this->repository->update($camp_ground);
    }
}

リクエストとハンドラの分離

  • コマンドを発行する側(プレゼンテーション層のコントローラー)は、そのコマンドを実際に処理するロジックの詳細を知る必要がないため、コマンドオブジェクトというメッセージを渡すだけで済む
  • また、コマンドオブジェクトを定義しておけばメッセージ内容が変わった際にもその変更を吸収できる

入力検証を行う

  • コマンドオブジェクト自体やそれを受け取る直前の層で、必須項目の存在のチェックなど、ドメインロジックに入る前の基本的な入力検証を行う場所として使える

集約

集約は明確な境界を持ち、内部のオブジェクトと外部のオブジェクトを区別する

e.g. キャンプ場の場合、「キャンプ場エンティティ」が集約ルートになり、キャンプ場内にあるキャンプサイトを表現する「キャンプサイトエンティティ」はキャンプ場エンティティのプロパティとして定義される。このキャンプサイト情報を更新する場合、必ず集約ルートであるキャンプ場エンティティからアクセスしなければならない

alt text

集約ルート

  • 集約内に存在する特定のエンティティのことで、その集約全体の入口となる
  • 集約ルートは識別子(ID)を持つ
  • 集約の外部にあるオブジェクトが、その集約の内部にあるオブジェクトへアクセスする際に、唯一許可されるアクセスポイント

境界

  • 集約の内部と外部を明確に分ける概念的な線引きのことで、どのオブジェクトが集約に含まれるかを定義する

内部オブジェクト

  • 集約ルート以外の、境界内部に含まれるエンティティや値オブジェクトのこと
  • 内部のエンティティは集約内でのみ意味を持つローカルな識別子を持ち、これは外部から直接参照されるべきではない

整合性の維持

  • 集約の最も重要な目的は整合性の維持
  • 集約は境界を定義し、複数のオブジェクトにまたがるビジネスルールをこの境界内で常に維持する
  • 集約内部の状態を変更する操作は必ず集約ルートを通じて行う
  • 集約ルートはその操作によって、集約全体が矛盾のない有効な状態に保たれることを保証する責任を持つ

モデルの複雑性の削減

  • 関連性の強いオブジェクトをグループ化し、それらを一つの単位として扱うことで、オブジェクト間の複雑な関連や整合性ルールを管理しやすくする
  • 個々のオブジェクトの関係性を細かく追うのではなく、集約単位で考えられるようになる

ドランザクション境界の定義

  • 通常、一つの集約に対する操作(復元、更新、保存)は、単一のトランザクション内で行われるべき
  • 集約全体をリポジトリを使って復元し、集約ルートを通じて更新、集約全体をリポジトリに渡して保存する

ライフサイクルの管理

  • 集約内部のオブジェクト(特に内部エンティティ)のライフサイクルは多くの場合、集約ルートのライフサイクルに依存する
  • 集約ルートが削除されれば、内部のオブジェクトも一緒に削除されると考える

仕様

複雑な評価手順をアプリケーションサービスに記述してしまうことが多いが、オブジェクトの評価はドメインの重要なルールなのでその対策として仕様が用いられる

e.g. キャンプサイトが駐車場に近いかを判断する仕様


<?php

namespace App\Domain\Camping\Specification\Concrete;

use App\Domain\Camping\Campsite;
use App\Domain\Camping\Specification\CampsiteSpecification;

// 仕様: 駐車場に近いか? (パラメータ化された仕様)
final readonly class NearParkingSpecification implements CampsiteSpecification
{
    public function __construct(private int $maxDistance)
    {
        if ($this->maxDistance < 0) {
            throw new \InvalidArgumentException('Maximum distance cannot be negative.');
        }
    }

    public function isSatisfiedBy(Campsite $campsite): bool
    {
        return $campsite->distanceFromParking <= $this->maxDistance;
    }
}

<?php

namespace App\Domain\Camping\Specification\Composite;

use App\Domain\Camping\Campsite;
use App\Domain\Camping\Specification\CampsiteSpecification;

// AND条件で仕様同士を組み合わせて表現することも可能
final readonly class AndSpecification implements CampsiteSpecification
{
    public function __construct(
        private CampsiteSpecification $left,
        private CampsiteSpecification $right
    ) {}

    public function isSatisfiedBy(Campsite $campsite): bool
    {
        return $this->left->isSatisfiedBy($campsite)
            && $this->right->isSatisfiedBy($campsite);
    }
}

ビジネスルールのカプセル化

  • オブジェクトを選択するための複雑な条件(クエリ条件)や、オブジェクトが有効かどうかを判断するための検証ルールを、それを利用するコード(エンティティのメソッド、リポジトリ、ドメインサービスなど)から切り離し、専用の仕様オブジェクト内にカプセル化する

明示性と可読性の向上

  • 複雑な条件分岐の連鎖や、長いクエリ条件などを意味のある名前を持つ仕様オブジェクトとして定義することで、明示性・可読性の向上を図る

組み合わせによって柔軟性を得る

  • 単純な仕様を論理演算子(AND, OR, NOT)で組み合わせることで、既存の仕様を変更することなく、より複雑なルールを構築できる
  • リポジトリに仕様を引き渡して、仕様に合致するオブジェクトを検索する
  • 仮に検索処理をリポジトリに実装してしまうと重要なルールがリポジトリに記述されてしまうので、仕様オブジェクトとして定義してリポジトリに引き渡すことで、重要なルールがリポジトリに漏れ出すことを防げる

おわりに

というわで、DDDの学びと考えをまとめてみました!

DDDは単なる技術的なパターン集ではなく、ソフトウェア開発の中心にドメイン(解決したい問題領域)を据えて、その複雑さに立ち向かうための思考プロセスだと考えています

この記事がこれからDDDをキャッチアップしようとしている方や、すでに実践中の方の理解の一助となれば嬉しいです(記事の内容で「ん?」と思うところや「これはこうやろ!」というアドバイスがあればコメントで教えていただけると助かります🙏)

GitHubで編集を提案

Discussion