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つの制約から導出できます。