目次を表示する

EvansとVernonで学ぶDDD

[共通] 境界づけられたコンテキスト(Bounded Context)── モデルに境界を引く

[共通] 境界づけられたコンテキスト(Bounded Context)── モデルに境界を引く

なぜモデルに境界が必要か

採用管理システムの設計を始めるとき、「候補者(Candidate)」という概念は最初から存在する。採用担当者も開発者も、この言葉を日常的に使う。問題は、この言葉が指す実体がコンテキストによって異なることだ。

求人管理コンテキストでの候補者は、求人票に興味を示したが、まだ応募フォームを送っていない人物を指す。メールアドレスと氏名程度の情報しかなく、選考上のステータスも存在しない。選考管理コンテキストでの候補者は、選考プロセスの中に入り込んでいる人物だ。書類審査・一次面接・二次面接・最終面接といったステージのいずれかに位置し、合否の判定を受け続けている。候補者管理コンテキストでの候補者は、スキルセット・職務経歴・資格情報を持つプロフィールの束として扱われる。将来の採用活動にも参照されうる人材データベースの単位だ。

これら3つは同じ「候補者」という言葉を使いながら、内包する情報も、適用されるルールも、ライフサイクルも異なる。この状況でシステム全体を1つの Candidate クラスで表現しようとすると、設計はすぐに破綻する。書類審査ステータスを持ちながらスキルセットも持ち、求人への興味フラグも持ち、特定のコンテキストでしか使わないフィールドが null で放置されるようなクラスが誕生する。ルールも混在する。「応募前候補者には面接フィードバックを登録できない」という選考管理のルールを、候補者管理のコードが誤って呼び出す可能性が生まれる。

Evansはこの状況を「大きな泥団子(Big Ball of Mud)」と呼ばれる状態に近いものとして警戒し、モデルに明示的な境界を引くことを提唱した。その単位が境界づけられたコンテキストだ。

境界づけられたコンテキストとは何か

境界づけられたコンテキストとは、特定の用語・定義・ルールが一貫して適用されるソフトウェアの明確な境界領域である。Evansは青本の中でこの概念を次のように説明している。「ある大きなシステムにおいて、すべての条件が変わらないまま1つのモデルが適用されることを保証できる部分」として定義された。

コンテキストの内側では、ユビキタス言語(前章参照)が完全に一致する。「候補者」という言葉がコンテキスト内のどこで使われても、同じ意味・同じ属性・同じ振る舞いを指す。コードのクラス名、データベースのテーブル名、チームの会話、仕様書の記述がすべて同一の概念を指している状態が保たれる。

コンテキストの外側との通信は、明示的なインターフェースを通じて行う。選考管理コンテキストが候補者管理コンテキストのデータを必要とするとき、候補者管理の内部モデルをそのまま参照するのではなく、明示的に定義された変換層を介する。これにより、一方のコンテキストの内部変更が他方に波及することを防ぐ。

コンテキストとサービスやモジュールの境界は必ずしも一致しない。1つのコンテキストが複数のサービスに分散することも、逆に複数の論理的コンテキストが一時的に同一のデプロイ単位に同居することもある。コンテキストはアーキテクチャ上の単位ではなく、モデルの意味的な単位だ。

採用管理システムの4コンテキスト設計

採用管理システムの4つのコンテキストは、それぞれ独自の関心を持つ。

求人管理コンテキストは求人票のライフサイクルを管理する。求人票は「下書き」「公開中」「締切済み」というステータスを持ち、応募締切日や募集要件が属する。このコンテキストの「候補者」は、求人票に紐づいた興味表明の記録にすぎず、選考上の情報を一切持たない。

選考管理コンテキストは応募から内定・不合格決定までのプロセスを管理する。「候補者」はステージと結果を持つ。面接フィードバックの記録、合否判定の変更履歴もこのコンテキストに属する。求人管理コンテキストから見れば、選考の詳細は関知しない。

候補者管理コンテキストは人材データベースとしての役割を担う。「候補者」はフルネーム、連絡先、スキルのリスト、職務経歴、最終学歴といった属性群として表現される。選考の進行状況は持たない。このコンテキストは将来的なタレントプール管理にも使われる。

通知コンテキストは他のコンテキストからドメインイベントを受け取り、メールやリマインダーを送信する。固有の業務ルールは薄く、典型的なサポートサブドメインだ。

「候補者」という概念がコンテキストごとにどう異なるかは、TypeScriptの型定義に直接反映される。

選考管理コンテキストでは、ScreeningProcess(選考プロセス)が集約ルートである。選考の進行・合否判定はこの集約に閉じており、Candidate オブジェクトへの直接参照は持たない。候補者は CandidateId というIDのみで参照される。これはVernonが示す「集約間はIDで参照する」原則に従った設計だ。

// 選考管理コンテキストの ScreeningProcess(集約ルート)
// 選考プロセスそのものを表現し、候補者はIDのみで参照する
class ScreeningProcess {
  readonly id: ScreeningProcessId;
  readonly candidateId: CandidateId;  // IDのみ参照(候補者オブジェクトは持たない)
  readonly jobPostingId: JobPostingId;
  private currentStage: ScreeningStage;

  advance(to: ScreeningStage): void {
    // ステージ遷移ルールはこのコンテキストのみが知る
    if (!this.currentStage.canAdvanceTo(to)) {
      throw new Error(`${this.currentStage} から ${to} への遷移は無効`);
    }
    this.currentStage = to;
  }

  reject(reason: RejectionReason): void {
    this.currentStage = ScreeningStage.Rejected;
  }
}

// 候補者管理コンテキストの Candidate
// プロフィール情報を持つ
class Candidate {
  readonly id: CandidateId;
  readonly name: FullName;
  readonly skills: Skill[];
  readonly workHistory: WorkHistory[];

  addSkill(skill: Skill): Candidate {
    // スキル重複チェックはこのコンテキストのルール
    const alreadyExists = this.skills.some((s) => s.equals(skill));
    if (alreadyExists) return this;
    return new Candidate(this.id, this.name, [...this.skills, skill], this.workHistory);
  }
}

選考管理コンテキストでは ScreeningProcess が中心モデルであり、Candidate クラスは存在しない。候補者管理コンテキストでは Candidate がプロフィールの束として扱われる。両コンテキストは同じ CandidateId を通じて間接的に繋がるが、モデルの属性・メソッド・ルールはまったく異なる。コンテキストをまたいで同一のクラスを使い回すことを、この設計は明示的に拒否する。

コンテキストの境界をどう決めるか

境界の設定は機械的な手順では解けない問題だ。Evansは青本の中で、いくつかの手がかりを示した。

一つ目は言語の境界だ。同じ言葉がチームや文脈によって異なる意味で使われていることに気づいたとき、そこにコンテキストの境界が潜んでいる可能性がある。「候補者」が示す通りだ。

二つ目はチームと組織の構造だ。Evansはコンテキストはチームの作業単位と対応しやすいと述べた。異なるチームが独立してリリースできる境界が、コンテキストの自然な切れ目になることが多い。

三つ目はビジネス機能の境界だ。「求人票を管理する」「選考を進める」「人材を記録する」という業務上の責務の違いが、コンテキストの分割軸になる。責務が異なれば、変更の理由も変更の頻度も異なるからだ。

Vernonは赤本でこの判断をより実践的に整理した。具体的には、コンテキスト間の依存関係と統合パターンを可視化するContext Mapという手法として体系化している。境界を引いた後にどうコンテキストを繋ぐかは、次章で扱う。


コラム: 貧血ドメインモデル(Anemic Domain Model)

境界のないモデルが行き着く典型的な末路として、Martin Fowlerが命名したアンチパターンがある。getter と setter だけを持ち、業務ロジックを一切持たないクラス群が、データの入れ物として並ぶ設計だ。

// 貧血ドメインモデルの典型例
class Candidate {
  id: string;
  name: string;
  stage: string;         // どのコンテキストの概念か不明
  skills: string[];      // 候補者管理の関心
  appliedAt: Date;       // 選考管理の関心
  jobPostingId: string;  // 求人管理の関心
  // ...フィールドが際限なく増える
}

// ロジックはすべてサービスクラスへ流出する
class CandidateService {
  advance(candidate: Candidate, nextStage: string): void { ... }
  addSkill(candidate: Candidate, skill: string): void { ... }
  notifyStatusChange(candidate: Candidate): void { ... }
}

この構造はデータと振る舞いの分離を招き、CandidateService はあらゆる業務ロジックの集積地となって肥大化する。コンテキストをまたいだルールが同一クラスに混在するため、変更の影響範囲が読めなくなる。Fowlerはこれを「オブジェクト指向設計の精神に反する」と評した。境界づけられたコンテキストは、この状態を構造的に防ぐ。


インフォグラフィック

サマリー

章末図:採用管理システムの境界づけられたコンテキスト

graph TD
  subgraph JM["求人管理コンテキスト"]
    JP[JobPosting\n求人票]
    JC[Candidate\n興味表明者]
    JP --> JC
  end

  subgraph SM["選考管理コンテキスト"]
    AP[Application\n応募]
    SC[Candidate\n選考中の人物]
    IF[InterviewFeedback\n面接評価]
    AP --> SC
    SC --> IF
  end

  subgraph CM["候補者管理コンテキスト"]
    CP[Candidate\nプロフィール]
    SK[Skill\nスキル]
    WH[WorkHistory\n職務経歴]
    CP --> SK
    CP --> WH
  end

  subgraph NT["通知コンテキスト"]
    NF[Notification\n通知]
    TP[NotificationTemplate\nテンプレート]
    NF --> TP
  end

  JM -->|応募イベント| SM
  SM -->|ステータス変更イベント| NT
  CM -->|プロフィール参照| SM