💎

モノレポ+Hono+OpenAPIスキーマ駆動(Orval)+Drizzleは最高

に公開
5

はじめに

最近の開発で、モノレポ上に Hono のバックエンドと Next.js のフロントを抱えたアプリを作っている。そこで一周回って確信したのが、「Hono + OpenAPIスキーマ駆動 (Orval) + Drizzle」の組み合わせがとにかく快適ということ。この記事では、その理由と気持ちよかった設計ポイントをまとめる。

ポイントは以下の3つ。

  1. Honoがプレゼンテーション層の薄皮に徹し、クリーンアーキテクチャを素直に表現できる。
  2. OpenAPI → Orval → Zod → TypeScript 型 というパイプラインが、“仕様=型=バリデーション” を保証してくれる。
  3. Drizzleでリッチドメインをそのまま永続化しつつ model.Team などのOpenAPI生成型を controller / route でも共通利用できるのが神。

モノレポ構成のざっくり像

apps/
  backend/    # Hono API。presentation/usecase/domain/infrastructureを階層分け
  frontend/   # Next.js 15
packages/
  openapi/    # OpenAPI仕様とOrval生成物(Zodスキーマ+型)

バックエンドは presentation -> usecase -> domain -> infrastructure の4層構成。各層をYarn Berryのworkspaceで束ね、共通のOpenAPIパッケージをバック/フロントどちらからも @application/openapi として参照している。


OpenAPIスキーマからZod・型を生成するOrvalパイプライン

  1. packages/openapi/openapi.yaml が唯一の仕様書。
  2. yarn generate:api を叩くと、orval.config.ts の設定で以下が生成される。
    • api.ts: OrvalがZodスキーマを生成。queryパラメータは coerce 設定でnumber/boolean文字列も安全に処理。
    • model/*: OpenAPIモデルごとの型定義。export * as model from './model' でまとめてimportできる。
  3. 生成後に prettier --write が自動で走り、各層で差分が見やすい。

要するに「設計が動的に崩れない」。仕様を1箇所直せばZod・型が再生成され、IDE補完が即座に反映される。ドキュメントと実装の整合性をチケットで管理しなくて済む。


Hono + クリーンアーキテクチャの気持ちよさ

apps/backend/src/presentation/team/team.routes.ts に全依存関係を集約し、DIの「配線図」と「Honoルート定義」を同居させている。

  • リポジトリやDomain ServiceをnewしてUseCaseに渡す。
  • Controllerを生成し、Honoのルートにぶら下げる。
const teamRoutes = new Hono()
teamRoutes.post('/', zValidator('json', createTeamBody), (c) =>
  teamController.createTeam(c)
)

HonoのルーターはただControllerを呼ぶだけ。Contextから authzContext を取り出し、UseCaseに投げる単純さが嬉しい。HTTP固有の責務とドメインロジックの境界が明確になり、テストも書きやすい。


「ルートでzValidator→Controllerでmodel.*」が神

このプロジェクトで一番テンションが上がったのがここ。Route側で zValidator('json', createTeamBody) を挟むと、Controllerでは z.infer<typeof createTeamBody> をそのまま型として扱える。
追記

const body: z.infer<typeof createTeamBody> = await c.req.json()
const team = await this.createTeamUseCase.execute(authzContext, {
  name: body.name,
  description: body.description,
  color: body.color,
})
return c.json({ team }, 201)

さらにUseCaseの戻り値は Promise<model.Team> で統一。model.Team はOpenAPIスキーマ由来の型なので、ControllerとRouteどちらでも同じ型を参照できる。「zValidatorでバリデーション済みのbody」と「model.TeamなどのOpenAPI型」が一本に繋がっている感覚が本当に神。型の再発明やDTO変換用の型定義が不要で、修正範囲も最小に抑えられる。


Drizzleでリッチドメインをそのまま永続化

DrizzleTeamRepository では Drizzle ORM の select / insert ... onDuplicateKeyUpdate を使い、DBレコードを Team.reconstruct(...) でリッチエンティティに戻している。エンティティは以下の3系統の変換を実装:

  • toOpenAPI(): model.Team … Presentation層に返すデータ。
  • toPersistence() … Drizzleに渡すための構造体。
  • ドメイン内メソッド (update, delete) … ビジネスルールを内包。

おかげで永続化とレスポンス変換の責務がぶれない。UseCaseは「権限チェック→エンティティ操作→Repository.save→model.Teamに変換」という黄金ルートだけ考えればいい。


フロントも同じmodel.*で補完される

Next.js側のAPIクライアント (apps/frontend/src/lib/api/endpoints/teams.ts) も @application/openapi から model.Team, model.CreateTeamRequest をそのままimport。

export async function createTeam(
  body: model.CreateTeamRequest
): Promise<{ team: model.Team }> {
  return post<{ team: model.Team }, model.CreateTeamRequest>('/teams', body)
}

フォーム定義・React Hook・SWR Queryなど、アプリ全体で「model.*」の補完が効くので、誤解が起きたらまずOpenAPIに立ち戻れば済む。APIの破壊的変更をしても、型エラーが編集時点で真っ赤になるのが心強い。


DX面のちょっとした工夫

  • teamRoutes.get('/', zValidator('query', listTeamsQueryParams), ...) のように、クエリパラメータもZodでcoerceする。?limit=100?includeArchived=true を全部文字列で受け取りつつ、実行時バリデーションも同時にこなせる。
  • Loggerを各層に用意 (PresentationLogger, UseCaseLogger, InfrastructureLogger) しており、Honoコンテキストの操作ログからDrizzleのSQLエラーまで追跡できる。
  • UseCase単位でDIされているため、E2EではOrvalが生成したfetchクライアント(apps/backend/src/tests/generated/...)を使ってAPIを叩き、スキーマとの整合性を検証している。

まとめ

  • Hono はHTTPの入り口だけを担当させ、Controller→UseCase→Domain→Repositoryの動線をクリーンに保てる。
  • OpenAPI + Orval + zValidator によって、「仕様・型・実行時バリデーション」が1箇所で管理され、controller / route 両方で model.Team などの型を使える体験が最高。
  • Drizzle がリッチドメインとの橋渡し役を担い、SQLを書かずともビジネスルールを壊さない永続化ができる。
  • フロント/バック/テストが同じ model.* を共有することで、機能追加やリファクタ時のコミュニケーションコストが劇的に下がる。

Hono+OpenAPIスキーマ駆動(Orval)+Drizzleをまだ触っていない人は、最小アプリでもいいので一度この回路を組んでみてほしい。controllerやrouteでzValidatorを噛ませながら model.Team をそのまま扱える瞬間の快感を味わうと、もう別のスタックに戻れなくなるはず。


追記 (2025-11-27): factory handlerベースへ舵を切った話

記事を公開したあとに「JSONから値を取り出すとき、c.req.valid('json') ではなく c.req.json() をあえて使っている理由は?」というコメントをもらい、Hono公式のベストプラクティスを改めて読み返した。公式ガイドfactory.handler を導入したPRでは、「RailsライクなControllerクラスを積み上げず、ルーターから直接DI済みのhandlerを返す」ことが推奨されていたので、プロジェクト全体をそちらに寄せた。

  • すべてのRouteは createFactory().createHandlers(...) で生成したhandlerに置き換えた。apps/backend/src/presentation/team/team.handlers.ts のように依存関係を関数の引数に閉じ込めることで、Honoが想定する“関数=handler”スタイルに沿いつつDIも担保している。
const factory = createFactory()

export const createTeamHandlers = (
  createTeamUseCase: CreateTeamUseCase,
  // ...中略...
  logger: PresentationLogger
) => ({
  createTeam: factory.createHandlers(
    zValidator('json', createTeamBody),
    // paramが必要な場合は以下のようにできる
    // zValidator('param', createTeamParam),
    async (c) => {
      // const param = c.req.valid('param')
      const body = c.req.valid('json')
      const team = await createTeamUseCase.execute(c.get('authzContext'), {
        name: body.name,
        description: body.description,
        color: body.color,
      })
      return c.json({ team }, 201)
    }
  ),
})
  • 各handlerは zValidator を必ず最初に噛ませ、コメントで突っ込まれた c.req.json() はやめて c.req.valid('json' | 'param' | 'query') だけを使う構成に統一。これで「バリデーション済み=型安全な値」だけが下流へ流れる状態になった。
  • ここで食わせている createTeamBody, getTeamDetailsParams, listTeamsQueryParams などのZodスキーマは、全部 @application/openapi がOrval経由でOpenAPIから生成したものなので、param/body/queryの仕様がソースコードと自動同期するようになった。
    CreateTeamRequest:
      type: object
      required:
        - name
      properties:
        name:
          type: string
        description:
          type: string
        color:
          type: string
import {
  createTeamBody,
  createTeamParam,
} from '@application/openapi'
  • Route定義(例: team.routes.ts)では const teamHandlers = createTeamHandlers(...) で一度だけハンドラーファクトリを呼び出し、teamRoutes.post('/', ...teamHandlers.createTeam) のように薄く繋ぐだけ。Rails的なControllerクラスを経由しないのでbundleもシンプルになり、Hono公式が推しているtree-shakingメリットも享受できるようになった。
const handlers = createTeamHandlers(
  // ...中略...
  createTeamUseCase,
  // ...中略...
  logger
)

// ルート定義
const teamRoutes = new Hono()

teamRoutes.post('/', ...handlers.createTeam)

export { teamRoutes }

もともと「Controllerに寄せるとUseCase境界が見やすい」と思っていたが、Honoに寄り添うならfactory handlerパターンのほうが素直だった。しかも、Honoがプレゼンテーション層の薄皮に徹していたためとても変更が楽だった。コメントをきっかけに設計を見直すと、新しいベストプラクティスにもすんなり乗り換えられると実感したので記録しておく。

Discussion

FatRicePaddyyyyFatRicePaddyyyy

Hono だと、Hono OpenAPIでOpenAPI docsを生成し、Hono RPCで型安全に呼び出しができるのでこちらも便利ですよ...!!!

たくみたくみ

コメントありがとうございます!!
Hono OpenAPI → OpenAPI docsという流れとRPCが要件的にあてまらず使えないんですよね。。。もし使えたら仕様書も書かなくて済むので本当に楽なんですけどね〜!!!笑

ありあなありあな

jsonから値を取り出す際,c.req.valid("json")ではなくc.req.json()を使っているのには何か理由があるのでしょうか?model.*として補完されないとかですかね?

たくみたくみ

はい!おっしゃる通りで、現在の実装では ルート定義とコントローラーを分離して管理しているため、Context の型情報が失われちゃってるので、c.req.json()と型アノテーションを使ってます!
(*コントローラークラスに分離する(DI構造にする)と、Context が素の Context 型として渡されるため、c.req.valid('json') の戻り値型が never になります)

実際 c.req.json() は再度JSONをパースするため、若干非効率でかつ、型注釈が手動なので、routesとcontrollerで異なるスキーマを指定してしまうリスクあるのが懸念なんですよね〜

たくみたくみ

追記で書いたようにすれば、わざわざ型注釈しなくて済みます!!