目次を表示する

戦術的DDD実践ガイド 2026

Entity ── 識別子とライフサイクルの設計

本章の方針

Entity(エンティティ)は、一意な識別子を持ち、時間とともに状態が変化するオブジェクト だ。VOが「値」なら、Entity は「ライフサイクルを持つ個体」である。本章では、Entityの本質と、実務で最も陥りやすい「God Entity(神エンティティ)」問題、そしてFactoryとの関係を扱う。


Entity の本質

VOとEntityの違いは、一行で言える。

VO  :値が同じなら同一。生成後に変わらない。
Entity:IDが同じなら同一。生成後に変わり続ける。

SaaS課金管理での具体例を見る。

// VO:通貨が同じで金額が同じなら同じ「お金」
const m1 = Money.of(1000, Currency.JPY)
const m2 = Money.of(1000, Currency.JPY)
m1.equals(m2)  // true

// Entity:IDが同じなら「同じ契約」だが、状態は時間で変わる
const sub1 = Subscription.create(customerId, planId)
// 翌日...
sub1.upgrade(newPlanId)  // 状態が変わっても「同じ契約」

Entityの核心は 「IDが同じなら同一」 であることだ。状態の変化はEntityの本質を変えない。


Entityの実装(SaaS契約管理)

契約管理コンテキストの主要なEntityを実装する。

// [VO] SubscriptionStatus
type SubscriptionStatus =
  | 'trial'
  | 'active'
  | 'past_due'      // 支払い失敗・猶予期間中
  | 'canceled'
  | 'expired'

// [Entity] Subscription
class Subscription {
  private constructor(
    readonly id: SubscriptionId,
    readonly customerId: CustomerId,
    private _planId: PlanId,
    private _status: SubscriptionStatus,
    private _currentPeriod: BillingPeriod,
    private _canceledAt: Date | null,
  ) {}

  // --- Factory method(静的生成メソッド)---
  static startTrial(
    customerId: CustomerId,
    planId: PlanId,
    trialPeriod: BillingPeriod,
  ): Subscription {
    return new Subscription(
      SubscriptionId.generate(),
      customerId,
      planId,
      'trial',
      trialPeriod,
      null,
    )
  }

  // --- 振る舞い(ドメインロジック)---
  activate(): void {
    if (this._status !== 'trial') {
      throw new Error(`Cannot activate subscription in ${this._status} state`)
    }
    this._status = 'active'
  }

  upgrade(newPlanId: PlanId): void {
    if (this._status !== 'active') {
      throw new Error('Only active subscription can be upgraded')
    }
    this._planId = newPlanId
  }

  cancel(asOf: Date): void {
    if (this._status === 'canceled' || this._status === 'expired') {
      throw new Error('Already terminated')
    }
    this._status = 'canceled'
    this._canceledAt = asOf
  }

  // --- 等価性は ID で判定 ---
  equals(other: Subscription): boolean {
    return this.id.equals(other.id)
  }

  // --- ゲッタ(必要最小限)---
  get status(): SubscriptionStatus { return this._status }
  get planId(): PlanId { return this._planId }
}

ここで重要なポイントは3つある。

  1. idreadonly:Entityの識別子は生成後に変わらない
  2. 状態変更は専用メソッドでsubscription.status = 'active' のような直接代入を許さない
  3. 等価性は ID で判定:内容ではなくIDで比較する

Entity設計の3つの迷い所

Entityを書き始めると、多くの人が悩む箇所がある。

迷い1:ゲッタを全フィールドに付けるべきか

答えは 「ノー」。ゲッタは外部が本当に必要とする情報だけに絞る。

// ❌ 全フィールド公開
class Subscription {
  get id() { ... }
  get customerId() { ... }
  get planId() { ... }
  get status() { ... }
  get currentPeriod() { ... }
  get canceledAt() { ... }
  get createdAt() { ... }
  get updatedAt() { ... }
  // ... 外部が触る必要のないものまで全公開
}

// ✅ 必要なものだけ
class Subscription {
  get id(): SubscriptionId { ... }
  get status(): SubscriptionStatus { ... }
  get planId(): PlanId { ... }
  // それ以外は外部に出さない
}

ゲッタを増やしすぎると、Entity の内部状態に外部が依存 してしまい、Entity のリファクタリングが破壊的になる。

迷い2:セッタを書くべきか

答えは 「原則ノー」。セッタは存在しないべきだ。状態変更は常に 意味のあるメソッド で行う。

// ❌ セッタで状態変更
subscription.status = 'canceled'
subscription.canceledAt = new Date()

// ✅ 意味のあるメソッドで状態変更
subscription.cancel(new Date())

後者は「キャンセルする」というドメイン操作として読める。前者は「状態をいじる」としか読めない。意味のあるメソッドは 不変条件を一箇所に集約 する効果もある(cancel() 内で「既に期限切れなら例外」などのルールを書ける)。

迷い3:Entityを肥大化させるか分割するか

答えは 「不変条件の単位で分割する」。ただし実務では、これが最も難しい。

後述の「God Entity アンチパターン」で詳しく扱う。


Factory:複雑な生成ロジックの居場所

Entity の生成ロジックが複雑になる場合に登場するのが Factory だ。ただし2026年現在、筆者は 「Factoryクラスを明示的に作るべき場面は少ない」 という立場を取る。多くの場合、Entity 内の 静的生成メソッド(static factory method) で十分だ。

静的生成メソッドで十分な例

// Entity内のstatic methodで十分
class Subscription {
  static startTrial(customerId, planId, trialPeriod): Subscription {
    // 初期化ロジック
  }

  static restoreFromSnapshot(data: SubscriptionSnapshot): Subscription {
    // 永続化層からの復元
  }
}

専用のFactoryクラスが必要な例

複数の集約を同時に生成したり、外部サービスに問い合わせる必要がある場合は、専用の Factory を用意する。

// 専用Factoryが妥当な例
class SubscriptionFactory {
  constructor(
    private readonly planRepository: PlanRepository,
    private readonly featureFlagService: FeatureFlagService,
  ) {}

  async createForNewCustomer(
    customerId: CustomerId,
    planCode: PlanCode,
  ): Promise<Subscription> {
    // Plan集約を取得する必要がある
    const plan = await this.planRepository.findByCode(planCode)
    if (!plan) throw new Error(`Plan not found: ${planCode}`)

    // フィーチャーフラグに応じてトライアル期間を変える
    const trialDays = await this.featureFlagService.trialDaysFor(customerId)
    const trialPeriod = BillingPeriod.of(
      new Date(),
      addDays(new Date(), trialDays),
    )

    return Subscription.startTrial(customerId, plan.id, trialPeriod)
  }
}

判断基準:Entity内の情報だけで生成できるなら static method、外部依存(他の集約・外部サービス・フィーチャーフラグ)が必要なら Factory クラス。


God Entityアンチパターン

Entityを使い続けると、必ず出会う病気がある。God Entity(神エンティティ) だ。

症状

// ❌ God Entity の典型
class Subscription {
  // 30個以上のフィールド
  // 50個以上のメソッド
  // トライアル管理・課金・通知送信・フィーチャー制御・使用量計測・監査ログ
  // すべてが1つのクラスに詰め込まれている

  sendWelcomeEmail() { ... }     // ← なぜEntityがメール送信?
  calculateUsage() { ... }       // ← 使用量は別集約では?
  logAuditEvent() { ... }        // ← 監査は横断関心事では?
  checkFeatureAvailable() { ... } // ← フィーチャーフラグはドメイン?
}

根本原因

3つの原因が複合する。

  1. ドメインの境界が見えていない:「Subscription に関係しそう」な処理を全て追加してしまう
  2. 「Serviceに書くと貧弱なドメインモデルになる」という恐れ:Ch.9 で扱うAnemic論争への過剰反応
  3. モデリングのリファクタリングを怠る:最初に決めた境界を更新しない

脱出法

ステップ1:そのメソッドが「Subscriptionの不変条件を守っている」か問う
   守っていないなら Entity から外す。

ステップ2:別のEntity・VO・集約に所属する責務を切り出す
   例:calculateUsage() → UsageRecord集約へ
   例:sendWelcomeEmail() → Notification側のイベントハンドラへ

ステップ3:純粋な「取得系の情報」はEntityに残さず、Query側に寄せる
   CQRS的に、Entity は「状態を変更する操作」の主役に限定する

ステップ4:それでも残る「どこに置けばいいか分からないロジック」は
   Domain Service を検討する(Ch.6 で詳述)

Entity の肯定的な体験

体験1:状態遷移バグの撲滅

以前は、契約ステータスを直接代入していた(sub.status = 'canceled')。ある日、「トライアル中の契約をいきなり expired にする」というバグが発生した。Entity化して activate() / cancel() / expire() のような意味のあるメソッドだけを公開した結果、不正な状態遷移がコード上で起こせなくなった

体験2:レビューでの議論が減った

「この処理はどこに書くべきか」のレビュー議論が、Entity導入後に明確に減った。「Subscriptionの状態を変える処理なら Subscription クラス」というルールが明文化されたためだ。


Entity の否定的な体験

体験1:ORMとの摩擦がVO以上に激しい

Entity は プライベートフィールド + 意味のあるメソッド で構成される。しかし多くのORMは、プリミティブなDTOとして読み書きできる ことを前提に設計されている。TypeORMやPrismaでEntityを直接マッピングしようとすると、

・プライベートフィールドにアクセスできない
・コンストラクタが private なので復元できない
・メソッドが付いているオブジェクトをシリアライズできない

という摩擦が生じた。Repository層で「Entity ↔ 永続化DTO」の変換 を明示的に書く運用に切り替えた。詳細は Ch.6 で扱う。

体験2:Entity名の議論が終わらない

「これは SubscriptionContractSubscription Agreement か」というユビキタス言語論争に、チームで3日費やしたことがある。名前選びは重要だが、初期は仮の名前で進め、後でリファクタ する柔軟性を持つべきだった。


本章のまとめ

・Entity は「ID が同じなら同一」なオブジェクト
・状態変更は専用メソッドで、直接代入は禁止
・ゲッタ・セッタは最小限。外部に必要な情報だけ
・Factory は外部依存がある場合のみ専用クラスを作る
・God Entity は最大の敵。責務ごとに分割する勇気を持つ

次章では、Entityの集まりである 集約(Aggregate) を扱う。戦術的DDDで最も議論を呼び、最も失敗しやすいパターンだ。