Lambdaの中のヘキサゴナルアーキテクチャ(コード付き)
50個超のTypeScript Lambda関数がテスト可能で一貫性を保てた理由:10行のハンドラー、ポートとアダプター、そしてAPI・キュー・バッチの3つの入口に仕える1つのユースケース。
この構造が元を取ったと確信した瞬間は、地味なものでした。バリデーションルールが1つ変わり、ユースケースを1か所編集したら、3つの異なる実行経路——同期APIチェック、SQS経由のバルクパス、登録前の再バリデーション——のすべてが、追加作業ゼロでそれを拾ったのです。50個を超えるTypeScript Lambda関数のプラットフォームでは、これがすべてです:変更されるコードは正確に1か所に住み、50個の入口は何も隠せないほど薄くなければならない。
そこへ至ったレイアウトを、実際のコードの形で示します。
レイアウト
コードベースのすべての関数が同じディレクトリ構造に従います:
src/
core/domains/<domain>/
domain/ # エンティティ、値オブジェクト、ドメインエラー
application/ # ユースケース — 操作ごとに1クラス
ports/ # ドメインが定義するインターフェース
adapters/
primary/ # 入口:httpハンドラー、スキーマ、プレゼンター
secondary/ # 実装:永続化 (Prisma)、ストレージ、キュー
containers/ # ユースケースのファクトリー — 配線を1か所に
荷重を支えるルールはハンドラーに関するものです:ハンドラーは10行。 イベントをパースし、ユースケースを呼び、結果をトランスポートに写像する。ハンドラーに if が生えたら、ビジネスロジックがトランスポート層に漏れています。
// adapters/primary/http/handlers/validate-record.ts
export const handler = async (event: APIGatewayProxyEvent) => {
const input = parseValidateRequest(event); // 型付きの400を投げる
const result = await getValidateRecordUsecase().run(input); // containers/ から
return toApiResponse(result); // ステータス+ボディの写像
};
ポートがユースケースを可搬にする
ユースケースはLambdaもPrismaもSQSも知りません。依存するのはポート——ドメインが定義するインターフェースだけです:
// core/domains/records/application/validate-record.usecase.ts
export class ValidateRecordUseCase {
constructor(
private readonly masters: MasterDataPort, // 存在チェック
private readonly records: RecordRepository, // 永続化
) {}
async run(input: ValidateInput): Promise<ValidationResult> {
const branch = await this.masters.findBranch(input.branchCode);
if (!branch) return ValidationResult.reject("UNKNOWN_BRANCH");
// ... 実際のルールは、ここ1か所に
}
}
セカンダリアダプターがポートを実装します:本番ではPrismaベースのリポジトリ、テストではインメモリのフェイク。プライマリアダプター(ハンドラー、スキーマ、プレゼンター)はトランスポートの縁を所有します。見返りは2か所に現れます:
テストがミリ秒で走る。 ユニットテストはフェイクに対してユースケースを動かします——Lambdaエミュレーターなし、Dockerのデータベースなし、AWSはループの外。プラットフォームが育ってもスイートは数百ミリ秒に収まり続けました。これは「コミットのたびに走らせるテスト」と「リリース前にだけ走らせるテスト」の分かれ目です。
入口が無料で増える。 同じ ValidateRecordUseCase の前面に、API Gatewayハンドラー(単票・同期)、SQSハンドラー(バルク・非同期)、登録ステップ(最終送信前の再バリデーション)が立ちます。トランスポート3つ、ルールの実装1つ——冒頭のルール変更が編集1回で済んだ理由です。
50個の関数に一貫性が買ってくれるもの
このような構造は、関数1個目には小さな税で、関数50個目には複利の配当です:
- ナビゲーションが均一になる。 どのエンジニアもどの関数を開いても、ロジックの場所、I/Oの場所、モックすべきものがわかります。メンバーが入れ替わるコードベースでは、これは個々の設計判断よりも重要でした。
- レビューが鋭くなる。 「なぜハンドラーにPrismaのimportがあるの?」という1行のレビューコメントが、劣化のカテゴリーを丸ごと捕まえます。
- ドメインが正直であり続ける。 ユースケースはフレームワークに手を伸ばせないので、「生イベントからこれ1個だけ読ませて」のような誘惑には行き場がありません。
正直なコストと縁
- ボイラープレートは実在します。 すべての操作がハンドラー、ユースケース、ポート、アダプターを引き連れます。2行のルックアップにこの儀式は馬鹿らしく感じます——それでも払いました。「単純なものは免除」という混合スタイルこそが、規約の死に方だからです。
- コールドスタートが気にするのはレイヤーではなくimport。 レイヤリング自体の実行時コストはゼロでしたが、重いクライアントを熱心に構築するアダプターには課金されました。クライアントはハンドラーの外で一度だけ生成し、importグラフは軽く保つこと。
- ローカルのE2Eには依然として実環境が必要。 ヘキサゴナル構造はユニットテストを救いましたが、IAMやSQSのリドライブやAPI Gatewayの癖まではシミュレートしません。Terraformで管理された環境を10個保っていたのは、まさに統合テストに本物の場所を与えるためです。
十数個を超えて育つと見込むサーバーレスのコードベースを始めるなら、内部構造は関数5個目より前に決めてください——そして「ハンドラーは10行」ルールを交渉不可にすること。この記事の残りは全部、その1つの制約から導出できます。