ヘキサゴナルアーキテクチャ は何をしますか?
ヘキサゴナルアーキテクチャ(Ports and Adapters)は、ビジネスロジックをフレームワーク、トランスポート、永続化の詳細から独立させます。コアアプリケーションは抽象的なポートに依存し、アダプターがそのポートを境界で実装します。
使用すべき場面
- 長期的な保守性とテスト容易性が重要な新機能の構築時。
- レイヤードアーキテクチャやフレームワークに依存したコードで、ドメインロジックがI/Oと混在している場合のリファクタリング時。
- 同じユースケースに対して複数のインターフェース(HTTP、CLI、キュー、cronジョブ)をサポートする場合。
- ビジネスルールを書き換えずにインフラストラクチャ(データベース、外部API、メッセージバス)を置き換える場合。
リクエストに境界、ドメイン中心設計、密結合サービスのリファクタリング、または特定のライブラリからのアプリケーションロジックの分離が含まれる場合にこのスキルを使用してください。
コアコンセプト
- ドメインモデル: ビジネスルールとエンティティ/値オブジェクト。フレームワークのインポートはありません。
- ユースケース(アプリケーション層): ドメインの動作とワークフローのステップをオーケストレーションします。
- インバウンドポート: アプリケーションが何をできるかを記述する契約(コマンド/クエリ/ユースケースインターフェース)。
- アウトバウンドポート: アプリケーションが必要とする依存関係の契約(リポジトリ、ゲートウェイ、イベントパブリッシャー、クロック、UUIDなど)。
- アダプター: ポートのインフラストラクチャおよび配信実装(HTTPコントローラー、DBリポジトリ、キューコンシューマー、SDKラッパー)。
- コンポジションルート: 具体的なアダプターをユースケースにバインドする単一の配線場所。
アウトバウンドポートインターフェースは通常、アプリケーション層(または抽象化が真にドメインレベルである場合のみドメイン層)に配置され、インフラストラクチャアダプターがそれらを実装します。
依存関係の方向は常に内側へ向かいます。
- アダプター -> アプリケーション/ドメイン
- アプリケーション -> ポートインターフェース(インバウンド/アウトバウンド契約)
- ドメイン -> ドメインのみの抽象化(フレームワークやインフラストラクチャへの依存なし)
- ドメイン -> 外部への依存なし
動作方法
ステップ 1: ユースケース境界をモデル化する
明確な入力と出力DTOを持つ単一のユースケースを定義します。トランスポートの詳細(Expressのreq、GraphQLのcontext、ジョブペイロードラッパー)はこの境界の外側に保持します。
ステップ 2: 最初にアウトバウンドポートを定義する
すべての副作用をポートとして特定します。
- 永続化(
UserRepositoryPort) - 外部呼び出し(
BillingGatewayPort) - 横断的関心事(
LoggerPort、ClockPort)
ポートは機能(capabilities)をモデル化するものであり、技術(technologies)をモデル化するものではありません。
ステップ 3: 純粋なオーケストレーションでユースケースを実装する
ユースケースのクラス/関数は、コンストラクタまたは引数を介してポートを受け取ります。アプリケーションレベルの不変条件を検証し、ドメインルールを調整し、プレーンなデータ構造を返します。
ステップ 4: 境界にアダプターを構築する
- インバウンドアダプターは、プロトコル入力をユースケース入力に変換します。
- アウトバウンドアダプターは、アプリケーション契約を具体的なAPI/ORM/クエリビルダーにマッピングします。
- マッピングはアダプター内に留まり、ユースケースの内部には入りません。
ステップ 5: コンポジションルートですべてを配線する
アダプターをインスタンス化し、それらをユースケースに注入します。この配線を一元化して、隠れたサービスロケータの動作を回避します。
ステップ 6: 境界ごとにテストする
- 偽のポートを使用してユースケースを単体テストします。
- 実際のインフラ依存関係を使用してアダプターの統合テストを実行します。
- インバウンドアダプターを通じてユーザー向けフローのE2Eテストを実行します。
アーキテクチャ図
flowchart LR
Client["クライアント (HTTP/CLI/Worker)"] --> 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つの垂直スライスから開始する。
- 言語/フレームワーク固有の詳細はアダプターに留め、ドメインルールには決して入れない。