🧩

ドメイン駆動設計(DDD)実践:GHG排出量可視化で学ぶ集約ルートの設計と責務

に公開

GHG排出量可視化で学ぶ集約ルートの設計と責務

はじめに

「ドメイン駆動設計(DDD)を導入したいけど、具体的にどうすればいいの?」
「エンティティをクラス化したはいいけど、どこまでがそのクラスの責任範囲なんだろう?」

ソフトウェア開発において、このような疑問に直面することは少なくありません。特に、ビジネスロジックが複雑になるにつれて、コードの整合性を保ち、変更に強い設計を実現することは大きな課題となります。

本記事では、ドメイン駆動設計(DDD) の中核概念である集約(Aggregate)集約ルート(Aggregate Root) に焦点を当て、具体的なGHG排出量可視化ソリューション を題材にしながら、その設計と責務について深く掘り下げていきます。


1.集約と集約ルートとは?

  • 集約(Aggregate) = 一貫性を保つべきドメインオブジェクトのまとまり
  • 集約ルート(Aggregate Root) = 集約の代表であり、外部との唯一の接点となるオブジェクト

集約ルートは、集約内部の整合性を保証する責任者であり、外部に対しては窓口としての役割を果たします。

  • 具体的には、集約ルートは以下のような責務を担います。

    • ✔️ 代表者(=代表エンティティ)
      • 集約の中で唯一、外部と直接やりとりできる存在です。
      • 他の構成要素(値オブジェクトや子エンティティ)への外部からの直接操作は禁止されます。
    • ✔️ 操作用の公開メソッドを持つ
      • 例)recalculate(Factor),updateActivityAmount(ActivityAmount,Factor)など
      • ドメインの“意思”を反映した動詞メソッドが、このルートに集中して定義されます。
  • なぜ集約が必要なのか?

    • 現実のビジネスでは、複数のデータが連動して変更される場面があります。
      例:「活動量が変わったら、排出量も自動で再計算されるべき」

この時、どこまでを一体として管理するかの境界が「集約」です。


2.GHG排出量ドメインにおける集約例

🎯 例:Emission 集約

class Emission
{
    private function __construct(
        private EmissionId $id,
        private ActivityAmount $activityAmount,
        private FactorId $factorId,
        private GHGEmissionValue $value
    ) {
    }

    public static function create(
        EmissionId $id,
        ActivityAmount $activityAmount,
        Factor $factor
    ): self {
        // 例: ここで係数の種類が一致しない場合はエラーとするなど、生成時の整合性も保証できます
        // if (!$factor->matches($id->getRelatedFactorId())) { ... }
        $initialValue = new GHGEmissionValue(
            $activityAmount->value() * $factor->coefficient()
        );
        return new self($id, $activityAmount, $factor->id(), $initialValue);
    }

    public function recalculate(Factor $factor): void
    {
        // 係数の種類が一致しない場合はドメイン例外をスローし、不正な再計算を防ぐ
        if (!$factor->matches($this->factorId)) {
            throw new DomainException("係数の種類が一致しません");
        }

        $this->value = new GHGEmissionValue(
            $this->activityAmount->value() * $factor->coefficient()
        );
    }

    /**
     * 活動量を更新し、それに伴って排出量も再計算します。
     * 集約ルートは、自身の整合性を保つために必要な情報を外部から受け取ります。
     *
     * @param ActivityAmount $newAmount 新しい活動量
     * @param Factor $currentFactor 現在の排出係数(再計算のために必要)
     * @throws DomainException 係数の種類が一致しない場合
     */
    public function updateActivityAmount(ActivityAmount $newAmount, Factor $currentFactor): void
    {
        // 係数の種類が一致するかどうかを再度チェック
        if (!$currentFactor->matches($this->factorId)) {
            throw new DomainException("新しい活動量に対する係数の種類が一致しません");
        }

        $this->activityAmount = $newAmount;
        // 活動量が変わったら、排出量も自動で再計算されるべき
     $this->recalculate($currentFactor);
    }

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

    public function emissionValue(): GHGEmissionValue
    {
        return $this->value;
    }
}
  • Emission は集約ルート
  • ActivityAmountFactorId は内部構成要素(直接操作不可)
  • recalculate()updateActivityAmount()は、排出量に関する公開される業務操作(責務)です。これらのメソッドを通じてのみ、排出量の再計算や活動量の更新が行われ、その際にFactorの種類が一致するかどうかのドメインルールが強制されます。

3.❌ NG例:内部構造への直接アクセス

$emission = $repository->findById($emissionId);
$emission->activityAmount()->setValue(999);  // 排出量が再計算されない!
$emission->value()->update(1500);            // 活動量との整合性が取れない!

// この時点で、活動量999 × 係数 ≠ 排出量1500 という不整合が発生
  • 活動量が変更されても排出量が再計算されない
  • カプセル化の破壊 & 整合性が保証されない
    ※そもそもprivateメンバへのアクセスなのでPHPとしてもエラーとなる。
  • Law of Demeter(デメテルの法則)に違反、友達の友達に話しかけちゃいけないよ!

4.✅ 正しい設計:ルート経由での操作

では、どのようにすれば集約の整合性を保ちながら、活動量を更新できるでしょうか?

// 使用例
$newActivityAmount = new ActivityAmount(999);

// 現在のFactorオブジェクトをリポジトリなどから取得(アプリケーションサービス層で行う処理)
$currentFactor = $factorRepository->findById($emission->factorId());

$emission->updateActivityAmount($newActivityAmount, $currentFactor); // ✅ OK

このアプローチでは、外部は Emission に対して「活動量を更新して、それに伴う再計算もやって」と命令(Tell) するだけです。

  • Emissionの内部構造は隠蔽されたままです。

  • updateActivityAmountメソッドが集約ルートの責任として、活動量の更新とGHGEmissionValueの再計算という一連の整合性保証を行います。

これは、オブジェクト指向の別の重要な原則である Tell, Don’t Ask(命令しろ、問い合わせるな) 原則にも適しています。
集約ルートが自身の状態管理とビジネスロジックの実行に責任を持つことで、外部は集約の内部実装を知る必要がなくなります。


5.集約ルートの4つの重要な責務

責務 詳細 具体例
🔒 整合性の保証 集約内部の全オブジェクトが常に正しい状態を保つ 活動量変更時の排出量自動再計算
🚪 アクセス制御 外部からの内部への直接アクセスを禁止 repositoryは集約ルートのみ取得可能
🎯 操作の主語 ビジネスロジックを表現するメソッドの提供 recalculate(), updateActivityAmount()
📦 カプセル化 内部実装の隠蔽により変更の影響を局所化 計算ロジック変更が外部に影響しない

6.おわりに

集約ルートは「一貫性の守護者」であり「業務ロジックの実行主体」です。
内部のオブジェクトに直接命令するのではなく、ルートに対して“お願い”する設計を意識することで、
保守性・拡張性・テストのしやすさが大きく向上します。

今一度、自分のコードベースに登場する「エンティティやモデル」が
本当に適切な集約ルートになっているか、ぜひ見直してみてください。


次回は「複雑なユースケースと整合性保証のトレードオフ」編を予定しています!


次回予告の背景:なぜ「複数集約の扱い方」が重要なのか?

現実の業務ユースケースでは、1つの処理が複数の集約をまたぐことは珍しくありません。
たとえば、以下のようなGHG排出量可視化ソリューションにおけるユースケースです:

  • 「ユーザーが活動量データを入力・保存したあと、管理者に承認依頼メールを送信したい」
  • 「GHG排出量を再計算したあと、それに基づいたレポートを更新したい」

このとき、1つの処理の中で複数の集約を操作する必要がありますが、DDDの原則では「集約は整合性の境界」であり、「1トランザクション = 1集約」が推奨されます。

では、どうやって“ユースケースとしての一貫性”と“集約ごとの整合性”を両立させるか?

このジレンマをどう扱うかは、DDDを実践するうえで避けて通れない設計課題です。

次回の記事では、まさにこのトレードオフと設計の選択肢について深掘りしていきます。

  • どこまでを1集約に含めるべきか?
  • トランザクションはどう貼る?
  • 整合性を保ちながらユースケース全体を成功させるには?

こうした問いに対して、設計原則だけでなく、現実的な落としどころも探っていきます。

Booost

Discussion