hexagonal-architecture について
ヘキサゴナルアーキテクチャを採用したプロジェクト向けのコーディングスキルで、ポートとアダプターによって境界を構成します。依存関係を内向きに保ち、ベンダー非依存のユースケースとインメモリのフェイクによるテストを重視します。
主な機能
- 01ポートによる内外の分離
- 02アダプターを介したベンダーSDKの隔離
- 03駆動・被駆動インターフェースの定義
- 04手書きフェイクによるユースケーステスト
- 05導入ガイドと実例リソース
活用シーン
- 01ヘキサゴナル構成のコードベースへの機能追加
- 02ベンダーSDKと業務ロジックの分離
- 03SDKモックを使わないユースケーステスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ citypaul/ .dotfiles --skill hexagonal-architectureインストール後、Claude Code で/hexagonal-architectureを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
resources/cqrs-lite.mdcross-cutting-concerns.mdgreenfield-sequence.mdincremental-adoption.mdreferences.mdtesting-hex-arch.mdworked-example.md詳細
ヘキサゴナルアーキテクチャ(ポート&アダプター)
このスキルは、ヘキサゴナルアーキテクチャを採用することを選択したプロジェクトにのみ適用されます。異なるアーキテクチャを使用するプロジェクトには、これらのパターンを適用しないでください。既存のコードベースにヘキサゴナルアーキテクチャを段階的に導入する方法については、resources/incremental-adoption.mdを参照してください。
コードを書く前に
このセクションを指示として読んでください。スキルの残りの部分では、その理由を説明します。
サードパーティ SDK のクライアント型がポートになることはありません。 OrdersApiClient、SmtpClient、PushClient、Stripe クライアントなど、ベンダーパッケージまたはリポジトリの SDK フォルダー(src/lib/、vendor/、node_modules)がエクスポートするものはすべてヘキサゴンの外側にあります。また、import typeを含め、それをインポートするすべてのファイルも同様です。SDK の DTO 型も含まれます。フィールドが SDK クライアント型の deps オブジェクトには、ポートがありません。テストでいくつのフェイクを渡していても同じです。SDK クライアントと必要なインターフェースの構造的な互換性は、アダプターを省略する理由にはなりません。
オプトイン済みのコードベースで機能を追加する場合 — 次の順序で行います。完成形はresources/worked-example.mdを参照してください。
- ビジネスルール(時間枠、フィルター、宛先、件名、メッセージ本文、切り詰め処理)を、ドメイン型と値としての
nowを受け取る純粋な内部関数に置きます。アダプターは完成した値を受け取り、SDK の形式に変換するだけにします。 - ユースケースが必要とするポートを、そのユースケースの隣にエクスポートされた
interfaceとして宣言します。駆動ポートはFor<Verb>ing<Noun>(ForPlacingOrders、ForSendingReceipts)とし、depsがポートとnowのみを保持するcreate<Capability>(deps)で実装します。被駆動ポートは役割を表す名詞(OrderRepository、ReceiptSender、PaymentGateway)とし、そのメソッドでは SDK の DTO ではなく、独自のドメイン型を使用します。 - 被駆動ポートごとに、SDK をインポートしてその形式をポートの型に変換するアダプターを1つ作成します。また、入力を解析して駆動ポートを呼び出す駆動アダプター(ルート、CLI、キュー)も作成します。
- SDK クライアント → アダプター → ユースケースの配線は、コンポジションルート(
src/index.ts、main.ts、またはcomposition/)でのみ行います。ルートは呼び出し元から SDK クライアントまたは設定を引き続き受け取り、アダプターを自分で構築します。ポートを受け取るように、ルートの公開シグネチャを変更しないでください。 - 駆動ポートを通してユースケースをテストし、被駆動ポートの手書きのインメモリフェイクを使います。各アダプターは SDK とは別にテストします。
SDK を直接インポートしているユースケースを変更する場合 — README にアーキテクチャ導入前のものと記載されているファイル、または SDK フォルダーからインポートしている内部ファイルが該当します。オプトイン済みのリポジトリでは、変更対象ファイルが依存する SDK をすべてポートの背後に移すことも変更の一部であり、スコープクリープではありません。これは、既存のテストを成功させた状態で、新しい振る舞いに対する RED の前に行う、振る舞いを維持する REFACTOR の手順です。依頼の文言が何を変更するかを定め、アーキテクチャがどのように変更するかを定めます。まず、SDK 依存をすべてポートの背後に移し(上記の手順2〜4)、駆動ポートのインターフェースがなければ追加します。エクスポートされた関数は駆動ポートではありません。ForSendingReceipts(または同等のもの)を宣言し、createReceiptSender(deps)から返してください。その後、ポートを使って依頼された変更を行います。依頼で指定された依存だけを移すと、次回の差し替えでもユースケースに手を加えることになります。完了する前に、ユースケースのインポートを確認してください。SDK フォルダーまたは SDK クライアント型を引き続き参照しているものがあれば、まだ完了していません。それもポートの背後に移してください。依頼に「SDK 呼び出しの箇所だけで対応して」とある場合でも、ルールは内部に適用します。その理由を一文で伝えてください。
リストラクチャリングの依頼がない場合に新しいファイルを置く場所: 内部コードはリポジトリの既存のレイアウトに合わせます。ただし、アダプターとフェイクはベンダーのコードではなく、あなたが作成するコードです。担当する機能の隣(src/<feature>/ports.ts、src/<feature>/adapters.ts、src/<feature>/testing/)またはadapters/フォルダーに配置してください。SDK の代替実装を置くフォルダーには決して配置しないでください。そこに置くと、アダプターがサードパーティのクライアントの一つに見えてしまいます。
ユースケーステストではモックではなくフェイクを使います。 ユースケーステストには vi.fn、vi.mock、vi.spyOn を含めず、SDK フォルダーから何もインポートしません。通知機能やメール送信機能を観察するには、記録用フェイク(sent: readonly Receipt[])を使います。SDK クライアントをモックしている既存のユースケーステストを変更する場合は、モックを拡張するのではなく、先ほど宣言したポートのフェイクに置き換えてください。
ドメインモデリング(エンティティ、値オブジェクト、集約、ユビキタス言語)には、domain-driven-design スキルを読み込んでください。ヘキサゴナルアーキテクチャとDDDは相補的ですが、独立しています。ヘキサゴナルアーキテクチャは構造的な分離(外側をどのように接続するか)を提供し、DDDはドメインモデル(中心に何を置くか)を提供します。どちらか一方だけを採用するプロジェクトもあります。
ソースツリーの物理的な構成を設計または変更する場合は、structure-codebase スキルを使ってください。ヘキサゴナルバックエンドを採用する場合、まずビジネス機能または境界づけられたコンテキストごとにグループ化し、次に、その所有者の hexagon/ の下で、プロバイダーに依存しない内部を明確にします。具体的な駆動側/駆動される側の技術と、再利用可能なテスト用インタラクターは、その外側に配置します。物理的な再構成が依頼されていない場合は、ここで説明する依存関係の方向を適用しつつ、内部の配置にはリポジトリの既存構成を維持してください。ただし、新しいポート、アダプター、フェイクはファーストパーティコードなので、担当する機能の隣か adapters/ フォルダーに配置し、サードパーティ SDK のコードを格納するフォルダーには決して置かないでください(「コードを書く前に」を参照)。
ポートやプロセス内モジュールの一貫した責務と、呼び出し側にかかる全負担を把握するには、codebase-design を使ってください。すべてのモジュールインターフェースやテストの継ぎ目がヘキサゴナルポートというわけではありません。また、意図的に薄くした駆動側/駆動される側のアダプターは、薄いままにしてください。ポートを恒久的に設けるべきか判断する前に、密結合のレガシー動作を特性評価するための最小限の手段が必要な場合は、finding-seams を使ってください。
詳細リソースは resources/ ディレクトリにあります。必要に応じて読み込んでください。
| リソース | 読み込むタイミング |
|---|---|
worked-example.md | テストとファイルマップを含め、すべてのレイヤーを通した機能全体の例が必要な場合 |
testing-hex-arch.md | テストの作成、フェイクの作成、createTestDb のセットアップ、差し替え可能性テストを行う場合 |
cqrs-lite.md | 集約をまたぐ JOIN が必要な読み取りや、読み取り/書き込み経路の分離を行う場合 |
cross-cutting-concerns.md | 認証、ロギング、トランザクション、エラー形式を配置する場合 |
incremental-adoption.md | 既存のコードベースにヘキサゴナルアーキテクチャを導入する場合 |
greenfield-sequence.md | ヘキサゴナルプロジェクトをゼロから始める場合。最初のポート、アダプター、テストの順序を決める際 |
references.md | 出典の根拠、特にポート/公開インターフェースの命名を確認する場合 |
一次資料と命名の根拠については、resources/references.md を参照してください。
コアコンセプト
ビジネスロジックはドメインポリシーに、ユースケースとオーケストレーションはアプリケーションポリシーに置きます。外部システムとは、ポート(インターフェース)とアダプター(実装)を介して接続します。依存関係は内側を向きます。ドメインは純粋な状態を保ち、アプリケーションは自ら所有する抽象化を通じてのみ外部機能を認識し、どちらも具象アダプターをインポートしません。
- ドメインコードは、フレームワーク、トランスポートプロトコル、永続化の実装、クロック、UUID ジェネレーター、ベンダー SDK、UI に関する要素に依存しません。時刻、識別子、外部の事実は、型付き入力として渡します。
- 駆動/入力アダプターは、信頼できない外部入力を解析して型付きのアプリケーションコマンドに変換し、アプリケーションのユースケースを呼び出します。
- アプリケーションサービスはユースケースを調整し、ドメインの振る舞いを呼び出して、必要なポートを呼び出します。
- 駆動される/出力アダプターは、内側の利用側が所有するポートを実装し、技術固有の結果を境界で変換します。
Driving (left) Driven (right)
┌──────────────────┐ ┌──────────────────┐
│ Route handlers │ │ Repositories │
│ CLI commands │──────┐┌────│ API clients │
│ Event listeners │ ││ │ Email services │
└──────────────────┘ ││ └──────────────────┘
call into ──────► ┌────┐ ◄────── implement
│ │
│ IN │
│ SI │
│ DE │
│ │
call into ──────► └────┘ ◄────── implement
┌──────────────────┐ ││ ┌──────────────────┐
│ Cron triggers │──────┘└────│ File storage │
│ Message queues │ │ Payment gateway │
└──────────────────┘ └──────────────────┘
駆動アダプター(左側):アプリケーション上のアクションを開始します。ユースケースを呼び出します。 駆動されるアダプター(右側):アプリケーションがこれらに働きかけます。ポートインターフェースを実装します。
このパターン自体は対称です。ルールは「内側か外側か」の一つだけであり、アプリケーションはどちら側に何が接続されているかを認識しません。左右の非対称性が現れるのは実装上の「誰が誰を知っているか」という点だけです。駆動アダプターはアプリケーションを認識し、アプリケーションが公開する駆動ポートインターフェースを呼び出します。一方、アプリケーションは駆動されるアダプターを、自ら定義したポートインターフェースを実装する注入済みのパラメーターとしてのみ認識します。そのため、駆動ポートは提供インターフェースであり、駆動されるポートは要求インターフェースです。
このパターンが定義するのは、「内側」と「外側」のちょうど二つの領域だけであり、どちらの内部構造についても何も定めません。このスキルで推奨するドメイン/ユースケースのレイヤー構成は、内側の一貫性を保つための方法であり、パターンそのものの一部ではありません。
用語: アクターとは、人間、データベース、別のプログラム、テストなど、境界の外側で振る舞いを持つあらゆるものです。駆動(=プライマリー)アクターはアプリケーションとのやり取りを開始し、駆動される(=セカンダリー)アクターはアプリケーションが呼び出す対象です。これらは、アクター、ポート、アダプターのいずれにも等しく適用できる唯一の形容詞の組み合わせです(インバウンド/アウトバウンドはポート、アダプター、フォルダーには使えますが、アクターには使えません)。インタラクターとは、ポートに直接接するアクター、またはそのアダプターのことです。すべてのアクターにアダプターが必要なわけではありません。テスト、同じ階層にある別のヘキサゴナルアプリ、プログラム間の呼び出し元は、そのままポートのインターフェースに接続できます。駆動ポートはアプリケーションの提供インターフェース(API)を構成し、駆動されるポートはアプリケーションの要求インターフェース(SPI)を構成します。
ポート=公開コントラクト
ポートとは、内側と外側の境界における、名前と目的が明確なやり取りです。TypeScript では、境界が公開されているかアーキテクチャ上重要な場合、ポートを明示的な interface 型として表します。駆動アダプターは駆動ポートインターフェースを呼び出し、駆動されるアダプターやフェイクは駆動されるポートインターフェースを実装します。
ポートを所有するのは、その抽象化を必要とする最も内側の利用側です。駆動ポートはアプリケーションのユースケースを公開するため、アプリケーションが所有します。インフラストラクチャーに接続するリポジトリやゲートウェイのポートも、ユースケースが利用するため、通常はアプリケーションが所有します。ドメインモデル自体がそのやり取りを実際に所有し、利用する場合に限り、ポートはドメインに属します。依存関係逆転を維持しつつ、内側が所有することとドメインが所有することを混同しないでください。
ユースケースの実装は駆動ポートを満たします。駆動アダプターは、フレームワーク固有のコントローラー、ハンドラークラス、具象アダプターではなく、役割に応じて定義されたポートインターフェースに依存する必要があります。
ポートと公開インターフェースの命名
会話で使われているドメイン言語を用い、アプリケーションの視点からすべてのポートに名前を付けます。元のパターンでは、会話の意図に沿って、駆動側と被駆動側の両方を含むすべてのポートに名前を付けます。例:ForPlacingOrders、ForGettingTaxRates、ForStoringTickets。ただし、このパターンは命名を一切規定しません。このスキルでは、駆動ポートには意図を表す名前を使い、被駆動ポートには役割を表す名詞(OrderRepository、PaymentGateway)を使うことを、意図的なプロジェクト内の慣例としています。どちらのスタイルも、技術名ではなく目的を表す名前です。すでにコードベースの被駆動側で For... の名前を使っている場合は、元の規約に沿っているため、そのままにしてください。
| 境界 | 適切な名前 | 避ける名前 | 理由 |
|---|---|---|---|
| 駆動側/公開 API | ForPlacingOrders、ForPledgingToOccasions | PlaceOrderUseCase、PlaceOrderHandler、OrderInputPort | パターンではなく、アクターができることを表すため |
| 集約の永続化 | OrderRepository、ContributorRepository(または ForStoringOrders) | OrderDatabase、OrderDao、SqlOrderPort | ユースケースに必要な集約へのアクセスを表すため |
| 外部サービス | PaymentGateway、ExchangeRateProvider(または ForPaying、ForObtainingRates) | StripeClient、HttpPaymentPort | ベンダー/プロトコルではなく、アプリケーションが必要とする機能を表すため |
| 通知/イベント | OrderEventPublisher、ReceiptSender(または ForNotifyingContributors) | SnsAdapter、MessageBusPort | ビジネス側のやり取りを表すため |
公開インターフェースの命名規則:
- 振る舞いの契約には
interfaceを使います。typeやスキーマで表すほうが適切なデータ形状には使いません。 - インターフェース名に
Iを付けたり、Interfaceを末尾に付けたりしないでください。役割を名前にします(IPaymentGatewayやPaymentGatewayInterfaceではなくPaymentGateway)。 - 型名に
Portを使わないでください。PaymentPortという名前は「会話」ではなく「アーキテクチャ」を表します。 - 実装名に
Implを使わないでください。具体的な技術や戦略を名前にします。例:createStripePaymentGateway、createFakePaymentGateway、createDrizzleOrderRepository。 - ヘッダーインターフェースより、役割を表すインターフェースを優先してください。アダプターがたまたま公開しているすべてのメソッドではなく、ユースケースに必要なメソッドだけを含めます。
- アダプターの交換後も安定する名前にしてください。Stripe から PayPal、SQL から DynamoDB、HTTP からキューへの移行でポート名の変更が必要になるなら、ポート名にインフラストラクチャが漏れています。
// Driving port — exposed by the application, called by driving adapters
interface ForPlacingOrders {
readonly placeOrder: (command: PlaceOrderCommand) => Promise<PlaceOrderResult>;
}
// Driven port — application-owned because the use case consumes it
interface UserRepository {
readonly findById: (id: UserId) => Promise<User | undefined>;
readonly save: (user: User) => Promise<void>;
}
type PreparePaymentResult =
| { readonly success: true; readonly paymentId: PaymentId }
| { readonly success: false; readonly reason: PaymentFailure };
type PaymentOutcome =
| { readonly outcome: 'paid'; readonly chargeId: ChargeId }
| { readonly outcome: 'declined'; readonly reason: PaymentFailure }
| { readonly outcome: 'pending' };
// Driven port — application-owned because the use case consumes it
interface PaymentGateway {
readonly preparePayment: (request: {
readonly amount: Money;
readonly reference: OrderId;
readonly idempotencyKey: OrderId;
}) => Promise<PreparePaymentResult>;
readonly completePayment: (request: {
readonly paymentId: PaymentId;
readonly paymentInfo: PaymentInfo;
}) => Promise<PaymentOutcome>;
}
// Driven port — event publishing (outbound to message brokers)
interface OrderEventPublisher {
readonly publish: (event: OrderEvent) => Promise<void>;
}
ポート設計の原則:
- 技術ではなく、ビジネス上の目的に基づいてポートに名前を付けます(
DatabasePortではなくUserRepository)。 - ポートの範囲を絞ります。巨大なポートを1つ作るのではなく、集約または機能ごとに1つずつ用意します。
- ポートのメソッドでは、インフラストラクチャの型(
SqlRow、HttpResponseなど)も、ベンダー SDK の DTO やメッセージ型も使わず、ドメイン型を使います(SDK のOrderDtoやPushMessageはインフラストラクチャの型です。独自のOrder/Receiptを内部で定義し、アダプターでマッピングしてください)。 - 作成パラメーターのスキーマは、それが表すリポジトリポートと同じ場所に配置します。
アダプター = 実装
アダプターは、特定の技術向けにポートを実装します。優れたアダプターはシンプルで、ポートのドメイン型と技術固有のネイティブ型との間を変換します。ビジネスロジックは含みません。
// Driven adapter — implements the repository port using Drizzle/D1
const createDrizzleUserRepository = (db: D1Database): UserRepository => ({
findById: async (id) => {
const row = await db.select().from(users).where(eq(users.id, id)).get();
return row ? toUser(row) : undefined;
},
save: async (user) => {
await db.insert(users).values(toRow(user)).onConflictDoUpdate({ ... });
},
});
// Driven adapter — implements the same port for tests
const createFakeUserRepository = (initial: readonly User[] = []): UserRepository => {
const store = new Map(initial.map(u => [u.id, u]));
return {
findById: async (id) => store.get(id),
save: async (user) => { store.set(user.id, user); },
};
};
アダプターのエラー処理: compare-and-save や一意性に関する想定内の結果は、明示的なアプリケーションポートの結果に変換します。想定外のインフラストラクチャ障害(接続切断、タイムアウト、ディスク障害)は、最上位のハンドラーに伝播させます。ドメインはインフラストラクチャの存在を知らないため、インフラストラクチャエラーを捕捉しません。
type CreateUserResult =
| { readonly success: true }
| { readonly success: false; readonly reason: 'already-exists' };
// Driven adapter: translate an expected storage condition into port vocabulary
const createDrizzleUserCreator = (db: Database) => ({
create: async (user: User): Promise<CreateUserResult> => {
try {
await db.insert(users).values(toRow(user));
return { success: true };
} catch (e) {
if (isUniqueConstraintError(e)) {
return { success: false, reason: 'already-exists' };
}
throw e; // unexpected errors (connection lost, disk full) propagate
}
},
});
ユースケースでは already-exists を漏れなく処理します。想定外のインフラストラクチャエラーは、最上位のハンドラーに伝播させます。
重要な原則: アダプターの交換にドメインコードの変更が必要なら、境界の設計が誤っています。
読み取りと書き込み(CQRS-lite)
すべての読み取りをリポジトリ経由で行う必要はありません。リポジトリパターンは集約境界を強制します。これは書き込みには不可欠ですが、表示用の読み取りでは、集約をまたいで JOIN する必要があることがよくあります。
| 操作 | パターン | 例 |
|---|---|---|
| 書き込み | リポジトリ(1 つの集約) | userRepo.save(user) |
| 読み取り(単一の集約) | リポジトリ | userRepo.findById(id) |
| 読み取り(集約横断、表示用) | クエリ関数(自由に JOIN) | getEventDetail(db, eventId) |
クエリ関数も駆動アダプターです。ケイパビリティ優先のレイアウトでは、所有者のプロバイダー側の境界(たとえば packages/reporting/adapters/driven/postgres/queries/)に配置され、読み取りに最適化された DTO を返します。意図的にリポジトリパターンを迂回します。
// Query function — JOINs across aggregates for display
// Lives at packages/reporting/adapters/driven/postgres/queries/, outside the hexagon
const getParticipantEventView = async (db: Database, eventId: string) => {
return db.select({ ... })
.from(events)
.innerJoin(occasions, ...)
.leftJoin(giftClaims, ...)
.where(eq(events.id, eventId))
.all();
};
関数内のプロバイダー非依存のロジックでは、クエリ結果を解釈してもかまいません。ただし、その解釈が真のビジネス上の意味を表す場合に限ります(たとえば「このアイテムは現在のユーザーが取得済みか?」)。表示のみを目的とするフォーマット処理は駆動側の境界に置きます。クエリが取得し、ビジネスポリシーが解釈します。
CQRS-lite の詳しいガイダンスについては、resources/cqrs-lite.md を参照してください。読み取りと書き込みの分離を徹底し、イベントを信頼できる唯一の情報源として、リプレイによって読み取りモデルを再構築する場合、それはイベントソーシングです。event-sourcing スキルを読み込んでください。
依存性注入とコンフィギュレーター
すべての依存関係は関数パラメーター経由で注入します。DI コンテナーは不要です。駆動アダプターは副作用のある依存関係を集めてユースケースに渡し、その結果に基づいて処理します。これは Seemann の「impureim sandwich」(副作用あり/純粋/副作用あり)です。
// WRONG — creates dependencies internally (untestable, tightly coupled)
const createOrder = async (order: NewOrder) => {
const repo = new DrizzleOrderRepo(getDb()); // hardcoded
const gateway = new StripeGateway(process.env.KEY); // hardcoded
// ...
};
// RIGHT — dependencies injected into a use case implementation
type RecordPaymentResult =
| { readonly outcome: 'recorded'; readonly order: Order }
| { readonly outcome: 'conflict' };
type RecordChargeResult =
| { readonly outcome: 'recorded'; readonly order: Order }
| { readonly outcome: 'conflict' };
interface OrderRepository {
readonly findById: (id: OrderId) => Promise<Order | undefined>;
readonly findOrCreatePending: (
order: NewOrder & { readonly id: OrderId },
) => Promise<Order>;
readonly recordPayment: (
id: OrderId,
paymentId: PaymentId,
options: { readonly expectedVersion: number },
) => Promise<RecordPaymentResult>;
readonly recordCharge: (
id: OrderId,
chargeId: ChargeId,
options: { readonly expectedVersion: number },
) => Promise<RecordChargeResult>;
}
interface ForPlacingOrders {
readonly placeOrder: (order: NewOrder & { readonly id: OrderId }) => Promise<OrderResult>;
}
const createOrderPlacement = (
repo: OrderRepository,
gateway: PaymentGateway,
): ForPlacingOrders => ({
placeOrder: async (order) => {
const pending = await repo.findOrCreatePending(order);
if (pending.status === 'paid') return { success: true, order: pending };
let payable = pending;
if (payable.paymentId === undefined) {
const prepared = await gateway.preparePayment({
amount: payable.total,
reference: payable.id,
idempotencyKey: payable.id,
});
if (!prepared.success) return { success: false, reason: prepared.reason };
const paymentRecorded = await repo.recordPayment(payable.id, prepared.paymentId, {
expectedVersion: payable.version,
});
if (paymentRecorded.outcome === 'recorded') {
payable = paymentRecorded.order;
} else {
const current = await repo.findById(payable.id);
if (current?.status === 'paid') return { success: true, order: current };
if (current?.status !== 'pending' || current.paymentId === undefined) {
return { success: false, reason: 'concurrent-change' };
}
payable = current;
}
}
if (payable.paymentId === undefined) return { success: false, reason: 'concurrent-change' };
const payment = await gateway.completePayment({
paymentId: payable.paymentId,
paymentInfo: payable.payment,
});
if (payment.outcome === 'pending') return { success: false, reason: 'payment-pending' };
if (payment.outcome === 'declined') return { success: false, reason: payment.reason };
const recorded = await repo.recordCharge(payable.id, payment.chargeId, {
expectedVersion: payable.version,
});
if (recorded.outcome === 'recorded') return { success: true, order: recorded.order };
const current = await repo.findById(payable.id);
if (current?.status === 'paid' && current.chargeId === payment.chargeId) {
return { success: true, order: current };
}
return { success: false, reason: 'concurrent-change' };
},
});
findOrCreatePending は外部処理の前に注文をコミットし、クライアントが指定した ID を異なるデータで再利用しようとすると拒否します。preparePayment は請求を行わずにプロバイダーの支払いオブジェクトを作成します。アプリケーションは、completePayment が資金を移動できるようになる前に、その永続的な paymentId を記録します。クラッシュや並行する準備処理によって、請求されていない孤立オブジェクトが残ることがあります。その場合は、注文参照を使って照合またはキャンセルします。ただし、recordPayment で採用された支払い ID のみが完了処理に進みます。たとえば Stripe アダプターは注文に対して 1 つの PaymentIntent を作成し、後で同じオブジェクトを取得して確定します。
準備処理の冪等性キーは、短時間のリトライを最適化するためのものであり、無期限のクラッシュ復旧を目的としたものではありません。Stripe は、作成から 24 時間以上経過したリクエストキーを削除する場合があります。そのため、遅れてリトライされても、すでに記録済みの支払い ID の代わりとなるものを作成することはありません。completePayment は永続的なプロバイダーオブジェクトを調べ、適切な場合にのみ確定します。また、プロバイダーの状態が未確定または曖昧な場合は payment-pending として報告し、Webhook またはリコンシリエーターによる後続処理に委ねます。支払い後、recordCharge の前にプロセスが停止した場合、復旧処理は同じプロバイダーオブジェクトを調べ、同じ請求を記録します。楽観的競合に負けた処理が成功するのは、その正確な請求がすでに保存されている場合のみです。永続的な支払いオブジェクトや参照を提供できないゲートウェイには、単純な「請求してから保存」ではなく、明示的に上限を定めたリトライと照合・補償のワークフローが必要です。
コンポジションルート: 配線はアプリケーションのエントリーポイントで行います。ここで環境や設定からアダプターを作成し、ユースケースに注入します。具象実装を把握するのはここだけです。
// Serverless executable entrypoint = inline composition + driving adapter
// Valid only while this object graph remains trivial and unshared.
export async function POST(request: Request) {
const { env } = getCloudflareContext();
const db = createDb(env.DB);
// Wire adapters
const repo = createDrizzleOrderRepository(db);
const gateway = createStripeGateway(env.STRIPE_KEY);
const orderPlacement: ForPlacingOrders = createOrderPlacement(repo, gateway);
// Translate transport syntax separately from request-schema validation.
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: 'malformed-json' }, { status: 400 });
}
const parsedBody = CreateOrderSchema.safeParse(rawBody);
if (!parsedBody.success) {
return NextResponse.json({ error: 'invalid-body' }, { status: 422 });
}
// Call use case
const result = await orderPlacement.placeOrder(parsedBody.data);
return NextResponse.json(result);
}
このハンドラーが2つの役割を兼ねるのは、フレームワークによって実行可能なデプロイエントリーポイントとなり、グラフも単純だからです。インラインの配線は、リクエストの変換と見た目で区別できるようにしてください。HTTP の境界では、不正な JSON はトランスポート構文のエラー(400)です。構文的には有効でも、ボディまたはパスのスキーマに適合しない入力は、処理できないリクエスト(422)です。JSON の try/catch は狭い範囲に保ち、アプリケーションやインフラストラクチャの障害をクライアントエラーに変換しないようにしてください。共有ホストや複雑なホストでは、main.ts または composition/ でグラフを一度だけ構築し、準備済みの ForPlacingOrders をルートに渡してください。通常のルートモジュールが実装を選択することはありません。どちらの役割にもビジネスロジックを含めてはいけません。
コンフィギュレーター: すべてのプレイヤーを把握し、参加させるコード、つまりここでのコンポジションルートが、このパターンの5つ目の要素です。コンストラクターインジェクション(このスキルのデフォルト)は、認められている3つの形のうちの1つです。セッターまたは ForConfiguring... 関数を使うと、駆動アダプターをシステムの実行中に切り替えられます(注意点:アプリケーションを構築したものの、設定し忘れる可能性があります)。依存関係の検索では、アプリケーションにブローカーを渡し、アプリケーションが必要なときにそれを呼び出します。テストでは、テストケース自体がコンフィギュレーターと駆動アクターの両方を担います。本番環境では、コンポジションルートがその役割を担います。
HTTP 以外の実行可能なエントリーポイントにも、同じ境界を適用します。デプロイエントリーポイント自体であるキューコールバックは、単純なグラフをインラインで構成してもかまいません。通常のコンシューマーには準備済みの駆動ポートを渡し、解析、委譲、変換だけを行わせます。
// Queue deployment entrypoint = inline composition + driving adapter
const handlePledgeMessage = async (message: SQSMessage, env: Env) => {
const db = createDb(env.DB);
const occasionRepo = createDrizzleOccasionRepository(db);
const contributorRepo = createDrizzleContributorRepository(db);
const pledging: ForPledgingToOccasions = createPledgingToOccasions(occasionRepo, contributorRepo);
const dto = PledgeSchema.parse(JSON.parse(message.body));
await pledging.pledgeToOccasion({
...dto,
pledgeId: createPledgeId(message.messageId),
});
};
ユースケースは、HTTP リクエスト、キューメッセージ、cron ジョブ、CLI コマンドのどれによって起動されたかを知る必要も、気にする必要もありません。messageId は SQS による再配信でも変わりません。プレッジの永続化では、プレッジと同時に pledgeId の一意性をアトミックに保証する必要があります。論理的に同一の発行でも SQS ID が異なる可能性がある場合は、代わりにボディに含まれるプロデューサーのイベント ID を使用してください。すべての駆動アダプターは、薄い変換グルーのままにします。単純な具象グラフの選択まで担ってよいのは、実行可能なエントリーポイントという例外だけです。
命名: ユースケースのインターフェースと実装には、ビジネス上の能力に基づく名前を付けます。たとえば、ForPlacingOrders、createOrderPlacement、pledgeToOccasion です。CreateOrderUseCase や PlaceOrderHandler は使いません。パターンに由来する接尾辞は技術用語であり、ドメインの言葉ではありません。ユースケースの実装とドメイン関数は、依存関係で見分けられます。ユースケースの実装は駆動ポート(リポジトリー、ゲートウェイ)をパラメーターに取り、ドメイン関数はドメイン型だけを取ります。
用語に関する注記: 元となるパターンの文献では、「ユースケース」は要件定義の手法です。主要アクターと副次アクターは駆動アクターと被駆動アクターに対応し、ポートは最初、アクターごとに1つずつ存在します。このスキルでは、駆動ポートを実装するオーケストレーションオブジェクトを「ユースケース」と呼びます。関連はありますが、同じものではありません。
ファイル構成
これらは論理上の役割であり、フォルダー名を指定するものではありません。再構成が対象となる場合、structure-codebase スキルが物理的なレイアウトを担い、外部アダプターの隣に hexagon/ を配置することを推奨します。
| 役割 | ゾーン | 内容 | テスト |
|---|---|---|---|
| ドメインポリシー | 内部 | 純粋なビジネスルール、型、状態遷移 | 動作/プロパティに焦点を当てたテスト |
| アプリケーションポリシー | 内部 | プロバイダーに依存しないオーケストレーション、駆動ポートの実装、駆動されるポートの使用 | テストインタラクターを使ったユースケーステスト |
| ポート契約 | 内部。通常はアプリケーションが所有 | 最も内側の利用者が所有する、目的の明確な提供側/要求側のやり取り | 各ポートの両側から実行 |
| 駆動されるアダプター | 外部 | リポジトリーの実装、API クライアント、クエリー関数 | 統合/契約テスト |
| 駆動するアダプター | 外部 | ルートハンドラー、イベントリスナー、CLI/キューのエントリーポイント | トランスポート/E2E テスト |
| テストインタラクター | 外部 | テストドライバー、フェイク、再利用可能な動作契約スイート | テスト支援のみ |
| コンポジションルート | 実行可能ホスト | 具体的な構築、設定、リソースのライフサイクル | 起動/スモーク/E2E テスト |
主なルール:
- ドメインポリシーにはフレームワーク/インフラストラクチャへの依存が一切なく、純粋な状態を保ちます。
- アプリケーションポリシーは注入されたポートを呼び出せますが、具体的な I/O は行わず、プロセス内で実行可能な状態を保ちます。
- 駆動するポートと駆動されるポートの契約は、最も内側の利用者の隣にある内部に配置します。通常、ユースケース、リポジトリー、ゲートウェイのポートはアプリケーションが所有し、ドメインが所有するのは実際に利用するポートのみです。
- 信頼境界/ワイヤースキーマは、それを解析するアダプターとともに配置します。プロバイダーの表現を漏らさずビジネス不変条件を表現する場合は、ドメインが所有するバリデーションを内部に配置できます。
- アダプターは内部の公開契約をインポートします。逆は許可されません。
- 駆動するアダプターは薄く保ちます。解析、認証、変換、委譲、応答を行います。
- 具体的な配線は実行可能なエントリーポイントの近くに配置します。小さなエントリーポイントではインラインで組み立ててもかまいませんが、複雑なホストでは明示的なコンポジションルートを使用します。
structure-codebaseを適用する場合は、そのパッケージ/インポート境界に関するガイダンスを使って、これらの役割を徹底します。
テスト戦略
ヘキサゴナルアーキテクチャの最大の利点はテスト容易性です。主なテスト境界はユースケースです。駆動されるポートをインメモリーのフェイク(モックではありません)に置き換えて呼び出します。これにより、機能全体が動作することを確認できます。
| 優先度 | 境界 | 確認できること |
|---|---|---|
| 最優先 | ユースケース(フェイク化した駆動されるポート) | ヘキサゴン内で機能がエンドツーエンドに動作する |
| 補完 | ドメインの純粋関数 | 複雑なビジネスルールを分離して確認できる |
| 二次 | 駆動されるアダプター(実際の DB/MSW) | アダプターが正しく変換する |
| 検証 | E2E(フルスタック) | ユーザー体験が正しく動作する |
モックよりフェイク: フェイクは実際のインターフェースを実装し、状態を保持します。モックは呼び出し順序を検証するため、リファクタリング時に壊れます。具体的には、ユースケーステストには vi.fn、vi.mock、vi.spyOn を含めず、ベンダーの SDK やリポジトリーの SDK フォルダーからのインポートも含めません。各駆動されるポートに対して createFake<Port>() を構築し、フェイクに記録された状態(notifier.sent、repo.saved)をアサートします。SDK クライアントをモックする既存のユースケーステストに手を加える際は、拡張せず、その時点でポートのフェイクに置き換えます。詳しいパターンについては resources/testing-hex-arch.md を参照してください。
テストされて初めて、ポートは実在するものになります。 すべてのポートにテストインタラクターが必要です。駆動する各ポートにはテストドライバーを、駆動される各ポートにはフェイクを用意します。これがなければ、「ポート」は図上の線にすぎず、境界として機能することを保証できません。テストの壁は漏れ検出器も兼ねています。ビジネスロジックがアダプターに入り込んだり、技術的な詳細がドメインに入り込んだりすると、境界テストですぐに検出できます。
用語集から型、ドメイン、ユースケース、アダプター、テスト、ファイルの配置まで、1 つの機能をすべてのレイヤーにわたって追った完全な実例については、resources/worked-example.md を参照してください。
横断的関心事
| 関心事 | 場所 | 理由 |
|---|---|---|
| 認証(あなたは誰ですか?) | 駆動アダプター | プロトコル固有(JWT、セッション、API キー) |
| 認可(許可されていますか?) | ドメイン | 権限に関するビジネスルール |
| 技術的なテレメトリと相関付け | アダプター(両側) | インフラストラクチャに伴う副作用 |
| ドメイン上の観測(サポートログ、ビジネスメトリクス) | 駆動ポート(プローブ)またはドメインイベント | ビジネス上重要な事実。ほかのポートと同様、フェイクを使ってテストします |
| トランザクション | アダプター/コンポジションルート | インフラストラクチャ上の関心事であり、ドメインは関与しません |
| エラーのフォーマット | 駆動アダプター | ドメインの結果を HTTP/gRPC に変換します |
ドメインがロガーをインポートしたり、HTTP エラーを捕捉したり、トランザクションを管理したりすることはありません。 ドメインは結果を返します。ビジネス上重要な事実が境界まで伝わらない場合は、生のロガーではなく、明示的な駆動ポート(Domain Probe)またはドメインイベントを通じてその事実を通知します。4 層のオブザーバビリティモデルと詳細なパターンについては、resources/cross-cutting-concerns.md を参照してください。テレメトリに含める内容(ワイドイベント、SLO、アラート)については、observability スキルを参照してください。
アンチパターン
インフラストラクチャへのドメイン依存
六角形アーキテクチャで最もよくある違反です。ドメインコードがフレームワーク、データベース、または外部サービスをインポートしています。
// ❌ Domain imports Drizzle
import { eq } from 'drizzle-orm';
export const findActiveUsers = async (db) => db.select()...
// ❌ Same violation, type-only: the use case is shaped by the vendor's client and DTO
import type { OrdersApiClient, OrderDto } from './lib/orders-api';
export const sendReceipts = async (deps: { ordersApi: OrdersApiClient }, customerId: string) => ...
// ✅ Application defines the contract it consumes; adapter implements it
interface UserRepository {
readonly findActive: () => Promise<readonly User[]>;
}
アダプター内のビジネスロジック
ルートハンドラーやリポジトリにビジネスルールが含まれ、ドメインに処理を委譲していません。
// ❌ Business rule in route handler
export async function POST(request: Request) {
const order = await orderRepo.findById(id);
if (order.itemCount > 100) { await requireManagerApproval(order); } // business rule!
...
}
// ✅ Business rule in domain
const placeOrder = (order: Order): PlaceOrderResult => {
if (order.itemCount > 100) return { success: false, reason: 'requires-approval' };
...
};
アダプターのバイパス
ルートハンドラーがポートを経由せず、データベースに直接アクセスしています。
// ❌ Route handler hits DB directly
export async function GET(request: Request) {
const users = await db.select().from(users).where(eq(users.active, true));
...
}
// ✅ Route handler calls use case, which uses a port
const result = await getActiveUsers(userRepo);
ポートの乱立
小さな抽象化ごとにポートを作成しています。ポートは意味のある境界を表すべきです。集約ごとに 1 つ(リポジトリ)、または外部機能ごとに 1 つ(決済、メール、認証)を目安にします。
ドメイン概念に対するポート
駆動ポートは、データベース、決済プロバイダー、通知チャネルなど、外部システムとのやり取りを表します。内部のドメイン抽象化をポートインターフェースでラップしても、保護すべき境界がないまま間接性が増すだけです。六角形の外部にそのインターフェースの実装を置くことがないのであれば、それはポートではありません。
入れ子の六角形
六角形を入れ子にしてはいけません。ポートとアダプターの境界は、システムレベルのテストを維持する価値がある、技術的な境界(またはチームの権限の境界)に置きます。内側に六角形の境界を設けると、そのテストの壁が重複します。内側のテストは劣化し、境界は実質的なものではなくなります。代わりに、モジュール、境界づけられたコンテキスト、または通常の関数を使って内部を構成してください。
テクノロジーに形作られたポート
メソッドに技術の詳細が表れているポートです。ポートのメソッドにはドメインの用語を使うべきです。
// ❌ Technology leaks into port
interface UserRepository {
readonly findBySqlQuery: (sql: string) => Promise<User[]>;
readonly getFromRedisCache: (key: string) => Promise<User>;
}
// ✅ Business language
interface UserRepository {
readonly findActive: () => Promise<readonly User[]>;
readonly findById: (id: UserId) => Promise<User | undefined>;
}
パターンを示す公開名
ビジネス上の役割ではなく、アーキテクチャパターンを示す名前を付けると、コードが読みにくくなり、パターンを盲目的に模倣しやすくなります。
// ❌ Names the pattern or type mechanism
interface IPaymentPort {
readonly charge: (amount: Money, paymentInfo: PaymentInfo) => Promise<ChargeResult>;
}
const createPaymentGatewayImpl = (): IPaymentPort => ...
const placeOrderUseCase = async (...) => ...
// ✅ Names the role and the concrete adapter
interface PaymentGateway {
readonly charge: (amount: Money, paymentInfo: PaymentInfo) => Promise<ChargeResult>;
}
const createStripePaymentGateway = (): PaymentGateway => ...
const placeOrder = async (...) => ...
チェックリスト
パターンで必須の項目:
- 内側のファイル(ユースケース、ドメイン、ポート)が、ベンダーパッケージやリポジトリの SDK フォルダーから何もインポートしないこと。
import typeも禁止です。SDK クライアント型で構成された deps オブジェクトはポートの集合ではありません(完了前にユースケースのインポートを grep してください) - すべての外部境界がポート/公開コントラクトを使用していること。ポートを越えて参照するものがないこと
- ドメインロジックがフレームワーク/インフラストラクチャに依存していないこと(アクターやアダプターへのソース依存がないこと)
- ドメインロジックが、時計、UUID ライブラリ、SDK を直接読み取るのではなく、時刻、生成された識別子、外部の事実を値として受け取ること
- 駆動されるアクターが実行時に設定可能であること。アプリケーションが内部でアクターを構築しないこと(このパターンでは設定方法の形式は限定しませんが、このスキルではパラメーター注入を標準とします)
- ポートがビジネス用語のみを使い、ポートのメソッドが技術型を公開しないこと
- アダプターをどれでも交換しても、ドメインコードの変更が不要であること
- すべてのポートにテスト用インタラクターがあること。駆動側にはテストドライバー、駆動される側にはフェイクを用意します
このスキルで追加する項目(標準スタイル):
-
structure-codebaseが適用済みの場合、可視化された内側/外側のパッケージルールまたはインポートルールが存在し、遵守されていること - 駆動アダプターが薄いこと。解析、認証、変換、委譲、応答のみを行い、実行可能なエントリーポイントの単純な例外のみ、構成も行います
- 駆動アダプターが、ユースケースを呼び出す前に外部入力を型付きのアプリケーションコマンドへ変換すること
- 駆動されるアダプター(リポジトリ)がポートを実装し、ビジネスロジックを含まないこと
- ポートが最も内側の利用者とともに配置されていること。ユースケース、リポジトリ、ゲートウェイのポートは通常アプリケーションが所有し、ドメインが所有するのは実際に利用するポートのみです
- 公開インターフェースで、
Iプレフィックス、Interfaceサフィックス、Portサフィックスを避け、実装にはImplを使わないこと - 駆動ポートとユースケースの名前に、
UseCase/Handlerのようなパターン名ではなく、ビジネス上の機能を表す言葉を使うこと - ワイヤースキーマを信頼境界に留め、ビジネス不変条件の検証を、プロバイダーへの依存を持ち込まず内側に置くこと
- 集約をまたぐ JOIN を伴う読み取りにクエリ関数を使うこと(CQRS ライト)
- 各レイヤーに、適切なレベルの振る舞いテストがあること
- 横断的関心事(認証、技術的なテレメトリー、トランザクション)をアダプターに置くこと。ドメイン上重要な観測は、明示的な駆動ポートまたはドメインイベントを通じて行い、生のロガーをインポートしないこと
- ドメインが想定される結果を結果型で返し、ビジネスルールのために例外をスローしないこと