目次を表示する

EvansとVernonで学ぶDDD

[比較] アーキテクチャ ── Layered(Evans)vs Hexagonal(Vernon)

[比較] アーキテクチャ ── Layered(Evans)vs Hexagonal(Vernon)

アーキテクチャの選択はDDDの実践において中心的な関心事のひとつだ。Evansは青本でレイヤードアーキテクチャを提示し、Vernonは赤本でヘキサゴナルアーキテクチャをDDDの文脈で体系化した。本章では両者のアプローチを採用管理システムを題材に比較し、それぞれの設計判断の背景と実装への影響を整理する。


Evansのレイヤードアーキテクチャ

Evansは青本(Domain-Driven Design, 2003)で、複雑なドメインロジックを他の関心事から分離するための構造としてレイヤードアーキテクチャを定義した。構成は4層に分かれる。

役割
UI層(User Interface Layer)ユーザーへの表示、入力の受け付け
アプリケーション層(Application Layer)ユースケースの調整、トランザクション管理
ドメイン層(Domain Layer)ビジネスルール、集約、ドメインイベント
インフラストラクチャ層(Infrastructure Layer)DB、メール、外部API等の技術的実装

依存の方向は原則として上から下だ。UI層はアプリケーション層に依存し、アプリケーション層はドメイン層に依存する。インフラストラクチャ層はその下に位置するが、ドメイン層はインフラストラクチャ層に依存しない。これがEvansの設計の核心だ。

採用管理システムに当てはめると次のようになる。

  • UI層: REST APIコントローラー、GraphQLリゾルバー
  • アプリケーション層: AdvanceScreeningHandler(選考フェーズ進行のユースケース)
  • ドメイン層: ScreeningProcess集約、OfferExtendedイベント、CandidateId等の値オブジェクト
  • インフラストラクチャ層: PostgreSQLリポジトリ、SendGridメールアダプター

ドメイン層がインフラストラクチャ層に依存しない、というルールは字義通りに受け取るべきだ。ScreeningProcessはPostgreSQLもSendGridも知らない。具体的な技術への参照がドメインコードに混入した時点で、このアーキテクチャの保証は失われる。

ただし、レイヤードアーキテクチャには実践上の曖昧さが残る。「インフラストラクチャ層はどこからでも参照できる」という緩いルールを採用するプロジェクトも多く、アプリケーション層がDBライブラリに直接依存する実装が紛れ込む。Evansはドメイン層の隔離を強調したが、その他の層の境界については裁量の余地を残している。


Vernonのヘキサゴナルアーキテクチャ

ヘキサゴナルアーキテクチャ(Hexagonal Architecture)はAlistair Cockburnが2005年に考案した。別名ポートとアダプターアーキテクチャ(Ports and Adapters Architecture)とも呼ばれる。Vernonは赤本(Implementing Domain-Driven Design, 2013)でこのパターンをDDDの実装指針として採用し、広く知られるようになった。

構造の中心にあるのはドメインとアプリケーションのコアだ。その外側に「ポート(Port)」と「アダプター(Adapter)」が配置される。

  • ポート: ドメイン層が定義するインターフェース。外部との通信窓口
  • アダプター: ポートの具体的な実装。インフラ技術ごとに差し替え可能

六角形(ヘキサゴン)という形状に深い意味はない。「外側がすべてアダプターである」という概念を視覚化するための表現だ。重要なのは、すべての外部との境界がポートという抽象を経由することだ。

レイヤードアーキテクチャとの本質的な違いは依存の向きの定式化にある。レイヤードは「上から下への依存」を基本とするが、ヘキサゴナルは「外から内への依存」を厳密に定義する。内側(ドメイン)は外側(アダプター)を一切知らない。UIからのリクエストも、DBへの永続化も、すべてアダプターがポートを実装する形で接続される。


両者の比較

共通する思想は明確だ。どちらもドメイン層をインフラストラクチャから隔離することを目的とする。Evansが青本でドメイン層の自律性を強調し、Vernonがそれをより厳密なポート・アダプター構造として定式化した、という流れで理解するとわかりやすい。

差異を整理する。

観点レイヤードアーキテクチャヘキサゴナルアーキテクチャ
依存の方向上から下外から内
境界の定義層の区分によるポート(インターフェース)による
外部との接続インフラ層に集約アダプターが個別に実装
テスト容易性インフラ層への依存が残りやすいポートをモックするだけでテスト可能
曖昧さ層間の依存ルールに裁量があるポートを通じない依存は構造上禁止

現代のシステム開発文脈では、ヘキサゴナルアーキテクチャの優位性が際立つ場面がある。

テスタビリティ: ポートがインターフェースとして定義されているため、テスト時にアダプターをインメモリ実装に差し替えることが容易だ。ScreeningProcessRepositoryのPostgreSQL実装をInMemoryScreeningProcessRepositoryに置き換えれば、DBなしで全ドメインロジックをテストできる。

クラウドとマイクロサービス: 採用管理システムの通知コンテキストを例にとると、メール送信アダプターをSendGridからAWS SESに切り替える際、ポート(NotificationPort)は変更せずアダプターのみ差し替えられる。インフラ選択の変更がドメインコードに波及しない。

チーム分業: ポートの定義さえ合意すれば、ドメインチームとインフラチームが並行して開発できる。


TypeScriptでヘキサゴナルアーキテクチャを実装する

採用管理システムの選考管理コンテキストを例に、実装構造を示す。

ポートの定義

ドメイン層がポートを定義する。インフラの実装詳細は含まない。

// domain/ports/ScreeningProcessRepository.ts

interface ScreeningProcessRepository {
  findById(id: ScreeningProcessId): Promise<ScreeningProcess>
  save(process: ScreeningProcess): Promise<void>
}
// domain/ports/NotificationPort.ts

interface NotificationPort {
  notify(event: DomainEvent): Promise<void>
}

ポートはドメイン層の一部だ。インフラストラクチャの存在を知らない純粋なインターフェースとして定義する。

アダプターの実装

インフラストラクチャ層がポートを実装する。

// infrastructure/adapters/PostgresScreeningProcessRepository.ts

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

  async findById(id: ScreeningProcessId): Promise<ScreeningProcess> {
    const row = await this.db.query(
      'SELECT * FROM screening_processes WHERE id = $1',
      [id.value]
    )
    return ScreeningProcessMapper.toDomain(row.rows[0])
  }

  async save(process: ScreeningProcess): Promise<void> {
    const data = ScreeningProcessMapper.toPersistence(process)
    await this.db.query(
      'INSERT INTO screening_processes VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE ...',
      [data.id, data.candidateId, data.currentStage]
    )
  }
}
// infrastructure/adapters/SendGridNotificationAdapter.ts

class SendGridNotificationAdapter implements NotificationPort {
  constructor(private readonly client: SendGridClient) {}

  async notify(event: DomainEvent): Promise<void> {
    // SendGrid固有の実装
  }
}

アプリケーション層のコマンドハンドラー

アプリケーション層はポートのみを依存として受け取る。アダプターの型は一切参照しない。

// application/handlers/AdvanceScreeningHandler.ts

// ポート: ドメイン層が定義するインターフェース
interface ScreeningProcessRepository {
  findById(id: ScreeningProcessId): Promise<ScreeningProcess>
  save(process: ScreeningProcess): Promise<void>
}

// アダプター: インフラ層の実装
class PostgresScreeningProcessRepository implements ScreeningProcessRepository {
  async findById(id: ScreeningProcessId): Promise<ScreeningProcess> {
    // PostgreSQL固有の実装
  }
  async save(process: ScreeningProcess): Promise<void> {
    // PostgreSQL固有の実装
  }
}

// ドメイン層はポートのみを知る(アダプターを知らない)
class AdvanceScreeningHandler {
  constructor(private readonly repo: ScreeningProcessRepository) {} // ポートに依存
  async handle(cmd: AdvanceScreeningCommand): Promise<void> {
    const process = await this.repo.findById(cmd.id)
    process.advanceStage()
    await this.repo.save(process)
  }
}

AdvanceScreeningHandlerのコンストラクターが受け取るのはScreeningProcessRepositoryインターフェースだ。DIコンテナがPostgresScreeningProcessRepositoryを注入するが、ハンドラーはその型を知らない。テスト時はInMemoryScreeningProcessRepositoryを渡すだけでよい。


ディレクトリ構造の例

ヘキサゴナルアーキテクチャを採用した採用管理システム(選考管理コンテキスト)のディレクトリ構造を示す。

src/
└── screening/                          # 選考管理コンテキスト
    ├── domain/
    │   ├── aggregates/
    │   │   └── ScreeningProcess.ts     # 集約ルート
    │   ├── events/
    │   │   ├── OfferExtended.ts
    │   │   └── StageAdvanced.ts
    │   ├── valueObjects/
    │   │   ├── ScreeningProcessId.ts
    │   │   ├── CandidateId.ts
    │   │   └── JobPostingId.ts
    │   └── ports/                      # ポート(ドメイン層が定義)
    │       ├── ScreeningProcessRepository.ts
    │       └── NotificationPort.ts
    ├── application/
    │   └── handlers/
    │       ├── AdvanceScreeningHandler.ts
    │       └── ExtendOfferHandler.ts
    └── infrastructure/
        └── adapters/                   # アダプター(インフラ層が実装)
            ├── PostgresScreeningProcessRepository.ts
            └── SendGridNotificationAdapter.ts

domain/ports/ディレクトリにポートを配置し、infrastructure/adapters/にその実装を置く構造が明快だ。ドメイン層はinfrastructure/配下のファイルを一切インポートしない。この制約をlintルール(import/no-restricted-paths等)で強制することも有効だ。


まとめ

Evans(レイヤード)Vernon(ヘキサゴナル)
アーキテクチャの形縦方向の4層内外の同心円
依存ルール上から下外から内
外部との接触面インフラ層アダプター(ポート経由)
柔軟性の焦点ドメインの独立性アダプターの差し替え容易性

Evansのレイヤードアーキテクチャはドメイン層の独立性という核心的な考え方を提示した。Vernonはそれをポートとアダプターという明確な構造に昇華させ、テスタビリティとインフラ切り替えの容易さを設計の一部として組み込んだ。どちらの書籍も「ドメインロジックを技術から守る」という点では一致している。実装上の選択は、チームの規模、テスト戦略、インフラの変動頻度によって判断する。


インフォグラフィック

サマリー

図: レイヤードアーキテクチャとヘキサゴナルアーキテクチャの対比

graph TB
  subgraph layered["Evansのレイヤードアーキテクチャ"]
    direction TB
    UI["UI層\n(REST / GraphQL)"]
    APP["アプリケーション層\n(AdvanceScreeningHandler)"]
    DOMAIN["ドメイン層\n(ScreeningProcess集約)"]
    INFRA["インフラストラクチャ層\n(PostgreSQL / SendGrid)"]

    UI -->|依存| APP
    APP -->|依存| DOMAIN
    APP -->|依存| INFRA
    INFRA -.->|実装提供| DOMAIN
  end

  subgraph hexagonal["Vernonのヘキサゴナルアーキテクチャ"]
    direction TB
    ADAPTER_IN["受信アダプター\n(REST Controller)"]
    PORT_IN["受信ポート\n(AdvanceScreeningUseCase)"]
    CORE["コア\n(ScreeningProcess集約\nAdvanceScreeningHandler)"]
    PORT_OUT["送信ポート\n(ScreeningProcessRepository\nNotificationPort)"]
    ADAPTER_OUT["送信アダプター\n(PostgresRepository\nSendGridAdapter)"]

    ADAPTER_IN -->|依存| PORT_IN
    PORT_IN -->|依存| CORE
    CORE -->|依存| PORT_OUT
    ADAPTER_OUT -->|実装| PORT_OUT
  end

左のレイヤードアーキテクチャでは、アプリケーション層がドメイン層とインフラストラクチャ層の両方に依存する構造になりやすい。右のヘキサゴナルアーキテクチャでは、コアへの依存は外から内の一方向に統一され、アダプターはポートを実装する形でのみコアと接続される。