目次を表示する

EvansとVernonで学ぶDDD

[実装] TypeScriptで全パターンを繋げる

本章の方針

本章は、これまでの章で個別に解説した概念を1つのコードベースに統合する。対象は選考管理コンテキストの実装である。

扱うパターンは以下の通りだ。

  • 値オブジェクト(Value Object)・エンティティ(Entity)(第6章)
  • 集約(Aggregate)・集約ルート(第7章)
  • ドメインイベント(Domain Event)(第8章)
  • CQRS(第9章)
  • ヘキサゴナルアーキテクチャ(Hexagonal Architecture)のポート・アダプター

各コードブロックの冒頭に // [パターン名] を付記する。どのパターンが実装されているかが一目でわかるようにするためだ。


ディレクトリ構造

src/
├── screening/                    # 選考管理コンテキスト
│   ├── domain/
│   │   ├── ScreeningProcess.ts   # 集約ルート
│   │   ├── ScreeningStage.ts     # 値オブジェクト
│   │   ├── events/
│   │   │   ├── ScreeningAdvanced.ts
│   │   │   └── OfferExtended.ts
│   │   └── ports/
│   │       └── ScreeningProcessRepository.ts  # ポート
│   ├── application/
│   │   ├── commands/
│   │   │   └── AdvanceScreeningHandler.ts
│   │   └── queries/
│   │       └── ScreeningDashboardQuery.ts
│   └── infrastructure/
│       └── PostgresScreeningProcessRepository.ts  # アダプター

ドメイン層は外部への依存を持たない。ポート(インターフェース)を通じてインフラ層と接続する。アプリケーション層はドメイン層を呼び出し、ユースケースを実現する。


値オブジェクトの実装

ID型と Money

// [値オブジェクト] ScreeningProcessId
class ScreeningProcessId {
  private constructor(readonly value: string) {}

  static create(): ScreeningProcessId {
    return new ScreeningProcessId(crypto.randomUUID())
  }

  static reconstruct(value: string): ScreeningProcessId {
    if (!value) throw new Error('ScreeningProcessId cannot be empty')
    return new ScreeningProcessId(value)
  }

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

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

// [値オブジェクト] CandidateId
class CandidateId {
  private constructor(readonly value: string) {}

  static reconstruct(value: string): CandidateId {
    if (!value) throw new Error('CandidateId cannot be empty')
    return new CandidateId(value)
  }

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

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

// [値オブジェクト] JobPostingId
class JobPostingId {
  private constructor(readonly value: string) {}

  static reconstruct(value: string): JobPostingId {
    if (!value) throw new Error('JobPostingId cannot be empty')
    return new JobPostingId(value)
  }

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

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

// [値オブジェクト] Money(給与オファー用)
class Money {
  private constructor(
    readonly amount: number,
    readonly currency: string,
  ) {}

  static create(amount: number, currency: string): Money {
    if (amount < 0) throw new Error('Amount must be non-negative')
    if (!currency) throw new Error('Currency is required')
    return new Money(amount, currency)
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency
  }

  format(): string {
    return `${this.amount.toLocaleString()} ${this.currency}`
  }
}

ScreeningStage

ScreeningStage は状態遷移のルールをカプセル化する値オブジェクトだ。遷移の可否判断を外部のサービスや集約に委ねず、値オブジェクト自身が持つ。

// [値オブジェクト] ScreeningStage(状態遷移ルールを内包)
type StageType =
  | 'APPLICATION_RECEIVED'  // 応募受付
  | 'DOCUMENT_SCREENING'    // 書類選考
  | 'FIRST_INTERVIEW'       // 一次面接
  | 'SECOND_INTERVIEW'      // 二次面接
  | 'FINAL_INTERVIEW'       // 最終面接
  | 'OFFER_EXTENDED'        // 内定
  | 'REJECTED'              // 不採用
  | 'WITHDRAWN'             // 辞退

const STAGE_ORDER: StageType[] = [
  'APPLICATION_RECEIVED',
  'DOCUMENT_SCREENING',
  'FIRST_INTERVIEW',
  'SECOND_INTERVIEW',
  'FINAL_INTERVIEW',
  'OFFER_EXTENDED',
]

class ScreeningStage {
  private constructor(readonly type: StageType) {}

  static of(type: StageType): ScreeningStage {
    return new ScreeningStage(type)
  }

  static initial(): ScreeningStage {
    return new ScreeningStage('APPLICATION_RECEIVED')
  }

  // 次のステージへ遷移できるか
  canAdvance(): boolean {
    return STAGE_ORDER.includes(this.type) &&
      STAGE_ORDER.indexOf(this.type) < STAGE_ORDER.length - 1
  }

  // 次のステージを返す(遷移不可なら例外)
  next(): ScreeningStage {
    const index = STAGE_ORDER.indexOf(this.type)
    if (index === -1 || index >= STAGE_ORDER.length - 1) {
      throw new Error(`Cannot advance from stage: ${this.type}`)
    }
    return new ScreeningStage(STAGE_ORDER[index + 1])
  }

  isInterviewCompleted(): boolean {
    return this.type === 'FINAL_INTERVIEW'
  }

  isFinal(): boolean {
    return (
      this.type === 'OFFER_EXTENDED' ||
      this.type === 'REJECTED' ||
      this.type === 'WITHDRAWN'
    )
  }

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

集約の実装

AggregateRoot 基底クラス

// [集約ルート基底クラス] ドメインイベントの収集
abstract class DomainEvent {
  readonly occurredAt: Date
  constructor() {
    this.occurredAt = new Date()
  }
}

abstract class AggregateRoot<T> {
  protected readonly _id: T
  private _domainEvents: DomainEvent[] = []

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

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

  protected addDomainEvent(event: DomainEvent): void {
    this._domainEvents.push(event)
  }

  // リポジトリが保存後に呼び出してイベントを取り出す
  pullDomainEvents(): DomainEvent[] {
    const events = [...this._domainEvents]
    this._domainEvents = []
    return events
  }
}

ScreeningAdvanced と OfferExtended

// [ドメインイベント] ScreeningAdvanced
// src/screening/domain/events/ScreeningAdvanced.ts
class ScreeningAdvanced extends DomainEvent {
  constructor(
    readonly screeningProcessId: ScreeningProcessId,
    readonly candidateId: CandidateId,
    readonly jobPostingId: JobPostingId,
    readonly newStage: ScreeningStage,
  ) {
    super()
  }
}

// [ドメインイベント] OfferExtended
// src/screening/domain/events/OfferExtended.ts
class OfferExtended extends DomainEvent {
  constructor(
    readonly screeningProcessId: ScreeningProcessId,
    readonly candidateId: CandidateId,
    readonly offeredSalary: Money,
  ) {
    super()
  }
}

ScreeningProcess

不変条件のチェックは各メソッド内に集約し、ドメインロジックがコードから直接読み取れるようにする。

// [集約ルート] ScreeningProcess
// src/screening/domain/ScreeningProcess.ts
class ScreeningProcess extends AggregateRoot<ScreeningProcessId> {
  private readonly _candidateId: CandidateId    // IDで参照(オブジェクト参照なし)
  private readonly _jobPostingId: JobPostingId  // IDで参照(オブジェクト参照なし)
  private _currentStage: ScreeningStage
  private _rejectionReason: string | null

  private constructor(
    id: ScreeningProcessId,
    candidateId: CandidateId,
    jobPostingId: JobPostingId,
    currentStage: ScreeningStage,
    rejectionReason: string | null,
  ) {
    super(id)
    this._candidateId = candidateId
    this._jobPostingId = jobPostingId
    this._currentStage = currentStage
    this._rejectionReason = rejectionReason
  }

  // ファクトリメソッド: 新規作成
  static create(
    id: ScreeningProcessId,
    candidateId: CandidateId,
    jobPostingId: JobPostingId,
  ): ScreeningProcess {
    const process = new ScreeningProcess(
      id,
      candidateId,
      jobPostingId,
      ScreeningStage.initial(),
      null,
    )
    process.addDomainEvent(
      new ScreeningAdvanced(id, candidateId, jobPostingId, ScreeningStage.initial()),
    )
    return process
  }

  // ファクトリメソッド: DBからの復元
  static reconstruct(
    id: ScreeningProcessId,
    candidateId: CandidateId,
    jobPostingId: JobPostingId,
    currentStage: ScreeningStage,
    rejectionReason: string | null,
  ): ScreeningProcess {
    return new ScreeningProcess(id, candidateId, jobPostingId, currentStage, rejectionReason)
  }

  get candidateId(): CandidateId { return this._candidateId }
  get jobPostingId(): JobPostingId { return this._jobPostingId }
  get currentStage(): ScreeningStage { return this._currentStage }

  // 選考を次のステージへ進める
  advanceStage(): void {
    // 不変条件: 終了済みの選考は進められない
    if (this._currentStage.isFinal()) {
      throw new Error(`Cannot advance a finalized screening: ${this._currentStage.type}`)
    }
    // 不変条件: 次のステージが存在しない場合は進められない
    if (!this._currentStage.canAdvance()) {
      throw new Error(`No next stage available from: ${this._currentStage.type}`)
    }

    this._currentStage = this._currentStage.next()
    this.addDomainEvent(
      new ScreeningAdvanced(this._id, this._candidateId, this._jobPostingId, this._currentStage),
    )
  }

  // 内定を出す
  extendOffer(salary: Money): void {
    // 不変条件: 最終面接完了後のみ内定を出せる
    if (!this._currentStage.isInterviewCompleted()) {
      throw new Error('Offer can only be extended after final interview')
    }

    this._currentStage = ScreeningStage.of('OFFER_EXTENDED')
    this.addDomainEvent(
      new OfferExtended(this._id, this._candidateId, salary),
    )
  }

  // 不採用にする
  reject(reason: string): void {
    // 不変条件: すでに終了している選考は変更できない
    if (this._currentStage.isFinal()) {
      throw new Error(`Cannot reject a finalized screening: ${this._currentStage.type}`)
    }
    if (!reason || reason.trim().length === 0) {
      throw new Error('Rejection reason is required')
    }

    this._currentStage = ScreeningStage.of('REJECTED')
    this._rejectionReason = reason
  }
}

リポジトリポートと CQRS の実装

ScreeningProcessRepository ポート

// [ポート] ScreeningProcessRepository
// src/screening/domain/ports/ScreeningProcessRepository.ts
interface ScreeningProcessRepository {
  findById(id: ScreeningProcessId): Promise<ScreeningProcess | null>
  save(process: ScreeningProcess): Promise<void>
}

インターフェースはドメイン層に置く。実装(アダプター)はインフラ層に置く。ドメイン層はインフラ層に依存しない。

AdvanceScreeningHandler(コマンド)

// [コマンドハンドラー] AdvanceScreeningHandler
// src/screening/application/commands/AdvanceScreeningHandler.ts

// コマンド(Command): データクラス、ロジックを持たない
class AdvanceScreeningCommand {
  constructor(
    readonly screeningProcessId: string,
    readonly advancedBy: string,
  ) {}
}

// イベントパブリッシャーのポート
interface DomainEventPublisher {
  publish(events: DomainEvent[]): Promise<void>
}

class AdvanceScreeningHandler {
  constructor(
    private readonly repo: ScreeningProcessRepository,
    private readonly publisher: DomainEventPublisher,
  ) {}

  async handle(cmd: AdvanceScreeningCommand): Promise<void> {
    const id = ScreeningProcessId.reconstruct(cmd.screeningProcessId)
    const process = await this.repo.findById(id)

    if (process === null) {
      throw new Error(`ScreeningProcess not found: ${cmd.screeningProcessId}`)
    }

    // ドメインロジックはすべて集約内に閉じている
    process.advanceStage()

    await this.repo.save(process)

    // 保存完了後にイベントを発行する(永続化とイベント配送を分離)
    const events = process.pullDomainEvents()
    await this.publisher.publish(events)
  }
}

ScreeningDashboardQuery(クエリ)

// [クエリ] ScreeningDashboardQuery
// src/screening/application/queries/ScreeningDashboardQuery.ts

// 読み取り専用の表示用データ構造(DTO)
interface ScreeningDashboardDTO {
  screeningProcessId: string
  candidateName: string
  jobTitle: string
  currentStage: string
  lastUpdatedAt: Date
}

// 読み取り専用DBのポート
interface ReadOnlyDatabase {
  query<T>(sql: string, params: unknown[]): Promise<T[]>
}

class ScreeningDashboardQuery {
  constructor(private readonly readDb: ReadOnlyDatabase) {}

  // クエリはドメインオブジェクトを経由しない
  // 集約のロードコストが発生せず、表示に最適化したSQLを直接発行できる
  async execute(recruiterId: string): Promise<ScreeningDashboardDTO[]> {
    return this.readDb.query<ScreeningDashboardDTO>(
      `SELECT
         sp.id              AS "screeningProcessId",
         c.full_name        AS "candidateName",
         jp.title           AS "jobTitle",
         sp.current_stage   AS "currentStage",
         sp.updated_at      AS "lastUpdatedAt"
       FROM   screening_processes sp
       JOIN   candidates      c  ON c.id  = sp.candidate_id
       JOIN   job_postings     jp ON jp.id = sp.job_posting_id
       WHERE  jp.recruiter_id = $1
       ORDER  BY sp.updated_at DESC`,
      [recruiterId],
    )
  }
}

インフラ層: PostgresScreeningProcessRepository

// [アダプター] PostgresScreeningProcessRepository
// src/screening/infrastructure/PostgresScreeningProcessRepository.ts
interface DatabaseClient {
  queryOne<T>(sql: string, params: unknown[]): Promise<T | null>
  execute(sql: string, params: unknown[]): Promise<void>
}

class PostgresScreeningProcessRepository implements ScreeningProcessRepository {
  constructor(private readonly db: DatabaseClient) {}

  async findById(id: ScreeningProcessId): Promise<ScreeningProcess | null> {
    const row = await this.db.queryOne<{
      id: string
      candidate_id: string
      job_posting_id: string
      current_stage: StageType
      rejection_reason: string | null
    }>(
      'SELECT id, candidate_id, job_posting_id, current_stage, rejection_reason FROM screening_processes WHERE id = $1',
      [id.toString()],
    )

    if (row === null) return null

    // DBの生データをドメインオブジェクトへ復元
    return ScreeningProcess.reconstruct(
      ScreeningProcessId.reconstruct(row.id),
      CandidateId.reconstruct(row.candidate_id),
      JobPostingId.reconstruct(row.job_posting_id),
      ScreeningStage.of(row.current_stage),
      row.rejection_reason,
    )
  }

  async save(process: ScreeningProcess): Promise<void> {
    await this.db.execute(
      `INSERT INTO screening_processes (id, candidate_id, job_posting_id, current_stage, rejection_reason, updated_at)
       VALUES ($1, $2, $3, $4, $5, NOW())
       ON CONFLICT (id) DO UPDATE
         SET current_stage    = EXCLUDED.current_stage,
             rejection_reason = EXCLUDED.rejection_reason,
             updated_at       = NOW()`,
      [
        process.id.toString(),
        process.candidateId.toString(),
        process.jobPostingId.toString(),
        process.currentStage.type,
        null, // rejectionReasonはgetterを追加すれば取得可能
      ],
    )
  }
}

ドメインイベントの発行と購読

イベントバスの実装例

// [インフラ] インメモリのイベントバス(テストおよびシンプルな環境向け)
type EventHandler<T extends DomainEvent> = (event: T) => Promise<void>

class InMemoryEventBus implements DomainEventPublisher {
  private handlers: Map<string, EventHandler<DomainEvent>[]> = new Map()

  subscribe<T extends DomainEvent>(
    eventName: string,
    handler: EventHandler<T>,
  ): void {
    const existing = this.handlers.get(eventName) ?? []
    this.handlers.set(eventName, [...existing, handler as EventHandler<DomainEvent>])
  }

  async publish(events: DomainEvent[]): Promise<void> {
    for (const event of events) {
      const eventName = event.constructor.name
      const handlers = this.handlers.get(eventName) ?? []
      for (const handler of handlers) {
        await handler(event)
      }
    }
  }
}

通知コンテキストへのイベント通知

// [ドメインイベントハンドラー] 通知コンテキスト側のサブスクライバー
// 選考管理コンテキストはこのハンドラーの存在を知らない
interface EmailService {
  sendOfferEmail(params: { to: string; processId: string; salary: string }): Promise<void>
}

interface CandidateQuery {
  findEmailById(candidateId: string): Promise<string | null>
}

class OfferExtendedNotificationHandler {
  constructor(
    private readonly candidateQuery: CandidateQuery,
    private readonly emailService: EmailService,
  ) {}

  async handle(event: OfferExtended): Promise<void> {
    const email = await this.candidateQuery.findEmailById(event.candidateId.toString())
    if (email === null) {
      throw new Error(`Candidate not found: ${event.candidateId}`)
    }

    await this.emailService.sendOfferEmail({
      to: email,
      processId: event.screeningProcessId.toString(),
      salary: event.offeredSalary.format(),
    })
  }
}

// イベントバスへのサブスクリプション登録
// アプリケーション起動時に1度だけ実行する
function registerEventHandlers(
  eventBus: InMemoryEventBus,
  handler: OfferExtendedNotificationHandler,
): void {
  eventBus.subscribe<OfferExtended>(
    'OfferExtended',
    (event) => handler.handle(event),
  )
}

インフォグラフィック

サマリー

全体の依存関係図

graph TD
  subgraph Domain["ドメイン層"]
    VO1[ScreeningProcessId]
    VO2[CandidateId]
    VO3[JobPostingId]
    VO4[Money]
    VO5[ScreeningStage]
    AR[ScreeningProcess<br/>集約ルート]
    DE1[ScreeningAdvanced<br/>ドメインイベント]
    DE2[OfferExtended<br/>ドメインイベント]
    PORT[ScreeningProcessRepository<br/>ポート]
    BASE[AggregateRoot]
  end

  subgraph Application["アプリケーション層"]
    CMD[AdvanceScreeningCommand]
    CMH[AdvanceScreeningHandler<br/>コマンドハンドラー]
    QRY[ScreeningDashboardQuery<br/>クエリ]
    PUB[DomainEventPublisher<br/>ポート]
  end

  subgraph Infrastructure["インフラ層"]
    REPO[PostgresScreeningProcessRepository<br/>アダプター]
    BUS[InMemoryEventBus<br/>アダプター]
    NOTIF[OfferExtendedNotificationHandler]
    RDB[(PostgreSQL<br/>書き込みDB)]
    RO[(PostgreSQL<br/>読み取りビュー)]
  end

  BASE --> AR
  AR --> VO1
  AR --> VO2
  AR --> VO3
  AR --> VO4
  AR --> VO5
  AR --> DE1
  AR --> DE2

  CMH --> PORT
  CMH --> PUB
  CMH --> CMD

  QRY --> RO

  REPO -->|implements| PORT
  BUS -->|implements| PUB
  REPO --> RDB

  BUS --> NOTIF
  NOTIF --> DE2

  style Domain fill:#dbeafe,stroke:#3b82f6
  style Application fill:#dcfce7,stroke:#22c55e
  style Infrastructure fill:#fef9c3,stroke:#eab308

依存の方向は常にインフラ層からドメイン層へ向かう。ドメイン層はインフラ層・アプリケーション層のどちらにも依存しない。ポート(インターフェース)がドメイン層に配置されることで、この依存方向が強制される。

コマンドとクエリは異なるデータソースに接続する。コマンドは ScreeningProcessRepository を通じてドメインオブジェクトを経由して書き込み、クエリは読み取り専用ビューへ直接SQLを発行する。ドメインイベントはアプリケーション層の AdvanceScreeningHandler が保存後に発行し、InMemoryEventBus を通じて通知コンテキストのハンドラーへ届く。