👏

DDDとクリーンアーキテクチャをはじめよう-Bun+Hono編

に公開

背景

ども!池田(ikedadada)です!

前回のRust編で終わろうと思っていたのですが、Bunがv1.0を超えて安定してきたので、急遽Bun+Hono編を追加しました。

ソースコード:

https://github.com/ikedadada/start-ddd-and-clean-architecture/tree/main/backend_bun

全体像と依存の向き

シリーズの他言語編と同じく、「依存は内向き」「ユースケースが境界を握る」という骨格は変えていません。

  • src/domain: エンティティ(Todo), ポート(TodoRepository)
  • src/application_service: ユースケースとトランザクションサービス
  • src/infrastructure: BunのSQLドライバを使ったアダプタ、AsyncLocalStorageによる接続共有
  • src/presentation: Honoのハンドラとミドルウェア
  • src/index.ts: Composition Root(依存組み立て)

実はApplicationService層とDomain層は、Node.js編のTypeScript実装をほぼそのまま持ってきています。バニラのTypeScriptで書いていたおかげでフレームワークに縛られず、TodoエンティティやユースケースはHonoやBunのどちらでも手を加えず流用できました。この「内側は技術詳細を知らない」設計にしていたことが、マルチランタイム展開のコストをゼロにしてくれます。

リポジトリ・トランザクション・ハンドラのみがBun/Honoに合わせて差し替えた部分です。

Bun純正SQLドライバとトランザクション境界

Bunはbun:sqlite文化のイメージが強いですが、v1.0以降はSQLドライバが常用レベルに達しました。今回はこのドライバを素のまま使い、PrismaやORMを挟まずにアダプタを構築しています。

// infrastructure/repository/db.ts(抜粋)
export const db = new SQL({
  adapter: "mysql",
  hostname: process.env.DB_HOST,
  // ...
});

AsyncLocalStorageで接続を透過共有

Node.js編と同じくAsyncLocalStorageを採用し、ユースケースからトランザクション境界を意識させない仕組みを維持しました。

// infrastructure/repository/context.ts
export class ALSContextProvider implements ContextProvider<Conn> {
  private readonly als = new AsyncLocalStorage<Conn>();

  constructor(private readonly base: SQL) {}

  get(): Conn {
    return this.als.getStore() ?? this.base;
  }

  async runWith<T>(context: Conn, fn: () => Promise<T>): Promise<T> {
    return this.als.run(context, fn);
  }
}

TransactionServiceImplはBunのdb.begin()で非同期関数をラップし、ALSContextProvider
にTx接続を差し込むだけです。ユースケースは以前と同じ
transactionService.run(async () => { ... })の呼び出しで済みます。

// infrastructure/service/transactionService.ts
export class TransactionServiceImpl implements TransactionService {
  constructor(
    private readonly db: SQL,
    private contextProvider: ContextProvider<Conn>,
  ) {}

  async run<T>(fn: () => Promise<T>): Promise<T> {
    return this.db.begin((tx) => this.contextProvider.runWith(tx, fn));
  }
}

リポジトリアダプタ:MySQLのクセは外側に閉じ込める

TodoRepositoryImplTodoエンティティとMySQLドライバの間を繋ぐ責務のみを持ちます。ON DUPLICATE KEY UPDATEなどのMySQL依存はここで完結させ、Domain/Applicationには漏らしません。

// infrastructure/repository/todoRepository.ts(抜粋)
await db`INSERT INTO todos (id, title, description, completed)
VALUES (${data.id}, ${data.title}, ${data.description}, ${data.completed})
ON DUPLICATE KEY UPDATE
  title = ${data.title},
  description = ${data.description},
  completed = ${data.completed}`;

TodoのIDはUUID
v7を生成(uuidパッケージ)し、完了フラグの状態遷移はエンティティ側のメソッドでガードしています。これもNode.js編の設計をそのまま再利用した恩恵です。

Presentation層:Honoのベストプラクティスに沿う

Honoはコントローラオブジェクトにhandleを生やすスタイルを非推奨としています(Best Practices)。

これはパスパラメータの推論可能性を考慮したものであり、妥当であるものだと判断しました。そこで、各ハンドラはregister(app: Hono)を公開し、内部でapp.getapp.postを定義する方式に統一しました。

// presentation/handler/createTodoHandler.ts(抜粋)
export class CreateTodoHandler {
  constructor(private createTodoUsecase: CreateTodoUsecase) {}

  register = (app: Hono) => {
    app.post(
      "/todos",
      zValidator("json", schema, (result) => {
        if (!result.success) {
          throw new BadRequestError("Invalid request data", result.error);
        }
      }),
      async (c) => {
        const body = await c.req.valid("json");
        const result = await this.createTodoUsecase.execute(body);
        return c.json(
          {
            id: result.todo.id,
            title: result.todo.title,
            description: result.todo.description,
            completed: result.todo.isCompleted,
          },
          201,
        );
      },
    );
  };
}

バリデーションにはzod@hono/zod-validatorを採用し、完了/未完リクエストでは
z.uuid({ version: "v7" })でUUID
v7を検証します。エラーハンドリングはHTTPExceptionを継承したラッパ(BadRequestError,
ConflictErrorなど)に集約し、ミドルウェアでHTTPレスポンスへマッピングします。

Composition Root

src/index.tsでは依存を束ねるだけ。Domain/Applicationの再利用性が高いので、ここで差し替えたのはリポジトリ・トランザクション・ハンドラのインスタンス化だけでした。

テスト戦略:SQLite+Mockで品質を担保

シリーズ通りレイヤごとにテストを用意していますが、現時点ではTestcontainersがBunに対応していません。そのためインフラ層のテストは次のように分けました。

  • MySQL依存が薄い箇所(トランザクション、ALSの挙動):
    bun:testSQL(":memory:")でSQLiteドライバを使う。コミット/ロールバックや接続のスコープを検証している(infrastructure/service/transactionService.test.ts)。
  • MySQL固有の部分(ON DUPLICATE KEY UPDATEなど):
    TodoRepositoryのテストではSQL呼び出しをモック化し、クエリ文字列・バインド値を検証する形で品質を担保。
  • Application/Presentation層:
    Node.js編と同じユニットテストをほぼそのまま転用でき、Honoのハンドラはapp.request()を使ったHTTPレベルのテストで動作確認している。

Testcontainersがサポートされ次第、MySQL実機との結合テストも導入する予定です。現状でも「トランザクション境界が守られているか」「HTTPレスポンスが正しく整形されるか」は十分に自動化できています。

動かしてみる

依存インストールはBunだけで完結します。

cd backend_bun
bun install
bun run dev

compose.ymlではbackend_bunサービスを定義しているので、シリーズ共通のdocker compose up -d db bunでMySQLと一緒に立ち上げられます。

エンドポイントは他言語編と同じです。

  • GET /todos
  • GET /todos/:id
  • POST /todos
  • PUT /todos/:id
  • DELETE /todos/:id
  • PUT /todos/:id/complete
  • PUT /todos/:id/uncomplete

まとめ

  • Domain/Applicationをフレームワーク非依存で書いていたおかげで、Bun+Honoでも実装をそのまま再利用できた。
  • Bun純正のSQLドライバとAsyncLocalStorageを組み合わせ、トランザクション境界をユースケースに委ねる構造を維持。
  • Honoのベストプラクティスに合わせてregisterパターンでルーティングを記述し、zodで入力をバリデーション。
  • Testcontainers未対応という制約はSQLite+モックで乗り切り、重要な不変条件とHTTPレスポンスは自動テストで担保。

シリーズ通して掲げてきた「ドメインは技術詳細を知らない」原則を貫いた結果、ランタイムやフレームワークが変わっても最小限の差分でDDD+クリーンアーキテクチャを展開できることを実感できました。

GitHubで編集を提案

Discussion