📐

DDDのEntity設計原則:不変条件とビジネスルールを分離する

に公開

ドメイン駆動設計(DDD)を実践していると、この要件はどのレイヤーに置くべきかと迷うことがあります。

特にドメイン層では、Entityや値オブジェクト、ドメインサービスが複雑に絡み合い、1レイヤー1クラスのような単純な分割では整理しきれません。

たとえば、注文Entityでは「注文の合計金額は0円以上」と「キャンペーン期間中は10%割引」のようなルールをすべて詰め込むと、Entityはすぐに肥大化します。

この問題に直面したとき「不変条件(Invariants)」と「ビジネスルール[1]」は異なる性質をもつことを知りました。

この記事では、その違いを軸に、Entity内の責務をどう分離すべきかを考察します。

肥大化したEntity

以前、私はありとあらゆるルールを1つのEntityの中に書いていました。

このようなコードには、いくつかの問題点があります。

  • キャンペーン追加や送料計算の変更で、他の条件まで再検証が必要になる
  • constructorCustomerに依存し、Entityの生成条件が複雑化している
  • constructorが肥大化して責務の境界が不明瞭になる
class Order {
  private items: OrderItem[];
  private discount: number = 0;
  private shippingFee: number;
  private tax: number = 0;

  constructor(items: OrderItem[], customer: Customer) {
    // 注文成立条件
    if (items.length === 0) throw new Error("商品が必要です");
    this.items = items;

    // VIP割引 (customerの会員ランクに依存)
    if (customer.isVip) this.discount = 0.1;

    // 曜日割引
    if (new Date().getDay() === 5) this.discount = 0.05;

    // 期間限定キャンペーン (customerの購買回数に依存)
    const month = new Date().getMonth();
    if (month === 11 && customer.purchaseCount > 10) this.discount = 0.15;

    // 送料計算
    this.shippingFee = this.calculateShipping();

    // 税金計算
    this.tax = this.calculateTax();

    // 合計チェック
    if (this.total < 0) throw new Error("合計金額が不正です");
  }

  get subtotal(): number {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }

  get total(): number {
    return this.subtotal * (1 - this.discount) + this.shippingFee + this.tax;
  }

  private calculateShipping(): number { /* 送料計算 */ }
  private calculateTax(): number { /* 税金計算 */ }
}

2種類のルール

Entity肥大化の根本的な原因は、性質の異なる2種類のルールを区別せずに実装していることにあります。

不変条件とは

不変条件(Invariant)は、ドメインの整合性を守る存在条件です。これはオブジェクトが存在するために満たすべき制約であり、ドメインの本質的な定義に関わるものです。

例えば、以下のようなルールが不変条件に該当します。

  • 注文には最低1つの商品が必要である
  • 金額は負にならない
  • 確定済み注文は変更できない
  • 割引率は0から1の範囲である

不変条件の特徴は、変更頻度が極めて低いことです。これらはドメインの定義そのものであり、ビジネスの根幹が変わらない限り変更されることはありません。

変更要因もドメインモデルの再定義という稀なケースに限られます。責任の所在も明確で、Entity自身が保証すべきものです。

ビジネスルールとは

ビジネスルール(Business Rule)は、業務上の判断基準や計算ロジックです。これはビジネス戦略や方針に基づく計算・判定であり、不変条件とは異なる性質を持ちます。

例えば、以下のようなルールがビジネスルールに該当します。

  • VIP会員は10%割引する
  • 金曜日は5%オフにする
  • 5000円以上で送料無料にする
  • 購入金額の1%をポイントとして付与する

ビジネスルールの特徴は変更頻度が高いことです。キャンペーンの実施、経営方針の転換などに応じて、月次や四半期ごとに見直されることがあります。

変更要因も外部要因(経営判断、市場環境、法規制)であり、不変条件とは明確に異なります。責任の所在についても、Entity外部で管理すべきものです。

不変条件とビジネスルールの比較

整理すると、以下のようになります。

観点 不変条件 ビジネスルール
目的 ドメインの整合性を守る存在条件 業務上の判断基準や計算ロジック
変更頻度 極めて低い(数年以上) 高い(月次〜年次)
変更要因 ドメインモデルの再定義 外部要因(経営、市場、法律)
具体例 注文には商品が必要、金額は負でない VIPは10%割引、金曜は5%オフ

ソフトウェア設計原則の違反

不変条件とビジネスルールの混在は、SOLID原則の2つの設計原則に違反します。

  • 単一責任原則(Single Responsibility Principle, SRP)
  • 開放閉鎖原則(Open-Closed Principle, OCP)

SRP違反: 変更理由が異なるものを混在させると、影響範囲が拡大する

単一責任原則(SRP)は、モジュールを変更する理由は1つだけであるべきことを要求します。

不変条件はドメイン定義の変更という理由で変わり、ビジネスルールは外部要因という理由で変わります。変更理由が異なるものを同居させることは、この原則に反しています。

送料計算ロジックを変更する場合、混在したOrderクラスではOrder.calculateShipping()を修正すると、Orderクラス全体を再テストしなければなりません。

本来変更していない不変条件のチェックも再検証の対象になり、Orderに依存する他の機能にも影響を与えます。結果として、送料計算だけの変更が、注文ドメイン全体の検証に拡大してしまいます。

class Order {
  private items: OrderItem[];
  private shippingFee: number;

  constructor(items: OrderItem[], customer: Customer) {
    // ✅ 不変条件
    if (items.length === 0) throw new Error("商品が必要");
    this.items = items;

    // ❌ ビジネスルール
    this.shippingFee = this.calculateShipping(customer);
  }

  private calculateShipping(_customer: Customer): number { /* 送料計算 */ }
}

OCP違反: 拡張のたびにEntityを修正している

開放閉鎖原則(OCP)は、拡張に開いて修正に閉じることを要求します。拡張に開くとは新機能追加が容易であることを意味し、修正に閉じるとは既存コードを変更しないことを意味します。

不変条件とビジネスルールの混在は、拡張に閉じており、修正に開いている状態なのでOCPに違反しています。

例えば、新しいキャンペーンを追加したいという要求が発生した場合、混在したEntityではOrderクラスのconstructorにキャンペーン条件を追加する実装が必要になります。

このような設計では、ビジネスルールの追加や変更のたびにOrderを直接修正する必要が生じ、既存の振る舞いに意図しない影響を与えるリスクが高まります。

class Order {
  private items: OrderItem[];
  private discount: number = 0;

  constructor(items: OrderItem[], customer: Customer) {
    // ✅ 不変条件
    if (items.length === 0) throw new Error("商品が必要");
    this.items = items;

    // ❌ 既存のビジネスルール
    if (customer.isVip) this.discount = 0.1;

    // ❌ 新しいキャンペーンを追加するたびに、ここを修正
    if (customer.purchaseCount > 10) this.discount = 0.15;
  }
}

不変条件とビジネスルールの境界

ここまで、不変条件とビジネスルールの責務を分離すべきであることを述べてきました。
しかし実際の開発では、どのロジックがどちらに属するのか、その境界が曖昧になることがあります。

消費税率は普段は固定ですが法改正によって変更される可能性があり、会員ランクの定義も「ドメインの本質的なルール」と「ビジネス上の運用ルール」のどちらに位置づけるか判断が難しい場合があります。

本記事では、このような判断のために「変更頻度」と「変更要因」 の2つの軸を基準に責務の分離を検討することを提案します。

1: 変更頻度

どれくらいの周期で変わるかで判断します。

年数回以上変わるものはビジネスルールとして外部化を検討し、数年単位でほぼ変わらないものは不変条件としてEntity内部に配置します。

例えば、季節キャンペーンの割引条件のように短期間で変更されるルールはビジネスルールとして外部化すべきと判断できます。

VIP会員割引のロジックも月次で見直される可能性があるため、ビジネスルールとして外部化しておくのが望ましいといえます。

一方、サービス開始以来変わらない注文の成立条件(注文は1つ以上の注文が必ず存在する)はほとんど変更されないため、不変条件としてEntity内部に保持します。

2: 変更要因

なぜ変わるのかで判断します。

変更要因によって、責務を分離するかどうかを判断します。外部要因(法律、市場、経営判断)によって変わるものはビジネスルール、ドメイン定義の変更によって変わるものは不変条件として扱います。

例えば、税率計算の場合、変更要因は消費税法の改正という外部要因です。変更頻度は数年に1回と低いものの、外部要因であるため、ビジネスルールとして外部化すべきと判断できます。

送料計算も同様で、変更要因は物流コストや競合状況といった外部要因です。これらもビジネスルールとして外部化することが望ましいといえます。

一方、注文の成立条件は、変更要因が「注文とは何か」というドメイン定義の変更にあたります。ドメインの本質的な定義であり、変更頻度もほとんどないため、Entity内部に保持する不変条件と判断します。

2軸での評価

変更の頻度を縦軸(高/低)、要因を横軸(外部/内部)に置くと、次のように4つのパターンに整理できます。

変更頻度 \ 変更要因 外部要因 内部要因
高頻度 ビジネスルール(キャンペーン、割引率など) レアケース(ドメイン設計の見直しが必要な可能性)
低頻度 ビジネスルール(税率計算など) 不変条件(注文成立条件など)

基本的には外部要因かつ変更頻度が高ければ、ビジネスルールとして切り出すことを積極的に検討してよいと思います。

クラスを分離することは影響範囲の調査や実装コストがかかるため、プロジェクトの状況を考慮して判断することが重要です。

リファクタリング

ここからは最初に提示したコードを例に、段階的なリファクタリングする手順を説明します。

Before: ルールが混在しているOrderクラス

こちらのコードを修正します。constructor内に不変条件とビジネスルールが混在しているので、ビジネスルールを切り離したいところです。

class Order {
  private items: OrderItem[];
  private discount: number = 0;
  private shippingFee: number;
  private tax: number = 0;

  constructor(items: OrderItem[], customer: Customer) {
    // ✅ 不変条件: 注文成立条件
    if (items.length === 0) throw new Error("商品が必要です");
    this.items = items;

    // ❌ ビジネスルール: VIP割引
    if (customer.isVip) {
      this.discount = 0.1;
    }

    // ❌ ビジネスルール: 曜日割引
    const today = new Date().getDay();
    if (today === 5) {
      this.discount = Math.max(this.discount, 0.05);
    }

    // ❌ ビジネスルール: 期間限定キャンペーン
    const month = new Date().getMonth();
    if (month === 11 && customer.purchaseCount > 10) {
      this.discount = 0.15;
    }

    // ❌ ビジネスルール: 送料計算
    if (customer.totalPurchase > 5000) {
      this.shippingFee = 0;
    } else {
      this.shippingFee = 500;
    }

    // ❌ ビジネスルール: 税金計算
    this.tax = this.subtotal * 0.1;

    // ✅ 不変条件: 合計チェック
    if (this.total < 0) throw new Error("合計金額が不正です");
  }

  get subtotal(): number {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }

  get total(): number {
    return this.subtotal * (1 - this.discount) + this.shippingFee + this.tax;
  }
}

Step1: 不変条件を明確化

まず、Entityが本当に守るべき不変条件だけを残し、ビジネスルールの計算ロジックを外部クラスに委譲します。

ここでの変更点は3つです。

  • ビジネスルールをconstructorから削除
  • ビジネスルールの適用は専用メソッドでのみ変更できるようにした
  • 割引率や送料の妥当性チェック(ありえない範囲の数値でないか)を追加
class Order {
  private items: OrderItem[];
  private discount: number = 0;
  private shippingFee: number = 0;
  private tax: number = 0;
  private status: OrderStatus = "draft";
  private confirmedAt?: Date;

  constructor(items: OrderItem[]) {
    // ✅ 不変条件: 注文の成立条件
    if (items.length === 0) {
      throw new Error("注文には最低1つの商品が必要です");
    }
    this.items = [...items]; // 防御的コピー
  }

  // ✅ 不変条件: 割引率の妥当性
  applyDiscount(discountRate: number): void {
    if (discountRate < 0 || discountRate > 1) {
      throw new Error("割引率は0から1の範囲である必要があります");
    }
    this.discount = discountRate;
  }

  // ✅ 不変条件: 送料の妥当性
  setShippingFee(fee: number): void {
    if (fee < 0) {
      throw new Error("送料は0以上である必要があります");
    }
    this.shippingFee = fee;
  }

  // ✅ 不変条件: 税金の妥当性
  setTax(tax: number): void {
    if (tax < 0) {
      throw new Error("税金は0以上である必要があります");
    }
    this.tax = tax;
  }

  // ✅ 不変条件: 状態遷移の整合性
  confirm(confirmedAt: Date): void {
    if (this.status === "confirmed") {
      throw new Error("既に確定済みです");
    }
    if (this.items.length === 0) {
      throw new Error("商品のない注文は確定できません");
    }
    this.status = "confirmed";
    this.confirmedAt = confirmedAt;
  }

  get subtotal(): number {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }

  get total(): number {
    return this.subtotal * (1 - this.discount) + this.shippingFee + this.tax;
  }
}

Step2: ビジネスルールを外部化

切り出した注文に関するビジネスルールは以下のようになっていました。これをPolicyクラスとして表現します。

  • 割引率の計算
    • VIP割引、曜日割引
    • 年末キャンペーン割引
  • 送料計算
    • 通常料金、VIP/5000円以上で送料無料
  • 税金計算

このフェーズの変更点は4つです。

  • DiscountPolicyインターフェースを定義
  • 割引率の計算ロジック(StandardDiscountPolicy, YearEndCampaignPolicy)を定義
  • 送料の計算ロジック(ShippingFeePolicy)を定義
  • 税金の計算ロジック(TaxPolicy)を定義
// 期間やキャンペーンに応じて具体的な実装を切り替えられる
// 例: 12月は年末キャンペーン用のDiscountPolicyを適用
interface DiscountPolicy {
  calculate(customer: Customer, order: Order, date: Date): number;
}
// 標準的な割引ポリシー
class StandardDiscountPolicy implements DiscountPolicy {
  calculate(customer: Customer, _order: Order, date: Date): number {
    let discount = 0;

    // VIP割引
    if (customer.isVip) {
      discount = Math.max(discount, 0.1);
    }

    // 曜日別割引
    if (date.getDay() === 5) {
      discount = Math.max(discount, 0.05);
    }

    return discount;
  }
}

// 年末キャンペーン割引ポリシー
class YearEndCampaignPolicy implements DiscountPolicy {
  constructor(private basePolicy: DiscountPolicy) {}

  calculate(customer: Customer, order: Order, date: Date): number {
    const baseDiscount = this.basePolicy.calculate(customer, order, date);

    // 年末キャンペーン: 12月は追加割引
    if (date.getMonth() === 11 && customer.purchaseCount > 10) {
      return Math.min(baseDiscount + 0.05, 0.2); // 最大20%
    }

    return baseDiscount;
  }
}
// 送料計算ポリシー
class ShippingFeePolicy {
  calculate(order: Order, customer: Customer): number {
    const subtotal = order.subtotal;

    // 5000円以上で送料無料
    if (subtotal >= 5000) return 0;

    // VIPは常に送料無料
    if (customer.isVip) return 0;

    // 通常送料
    return 500;
  }
}
// 税金計算ポリシー
class TaxPolicy {
  calculate(subtotal: number): number {
    return subtotal * 0.1; // 10%
  }
}

Step3: Application層での組み立て

不変条件とビジネスルールをApplication層で組み合わせます。

Entity生成では不変条件のみを検証し、Policy適用でビジネスルールを計算します。責務が明確に分離され、組み立てのプロセスが見えるようになりました。

class OrderService {
  constructor(
    private discountPolicy: DiscountPolicy,
    private shippingFeePolicy: ShippingFeePolicy,
    private taxPolicy: TaxPolicy,
    private clock: Clock,
  ) {}

  createOrder(items: OrderItem[], customer: Customer): Order {
    // 1. Entity生成(不変条件のみ検証)
    const order = new Order(items);

    // 2. ビジネスルール適用: 割引計算
    const discount = this.discountPolicy.calculate(customer, order, this.clock.now());
    order.applyDiscount(discount);

    // 3. ビジネスルール適用: 送料計算
    const shippingFee = this.shippingFeePolicy.calculate(order, customer);
    order.setShippingFee(shippingFee);

    // 4. ビジネスルール適用: 税金計算
    const tax = this.taxPolicy.calculate(order.subtotal);
    order.setTax(tax);

    // 5. 注文確定
    order.confirm(this.clock.now());

    return order;
  }
}
補足: 注文の復元について

コンストラクタで注文を作成した直後は、割引率が適用されていない状態になります。
注文の更新処理など、DBからEntityを復元する際は、fromPersistenceのようなメソッドを定義して、永続化された値を直接セットする必要があります。

class Order {
  // DB復元用: 内部的に直接セットする
  static fromPersistence(
    items: OrderItem[],
    discount: number,
    shippingFee: number,
    // ...
  ): Order {
    const order = new Order(items);
    order.discount = discount;
    order.shippingFee = shippingFee;
    // ...
    return order;
  }
}

まとめ

Entityの肥大化は、異なる性質のルールを理解せずに混在させることが原因です。不変条件とビジネスルールを適切に分離することで、変更に強く保守しやすいシステムを構築できます。

要点

  • 変更理由が異なるものを分離するため、不変条件はEntity内部に、ビジネスルールはEntity外部に配置します。
  • 不変条件とビジネスルールの分類は、変更頻度と変更要因の2つの軸を基準に判断します。
  • Entityの責務を分離することは、保守性の向上と開発スピードの確保のバランスを考慮する必要があります。

参考文献

脚注
  1. ビジネスルールの定義について
    本記事では以下の用語を便宜的に使用します。
    同じ用語でも著者や資料によって意味が微妙に異なる場合があります。
    不変条件(Invariant / Business Invariant / true invariant)
    Entityや集約内部で常に保持されるべきルール
    ビジネスルール(Business Rule)
    業務上の条件や制約で、状況やコンテキストによって変化するルール。ポリシー(Policy / Corrective Policy)と呼ばれる場合もあります。
    文脈によっては、ビジネスルールが不変条件も含め業務上の制約全般を指すこともあります。 ↩︎

Discussion