目次を表示する

EvansとVernonで学ぶDDD

[共通] エンティティ(Entity)と値オブジェクト(Value Object)── 同一性とは何か

エンティティとは何か

Evansは「ドメイン駆動設計」の中で、エンティティを次のように定義している。オブジェクトの本質が属性の組み合わせではなく、「同一性の連続性(continuity of identity)」によって規定されるもの、それがエンティティである。

具体的に言えば、エンティティはIDを持ち、そのIDが変わらない限り、属性がいかに変化しても「同じオブジェクト」として扱われる。名前が変わろうと、住所が変わろうと、IDが同一であれば同一のエンティティだ。

採用管理システムで考えてみる。Candidate(候補者)はエンティティの典型例だ。候補者が結婚して姓が変わっても、連絡先のメールアドレスを変更しても、その人は選考プロセス上で同一の候補者である。システムが追跡しているのは「その人物」という存在そのものであり、属性の集合ではない。

同様に、JobPosting(求人票)もエンティティだ。採用担当者が求人タイトルを修正したり、業務内容の記述を更新したりしても、その求人票は同一の求人として継続する。応募者との紐付けや選考履歴はIDを軸に保持されるため、属性の変化によって同一性が失われてはならない。

エンティティが必要になる条件は明確だ。「そのオブジェクトのライフサイクルを通じて追跡が必要か」という問いに「はい」と答えられるなら、それはエンティティである。

値オブジェクトとは何か

値オブジェクトは、固有のIDを持たず、属性の組み合わせそのものによって定義される不変オブジェクトだ。Evansの定義では、同じ属性値を持つ2つの値オブジェクトは完全に等しいとみなされる。これを「値の等価性(value equality)」という。

銀行券に例えるとわかりやすい。1万円札2枚は、印刷番号が異なっていても経済的には等価だ。「この1万円札」を特定して追跡する必要はない。値オブジェクトはこれと同じ性質を持つ。

採用管理システムにおける値オブジェクトの例を挙げる。

  • FullName(氏名): 姓と名の組み合わせで定義される。同じ姓・名を持つ2つのFullNameは等しい。ただし、氏名そのものをドメインイベントとして追跡する必要はない
  • EmailAddress(メールアドレス): フォーマットの正当性を内包した文字列の値。同じアドレス文字列を持つ2つのEmailAddressは等価
  • ScreeningStage(選考ステージ): 「書類選考」「一次面接」「二次面接」「内定」といったステージを表す値。ステージそのものに固有IDは不要

値オブジェクトのもう一つの重要な特性は不変性だ。一度生成した値オブジェクトの内部状態は変更しない。変更が必要な場合は新しい値オブジェクトを生成して置き換える。この不変性が、値オブジェクトを扱うコードのシンプルさを保証する。

TypeScriptでの実装

エンティティ基底クラス

// エンティティ基底クラス: IDによる同一性比較
abstract class Entity<T> {
  protected readonly _id: T

  constructor(id: T) {
    this._id = id
  }

  get id(): T {
    return this._id
  }

  equals(other: Entity<T>): boolean {
    if (!(other instanceof Entity)) return false
    return this._id === other._id
  }
}

IDの型をジェネリクスで受け取る構造にすることで、CandidateIdJobPostingIdといった型安全なIDを各エンティティに持たせられる。

値オブジェクトの実装例

// 値オブジェクト: EmailAddress
class EmailAddress {
  private readonly value: string

  private constructor(value: string) {
    this.value = value
  }

  // ファクトリメソッドでバリデーションを集約
  static create(value: string): EmailAddress {
    if (!value.includes('@')) throw new Error('Invalid email format')
    return new EmailAddress(value.toLowerCase())
  }

  equals(other: EmailAddress): boolean {
    return this.value === other.value
  }

  toString(): string {
    return this.value
  }
}

// 値オブジェクト: FullName
class FullName {
  private readonly firstName: string
  private readonly lastName: string

  private constructor(firstName: string, lastName: string) {
    this.firstName = firstName
    this.lastName = lastName
  }

  static create(firstName: string, lastName: string): FullName {
    if (!firstName.trim() || !lastName.trim()) {
      throw new Error('Name must not be empty')
    }
    return new FullName(firstName.trim(), lastName.trim())
  }

  equals(other: FullName): boolean {
    return this.firstName === other.firstName && this.lastName === other.lastName
  }

  toString(): string {
    return `${this.lastName} ${this.firstName}`
  }
}

// 値オブジェクト: ScreeningStage(選考ステージ)
type ScreeningStageValue = 'document' | 'first_interview' | 'second_interview' | 'offer'

class ScreeningStage {
  private readonly value: ScreeningStageValue

  private constructor(value: ScreeningStageValue) {
    this.value = value
  }

  static documentReview(): ScreeningStage {
    return new ScreeningStage('document')
  }

  static firstInterview(): ScreeningStage {
    return new ScreeningStage('first_interview')
  }

  static offer(): ScreeningStage {
    return new ScreeningStage('offer')
  }

  equals(other: ScreeningStage): boolean {
    return this.value === other.value
  }
}

コンストラクタをprivateにし、ファクトリメソッドを通じてのみインスタンスを生成する構造がポイントだ。バリデーションをファクトリメソッドに集約することで、不正な状態のオブジェクトがシステム内に存在できない設計になる。

エンティティの実装例

type CandidateId = string

// エンティティ: Candidate(選考管理コンテキスト)
class Candidate extends Entity<CandidateId> {
  private name: FullName
  private email: EmailAddress
  private stage: ScreeningStage

  constructor(
    id: CandidateId,
    name: FullName,
    email: EmailAddress,
    stage: ScreeningStage
  ) {
    super(id)
    this.name = name
    this.email = email
    this.stage = stage
  }

  // メールアドレス変更: 新しい値オブジェクトで置き換え
  changeEmail(newEmail: EmailAddress): void {
    this.email = newEmail
  }

  // ステージ更新: 新しい値オブジェクトで置き換え
  advanceStage(nextStage: ScreeningStage): void {
    this.stage = nextStage
  }

  getEmail(): EmailAddress {
    return this.email
  }

  getStage(): ScreeningStage {
    return this.stage
  }
}

CandidateのIDは変わらないが、emailstageといったフィールドは値オブジェクトの置き換えによって更新される。値オブジェクト自体は不変であっても、エンティティがその参照を更新することで状態変化を表現する点に注目してほしい。

エンティティと値オブジェクトの使い分け

判断の軸は2つある。

1. 追跡が必要か

そのオブジェクトのライフサイクルを通じて同一性を追跡する必要があるなら、エンティティだ。採用管理システムにおけるCandidateは選考開始から内定・辞退に至るまで追跡される。JobPostingは公開から掲載終了まで追跡される。一方、EmailAddressFullNameにそのようなライフサイクルはない。

2. 交換可能か

同じ属性値を持つ別インスタンスに置き換えても意味が変わらないなら、値オブジェクトだ。EmailAddress("[email protected]")を別のインスタンスで生成しても、それらは等価として扱えばよい。

Vernonは「実装するドメイン駆動設計」の中で、迷ったら値オブジェクトを選ぶよう勧めている。値オブジェクトは不変であるため、状態変化を追跡する必要がなく、テストも書きやすい。エンティティの数を最小限に抑えることで、ドメインモデル全体の状態管理の複雑さが減る。

採用管理システムでも、一見エンティティに見えるものが値オブジェクトとして扱えるケースがある。たとえばInterviewSchedule(面接日程)は、変更時に既存オブジェクトを更新するよりも、新しい日程を示す値オブジェクトで置き換えるほうがシンプルな実装になることが多い。

EvansとVernonの共通認識

エンティティと値オブジェクトの区別は、DDDの中でも最も基礎的な概念のひとつだ。EvansとVernonの両者とも、この区別をドメインモデリングの出発点として強調している。

Evansの「ドメイン駆動設計」ではエンティティの章の前に値オブジェクトを独立して扱い、軽視されがちなこの概念に正当な地位を与えた。VernonはさらにここからStepを進め、「実装するドメイン駆動設計」では値オブジェクトをエンティティより先に詳述している。Vernonが値オブジェクトの積極的活用を推奨する背景には、現代のソフトウェアが持つ状態管理の複雑さへの問題意識がある。

実装の詳細を見ると、両者ともに「バリデーションをオブジェクト生成時に行う」という点で一致している。EmailAddressのファクトリメソッドがフォーマット検証を担うように、不正な状態のオブジェクトを生成できない設計が、ドメインロジックの堅牢性を支える。


インフォグラフィック

サマリー

graph TD
    subgraph 同一性["同一性(Identity)で識別 → エンティティ"]
        E1["Candidate(候補者)<br/>id: CandidateId<br/>─────────────<br/>名前が変わっても同じ候補者<br/>IDが追跡の軸"]
        E2["JobPosting(求人票)<br/>id: JobPostingId<br/>─────────────<br/>タイトルを変更しても同じ求人<br/>応募データと紐づく"]
    end

    subgraph 等価性["等価性(Equality)で識別 → 値オブジェクト"]
        V1["EmailAddress<br/>value: string<br/>─────────────<br/>同じアドレス文字列 = 等価<br/>不変・IDなし"]
        V2["FullName<br/>firstName / lastName<br/>─────────────<br/>同じ姓・名の組み合わせ = 等価<br/>不変・IDなし"]
        V3["ScreeningStage<br/>value: document | first_interview ...<br/>─────────────<br/>同じステージ値 = 等価<br/>不変・IDなし"]
    end

    E1 -->|フィールドとして保持| V1
    E1 -->|フィールドとして保持| V2
    E1 -->|フィールドとして保持| V3

上の図はエンティティと値オブジェクトの関係を示している。CandidateはIDによって同一性を持つエンティティであり、EmailAddressFullNameScreeningStageといった値オブジェクトをフィールドとして保持する。値オブジェクトは不変であるため、変更が発生した際はエンティティ内のフィールドを新しいインスタンスで置き換える。