ヘキサゴナルアーキテクチャ は何をしますか?
ヘキサゴナルアーキテクチャ(ポートとアダプター)は、ビジネスロジックをフレームワーク、トランスポート、永続化の詳細から独立させます。コアとなるアプリケーションは抽象的なポートに依存し、アダプターがそのポートを境界で実装します。
使用するタイミング
- 長期的な保守性とテスタビリティが重要な新機能を構築する場合。
- ドメインロジックがI/Oの懸念と混在している、レイヤードまたはフレームワークに依存したコードをリファクタリングする場合。
- 同じユースケースに対して複数のインターフェース(HTTP、CLI、キューワーカー、cronジョブ)をサポートする場合。
- ビジネスルールを書き換えずにインフラストラクチャ(データベース、外部API、メッセージバス)を交換する場合。
このスキルは、境界、ドメイン中心設計、密結合されたサービスのリファクタリング、またはアプリケーションロジックを特定のライブラリから切り離すことに関するリクエストがある場合に使用してください。
コアコンセプト
- ドメインモデル: ビジネスルールとエンティティ/値オブジェクト。フレームワークのインポートはありません。
- ユースケース(アプリケーション層): ドメインの動作とワークフローステップをオーケストレーションします。
- インバウンドポート: アプリケーションが何をできるかを記述する契約(コマンド/クエリ/ユースケースインターフェース)。
- アウトバウンドポート: アプリケーションが必要とする依存関係のための契約(リポジトリ、ゲートウェイ、イベントパブリッシャー、クロック、UUIDなど)。
- アダプター: ポートのインフラストラクチャおよび配信の実装(HTTPコントローラー、DBリポジトリ、キューコンシューマー、SDKラッパー)。
- コンポジションルート: 具体的なアダプターがユースケースにバインドされる単一の配線場所。
アウトバウンドポートインターフェースは通常アプリケーション層に存在します(抽象化が真にドメインレベルの場合のみドメイン層に配置します)。一方、インフラストラクチャアダプターはそれらを実装します。
依存関係の方向は常に内側です:
- アダプター -> アプリケーション/ドメイン
- アプリケーション -> ポートインターフェース(インバウンド/アウトバウンド契約)
- ドメイン -> ドメインのみの抽象化(フレームワークやインフラストラクチャの依存関係なし)
- ドメイン -> 外部への依存関係なし
動作方法
ステップ 1: ユースケース境界をモデル化する
明確な入力DTOと出力DTOを持つ単一のユースケースを定義します。トランスポートの詳細(Expressのreq、GraphQLのcontext、ジョブペイロードラッパー)はこの境界の外側に保持します。
ステップ 2: まずアウトバウンドポートを定義する
すべての副作用をポートとして特定します:
- 永続化 (
UserRepositoryPort) - 外部呼び出し (
BillingGatewayPort) - 横断的関心事 (
LoggerPort、ClockPort)
ポートはテクノロジーではなく、機能をモデル化する必要があります。
ステップ 3: 純粋なオーケストレーションでユースケースを実装する
ユースケースクラス/関数は、コンストラクタ/引数を介してポートを受け取ります。アプリケーションレベルの不変条件を検証し、ドメインルールを調整し、プレーンなデータ構造を返します。
ステップ 4: 境界にアダプターを構築する
- インバウンドアダプターはプロトコル入力をユースケース入力に変換します。
- アウトバウンドアダプターはアプリケーション契約を具体的なAPI/ORM/クエリビルダーにマッピングします。
- マッピングはアダプター内に留まり、ユースケース内部には入りません。
ステップ 5: コンポジションルートですべてを配線する
アダプターをインスタンス化し、それらをユースケースに注入します。この配線を集中管理して、隠れたサービスロケーター動作を避けます。
ステップ 6: 境界ごとにテストする
- フェイクポートを使用してユースケースを単体テストします。
- 実際のインフラ依存関係を使用してアダプターを統合テストします。
- インバウンドアダプターを通じてユーザー向けフローをE2Eテストします。
アーキテクチャ図
flowchart LR
Client["クライアント (HTTP/CLI/ワーカー)"] --> InboundAdapter["インバウンドアダプター"]
InboundAdapter -->|"呼び出し"| UseCase["ユースケース (アプリケーション層)"]
UseCase -->|"使用"| OutboundPort["アウトバウンドポート (インターフェース)"]
OutboundAdapter["アウトバウンドアダプター"] -->|"実装"| OutboundPort
OutboundAdapter --> ExternalSystem["DB/API/キュー"]
UseCase --> DomainModel["ドメインモデル"]
推奨モジュール構成
機能優先の構成と明示的な境界を使用:
src/
features/
orders/
domain/
Order.ts
OrderPolicy.ts
application/
ports/
inbound/
CreateOrder.ts
outbound/
OrderRepositoryPort.ts
PaymentGatewayPort.ts
use-cases/
CreateOrderUseCase.ts
adapters/
inbound/
http/
createOrderRoute.ts
outbound/
postgres/
PostgresOrderRepository.ts
stripe/
StripePaymentGateway.ts
composition/
ordersContainer.ts
TypeScriptの例
ポート定義
export interface OrderRepositoryPort {
save(order: Order): Promise<void>;
findById(orderId: string): Promise<Order | null>;
}
export interface PaymentGatewayPort {
authorize(input: { orderId: string; amountCents: number }): Promise<{ authorizationId: string }>;
}
ユースケース
type CreateOrderInput = {
orderId: string;
amountCents: number;
};
type CreateOrderOutput = {
orderId: string;
authorizationId: string;
};
export class CreateOrderUseCase {
constructor(
private readonly orderRepository: OrderRepositoryPort,
private readonly paymentGateway: PaymentGatewayPort
) {}
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
const order = Order.create({ id: input.orderId, amountCents: input.amountCents });
const auth = await this.paymentGateway.authorize({
orderId: order.id,
amountCents: order.amountCents,
});
// markAuthorizedは新しいOrderインスタンスを返します。その場で変更するわけではありません。
const authorizedOrder = order.markAuthorized(auth.authorizationId);
await this.orderRepository.save(authorizedOrder);
return {
orderId: order.id,
authorizationId: auth.authorizationId,
};
}
}
アウトバウンドアダプター
export class PostgresOrderRepository implements OrderRepositoryPort {
constructor(private readonly db: SqlClient) {}
async save(order: Order): Promise<void> {
await this.db.query(
"insert into orders (id, amount_cents, status, authorization_id) values ($1, $2, $3, $4)",
[order.id, order.amountCents, order.status, order.authorizationId]
);
}
async findById(orderId: string): Promise<Order | null> {
const row = await this.db.oneOrNone("select * from orders where id = $1", [orderId]);
return row ? Order.rehydrate(row) : null;
}
}
コンポジションルート
export const buildCreateOrderUseCase = (deps: { db: SqlClient; stripe: StripeClient }) => {
const orderRepository = new PostgresOrderRepository(deps.db);
const paymentGateway = new StripePaymentGateway(deps.stripe);
return new CreateOrderUseCase(orderRepository, paymentGateway);
};
マルチ言語マッピング
エコシステム全体で同じ境界ルールを使用します。構文と配線スタイルのみが変わります。
- TypeScript/JavaScript
- ポート:
application/ports/*をインターフェース/型として。 - ユースケース: コンストラクタ/引数インジェクションを持つクラス/関数。
- アダプター:
adapters/inbound/*、adapters/outbound/*。 - コンポジション: 明示的なファクトリ/コンテナモジュール(隠れたグローバルは禁止)。
- ポート:
- Java
- パッケージ:
domain、application.port.in、application.port.out、application.usecase、adapter.in、adapter.out。 - ポート:
application.port.*内のインターフェース。 - ユースケース: プレーンなクラス(Spring
@Serviceはオプションであり、必須ではありません)。 - コンポジション: Spring設定または手動配線クラス。配線はドメイン/ユースケースクラスの外に保ちます。
- パッケージ:
- Kotlin
- モジュール/パッケージはJavaの分割を反映します(
domain、application.port、application.usecase、adapter)。 - ポート: Kotlinインターフェース。
- ユースケース: コンストラクタインジェクション(Koin/Dagger/Spring/手動)を持つクラス。
- コンポジション: モジュール定義または専用のコンポジション関数。サービスロケーターパターンは避けます。
- モジュール/パッケージはJavaの分割を反映します(
- Go
- パッケージ:
internal/<feature>/domain、application、ports、adapters/inbound、adapters/outbound。 - ポート: 利用するアプリケーションパッケージが所有する小さなインターフェース。
- ユースケース: インターフェースフィールドと明示的な
New...コンストラクタを持つ構造体。 - コンポジション:
cmd/<app>/main.go(または専用の配線パッケージ)で配線し、コンストラクタは明示的にします。
- パッケージ:
避けるべきアンチパターン
- ドメインエンティティがORMモデル、Webフレームワーク型、SDKクライアントをインポートすること。
- ユースケースが
req、res、キュー・メタデータを直接読み取ること。 - ユースケースからドメイン/アプリケーション・マッピングなしでデータベース行を直接返すこと。
- アダプターがユースケース・ポートを通らずに互いに直接呼び出し合うこと。
- 依存関係の配線を多数のファイルに分散させ、隠れたグローバルシングルトンを使用すること。
移行プレイブック
- 変更が頻繁に発生する1つの垂直スライス(単一のエンドポイント/ジョブ)を選択します。
- 明示的な入出力型を持つユースケース境界を抽出します。
- 既存のインフラストラクチャ呼び出しの周りにアウトバウンドポートを導入します。
- コントローラー/サービスからユースケースへオーケストレーションロジックを移動します。
- 古いアダプターはそのままにして、新しいユースケースに委譲するようにします。
- 新しい境界の周りにテストを追加します(単体テスト+アダプター統合テスト)。
- スライスごとに繰り返します。完全な書き換えは避けます。
既存システムのリファクタリング
- ストラングラーアプローチ: 現在のエンドポイントを維持し、一度に1つのユースケースを新しいポート/アダプター経由でルーティングします。
- ビッグバン書き換えはしない: 機能スライスごとに移行し、特性テストで動作を保護します。
- まずファサード: 内部を置き換える前に、レガシーサービスをアウトバウンドポートの背後にラップします。
- コンポジションの凍結: 新しい依存関係がドメイン/ユースケース層に漏れないように、早期に配線を集中管理します。
- スライス選択ルール: 変更頻度が高く、影響範囲が狭いフローを優先します。
- ロールバックパス: プロダクションでの動作が検証されるまで、移行したスライスごとに元に戻せるトグルまたはルートスイッチを保持します。
テストガイダンス(同じヘキサゴナル境界)
- ドメインテスト: エンティティ/値オブジェクトを純粋なビジネスルールとしてテストします(モックなし、フレームワークのセットアップなし)。
- ユースケース単体テスト: アウトバウンドポートのフェイク/スタブを使用してオーケストレーションをテストし、ビジネス成果とポートインタラクションをアサートします。
- アウトバウンドアダプター契約テスト: ポートレベルで共有の契約スイートを定義し、各アダプター実装に対して実行します。
- インバウンドアダプターテスト: プロトコルマッピング(HTTP/CLI/キューペイロードからユースケース入力、出力/エラーのプロトコルへのマッピング)を検証します。
- アダプター統合テスト: シリアル化、スキーマ/クエリ動作、リトライ、タイムアウトについて実際のインフラ(DB/API/キュー)に対して実行します。
- エンドツーエンドテスト: インバウンドアダプター -> ユースケース -> アウトバウンドアダプターを通じて、重要なユーザージャーニーをカバーします。
- リファクタリングの安全性: 抽出前に特性テストを追加し、新しい境界の動作が安定して同等になるまで維持します。
ベストプラクティスチェックリスト
- ドメイン層とユースケース層は内部型とポートのみをインポートします。
- すべての外部依存関係はアウトバウンドポートによって表現されています。
- 検証は境界(インバウンドアダプター + ユースケース不変条件)で行われます。
- 不変の変換を使用します(共有状態を変更する代わりに、新しい値/エンティティを返します)。
- エラーは境界を越えて変換されます(インフラエラー -> アプリケーション/ドメインエラー)。
- コンポジションルートは明示的で監査が容易です。
- ユースケースはポートに対するシンプルなインメモリフェイクでテスト可能です。
- リファクタリングは動作を保持するテストから始まる1つの垂直スライスから開始します。
- 言語/フレームワーク固有の詳細はアダプターに留まり、ドメインルールには決して入りません。