ヘキサゴナルアーキテクチャ入門 - ポートとアダプターでドメインを守る

2026.04.10

公式ドキュメント

この記事の要点

• ヘキサゴナルアーキテクチャは「ポート」と「アダプター」でドメインを外部技術から分離する
• 内側は外側を知らないという依存ルールが最重要原則
• テスト容易性・技術交換性・責任の分離を実現する設計パターン

ヘキサゴナルアーキテクチャとは

ヘキサゴナルアーキテクチャ(Hexagonal Architecture)は、Alistair Cockburn が 2005 年に提唱したソフトウェアアーキテクチャパターンです。別名「ポート&アダプターパターン(Ports and Adapters Pattern)」とも呼ばれます。アプリケーションの中心にあるドメインロジックを、UI・データベース・外部 API などの「外側の世界」から完全に切り離すことを目的とします。

六角形のメタファーは、図形そのものに意味があるわけではなく、「外部との接点(辺)が複数あり、どの方向からアクセスされても中心は変わらない」ことを視覚的に示すために選ばれました。

flowchart LR
    subgraph Drivers["Driving Side (Primary)"]
        UI["Web UI"]
        CLI["CLI"]
        Test["Test Runner"]
    end

    subgraph Hex["Application Core"]
        PIn["Input Port"]
        Domain["Domain Model<br/>Use Cases"]
        POut["Output Port"]
        PIn --> Domain --> POut
    end

    subgraph Driven["Driven Side (Secondary)"]
        DB[("Database")]
        MQ["Message Broker"]
        Ext["External API"]
    end

    UI -->|Adapter| PIn
    CLI -->|Adapter| PIn
    Test -->|Adapter| PIn
    POut -->|Adapter| DB
    POut -->|Adapter| MQ
    POut -->|Adapter| Ext

なぜヘキサゴナルアーキテクチャが必要か

従来の階層型アーキテクチャの問題点

伝統的な「UI → ビジネスロジック → データアクセス層」という縦型の階層構造では、ビジネスロジックがデータベース実装やフレームワークに強く依存しがちです。これにより以下のような問題が発生します。

  • データベースを変更すると、ドメインロジックにまで影響が波及する
  • フレームワークのアップグレードでビジネスロジックが壊れる
  • 単体テストを書くために外部リソースのモック準備が必須になる
  • UI とビジネスロジックの境界が曖昧になり、ロジックが UI 側に漏れる

ヘキサゴナルアプローチの解決策

ヘキサゴナルアーキテクチャでは、ドメインが外部のいかなる技術詳細も知らないように設計します。外部とのやり取りは「ポート」と呼ばれるインターフェースを通じてのみ行われ、その実装である「アダプター」が外側に配置されます。これにより、データベースを PostgreSQL から MongoDB に変えてもドメインコードは一切変更不要、というレベルの独立性が達成できます。

基本原則

1. ドメインを中心に置く

アプリケーションの最も重要な資産はビジネスロジック(ドメインモデル)です。これを中心に置き、すべての依存はドメインに向かって流れるようにします。

2. 内側は外側を知らない

ドメイン層は、HTTP・SQL・JSON・特定のフレームワークといった技術的概念を一切持ち込みません。純粋な言語機能のみで記述します。

3. ポートは技術非依存

ポートはドメインの言葉で語られるインターフェースです。「ユーザーを保存する」というポートは存在してよいですが、「INSERT 文を実行する」というポートは存在してはいけません

4. アダプターは交換可能

同じポートに対して複数のアダプターが存在し得ます。本番では PostgreSQL アダプター、テストではインメモリアダプター、というように差し替え可能です。

構成要素の詳細

ドメイン層(Application Core)

エンティティ、値オブジェクト、ドメインサービス、ユースケースが含まれます。ここには import 文が極力少なく、外部ライブラリへの依存はゼロを目指します。

ポート(Ports)

ポートは 2 種類に分類されます。

  • インバウンドポート(Driving Port / Primary Port): アプリケーションが外部から呼び出される入口。ユースケースのインターフェースに相当します。
  • アウトバウンドポート(Driven Port / Secondary Port): アプリケーションが外部に対して要求を出す出口。リポジトリやメッセージ送信のインターフェースに相当します。

アダプター(Adapters)

ポートを実装する具体的な技術コンポーネントです。

  • インバウンドアダプター: REST コントローラ、GraphQL リゾルバ、CLI コマンド、メッセージコンシューマー
  • アウトバウンドアダプター: ORM リポジトリ、HTTP クライアント、メッセージプロデューサー、ファイルシステムアクセス
flowchart TB
    subgraph Layer["依存方向"]
        direction TB
        A["Adapters (外側)"]
        P["Ports (境界)"]
        D["Domain (内側)"]
        A -->|implements| P
        P -.->|defined by| D
    end

実装例(TypeScript)

ドメインエンティティ

// domain/entities/User.ts
export class User {
  constructor(
    public readonly id: string,
    public readonly email: string,
    private name: string,
  ) {}

  rename(newName: string): void {
    if (newName.trim().length === 0) {
      throw new Error("Name cannot be empty");
    }
    this.name = newName;
  }

  getName(): string {
    return this.name;
  }
}

アウトバウンドポート

// domain/ports/UserRepository.ts
import { User } from "../entities/User";

export interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
  delete(id: string): Promise<void>;
}

インバウンドポート(ユースケース)

// application/ports/RegisterUserUseCase.ts
export interface RegisterUserCommand {
  email: string;
  name: string;
}

export interface RegisterUserUseCase {
  execute(command: RegisterUserCommand): Promise<{ id: string }>;
}

ユースケース実装

// application/usecases/RegisterUserService.ts
import { randomUUID } from "node:crypto";
import { User } from "../../domain/entities/User";
import { UserRepository } from "../../domain/ports/UserRepository";
import {
  RegisterUserCommand,
  RegisterUserUseCase,
} from "../ports/RegisterUserUseCase";

export class RegisterUserService implements RegisterUserUseCase {
  constructor(private readonly users: UserRepository) {}

  async execute(command: RegisterUserCommand): Promise<{ id: string }> {
    const id = randomUUID();
    const user = new User(id, command.email, command.name);
    await this.users.save(user);
    return { id };
  }
}

アウトバウンドアダプター(PostgreSQL 実装)

// infrastructure/adapters/PostgresUserRepository.ts
import { Pool } from "pg";
import { User } from "../../domain/entities/User";
import { UserRepository } from "../../domain/ports/UserRepository";

export class PostgresUserRepository implements UserRepository {
  constructor(private readonly pool: Pool) {}

  async findById(id: string): Promise<User | null> {
    const result = await this.pool.query(
      "SELECT id, email, name FROM users WHERE id = $1",
      [id],
    );
    if (result.rowCount === 0) return null;
    const row = result.rows[0];
    return new User(row.id, row.email, row.name);
  }

  async save(user: User): Promise<void> {
    await this.pool.query(
      `INSERT INTO users(id, email, name) VALUES ($1, $2, $3)
       ON CONFLICT (id) DO UPDATE SET email = $2, name = $3`,
      [user.id, user.email, user.getName()],
    );
  }

  async delete(id: string): Promise<void> {
    await this.pool.query("DELETE FROM users WHERE id = $1", [id]);
  }
}

インバウンドアダプター(HTTP コントローラ)

// infrastructure/adapters/UserController.ts
import { Request, Response } from "express";
import { RegisterUserUseCase } from "../../application/ports/RegisterUserUseCase";

export class UserController {
  constructor(private readonly registerUser: RegisterUserUseCase) {}

  async register(req: Request, res: Response): Promise<void> {
    try {
      const result = await this.registerUser.execute({
        email: req.body.email,
        name: req.body.name,
      });
      res.status(201).json(result);
    } catch (err) {
      res.status(400).json({ error: (err as Error).message });
    }
  }
}

依存性の組み立て(Composition Root)

// main.ts
import express from "express";
import { Pool } from "pg";
import { PostgresUserRepository } from "./infrastructure/adapters/PostgresUserRepository";
import { RegisterUserService } from "./application/usecases/RegisterUserService";
import { UserController } from "./infrastructure/adapters/UserController";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const userRepo = new PostgresUserRepository(pool);
const registerUser = new RegisterUserService(userRepo);
const controller = new UserController(registerUser);

const app = express();
app.use(express.json());
app.post("/users", (req, res) => controller.register(req, res));
app.listen(3000);

テスト時のインメモリアダプター

// test/InMemoryUserRepository.ts
import { User } from "../src/domain/entities/User";
import { UserRepository } from "../src/domain/ports/UserRepository";

export class InMemoryUserRepository implements UserRepository {
  private store = new Map<string, User>();

  async findById(id: string): Promise<User | null> {
    return this.store.get(id) ?? null;
  }

  async save(user: User): Promise<void> {
    this.store.set(user.id, user);
  }

  async delete(id: string): Promise<void> {
    this.store.delete(id);
  }
}

このように、アダプターを差し替えるだけで、データベースなしでユースケースの単体テストが書けます。

実践メモ: テスト時にはインメモリアダプターを使うことで、外部依存なしに高速なユニットテストが実行可能です。モックよりもフェイク(簡易実装)を使うほうがテストの可読性が向上します。

メリット

  • テスト容易性: ドメインを純粋な単体テストで検証できる
  • 技術交換性: データベース、フレームワーク、メッセージブローカーを置き換えやすい
  • 責任の分離: ビジネスロジックと技術関心事が混ざらない
  • 並行開発: ポートを定義すればチームが並行作業できる
  • 長寿命設計: フレームワークの流行り廃りに耐える

デメリット

  • 学習コスト: 初学者には抽象レイヤーが多く感じられる
  • ボイラープレート: 小規模アプリでは過剰設計になりやすい
  • ファイル数増加: インターフェースと実装の分離でファイル数が増える
  • 依存性注入の必要性: DI コンテナや手動配線が必須になる

ユースケース

  1. 長期運用が予想されるエンタープライズシステム: 10 年単位で生き残る業務システムでは技術スタックが必ず変わるため有効
  2. 複数のフロントエンドを持つアプリケーション: Web UI、モバイル、CLI、バッチを同じドメインで共有する場合
  3. マイクロサービスのコア: 各サービスの内部設計に適用しやすい
  4. DDD と組み合わせた業務システム: ドメインモデルを純粋に保ちたい設計
  5. レガシー置き換えプロジェクト: ストラングラーフィグと組み合わせて段階的移行

よくある落とし穴

ドメインに技術が漏れる

User エンティティに @Entity のような ORM デコレータを付けてしまうと、ドメインが ORM に依存します。永続化用のクラスとドメインクラスは分け、マッパーで変換します。

ポートが技術用語になる

UserSqlRepository のような名前を付けると技術が漏れます。ポートはあくまで UserRepository のように業務語彙で命名します。

アダプター同士の直接呼び出し

HTTP コントローラから直接 SQL リポジトリを呼ぶと境界が崩壊します。必ずユースケース(インバウンドポートの実装)を経由します。

注意: アダプター同士の直接呼び出しはアーキテクチャの境界を破壊する最も一般的なアンチパターンです。コードレビューで特に注意して確認しましょう。

過剰なポート定義

CRUD ごとに細かくポートを切ると爆発的に増えます。ユースケース単位で粒度をまとめると保守しやすくなります。

他パターンとの比較

観点ヘキサゴナルクリーンアーキテクチャレイヤード
提唱者Alistair CockburnRobert C. Martin一般的な伝統的設計
依存方向外→内外→内(同心円)上→下
焦点ポートとアダプターの分離レイヤーと依存性逆転関心の階層分離
抽象度
ドメイン中心はいはいいいえ
学習コスト
適用規模中〜大小〜中

クリーンアーキテクチャはヘキサゴナルアーキテクチャを発展させたものとして位置づけられ、本質的な思想(依存方向の逆転)は共通しています。

ベストプラクティス

1. ポートはドメインの言葉で書く

fetchRowsFromUsersTable ではなく findActiveUsers のように、業務的に意味のある名前を選びます。

2. アダプターは薄く保つ

アダプターはポートを実装し、外部技術との変換のみを担当します。ビジネスロジックが入り込まないようにします。

3. 依存性注入を徹底する

ドメインがアダプターを直接 new するのは禁止です。Composition Root で組み立てます。

4. ドメインモデルとデータモデルを分ける

ORM のエンティティとドメインのエンティティを同じクラスにしないことで、両者の都合の干渉を防ぎます。

5. 単体テストはアダプターをモックしない

インメモリアダプターのような実装を用意し、フェイクで置き換えるとテストの可読性が向上します。

6. 段階的に導入する

既存システム全体を一気に書き換えるのではなく、新機能や境界がはっきりしているモジュールから導入します。

まとめ

ヘキサゴナルアーキテクチャは、ドメインを外部技術から守るためのシンプルかつ強力なパターンです。「ポート」と「アダプター」という 2 つの概念だけで、アプリケーションの寿命と保守性を大きく改善できます。重要なのはディレクトリ構造の真似ではなく、「内側は外側を知らない」という依存ルールを徹底することです。小さく始めて、必要に応じて拡張していくのが成功の秘訣です。

さらに踏み込んだトピック

マッパー層の役割

ドメインモデルとデータベース行を相互変換するマッパーは、ヘキサゴナルアーキテクチャの「縫い目」に位置します。マッパーは双方向変換に責任を持ち、ドメイン側の不変条件を破らないように注意して構築する必要があります。マッパーが肥大化したら、それはドメインモデルかデータモデルのどちらかが歪んでいるサインです。

// infrastructure/mappers/UserMapper.ts
import { User } from "../../domain/entities/User";

interface UserRow {
  id: string;
  email: string;
  name: string;
}

export class UserMapper {
  static toDomain(row: UserRow): User {
    return new User(row.id, row.email, row.name);
  }

  static toRow(user: User): UserRow {
    return {
      id: user.id,
      email: user.email,
      name: user.getName(),
    };
  }
}

イベント駆動との組み合わせ

アウトバウンドポートにイベント発行用のインターフェースを追加すると、ドメインイベントを外部に通知する実装をアダプター側に閉じ込められます。Kafka、RabbitMQ、AWS SNS などの選択がドメインに影響しません。

// domain/ports/DomainEventPublisher.ts
export interface DomainEvent {
  name: string;
  occurredAt: Date;
  payload: Record<string, unknown>;
}

export interface DomainEventPublisher {
  publish(event: DomainEvent): Promise<void>;
}

マルチアダプター運用

同じポートに対して、本番、開発、テストで異なるアダプターを登録するのは典型的な運用です。さらに「読み取りはキャッシュアダプター、書き込みは DB アダプター」という分割もポートを 2 つに分けることで実現できます。

モジュラーモノリスとの相性

ヘキサゴナルアーキテクチャは、必ずしもマイクロサービスを前提としません。むしろモジュラーモノリスの内部構造として採用すると、将来サービス分割が必要になった際の手間が大幅に減ります。各モジュールが独自の Hex を持ち、モジュール境界がそのまま将来のサービス境界候補になります。

CI における境界検証

依存方向のルールは口約束では守られません。ArchUnit、dependency-cruiser、ts-arch のようなツールで「ドメイン層が infrastructure を import していたら CI 失敗」というルールを機械的に強制することが推奨されます。

ポイント: CI/CDパイプラインに依存方向の検証を組み込むことで、アーキテクチャの劣化を自動的に防止できます。これは大規模チームでの開発では特に重要です。

// .dependency-cruiser.cjs(抜粋)
module.exports = {
  forbidden: [
    {
      name: "no-domain-to-infra",
      severity: "error",
      from: { path: "^src/domain" },
      to: { path: "^src/infrastructure" },
    },
  ],
};

参考リソース

この技術を体系的に学びたいですか?

未来学では東証プライム上場企業のITエンジニアが24時間サポート。月額24,800円から、退会金0円のオンラインIT塾です。

メールで無料相談する
← 一覧に戻る