日曜日, 8月 31, 2025
日曜日, 8月 31, 2025
- Advertisment -
ホームニューステックニュースHono × Cloudflare で実現する最高のDeveloper Experience

Hono × Cloudflare で実現する最高のDeveloper Experience



はじめに

こんにちは、株式会社bestieeでエンジニアをしているyuuuminです。

私たちは「ベストティーチ」という家庭教師サービスを運営しています。保護者向け・講師向け・管理者向けの3つのWebアプリケーションをLINE上で提供しています。

https://best-teach.jp/

主な機能として、認証、講師の検索、授業依頼〜完了までの一連のフロー、チャット機能(画像・PDF添付対応)、LINE通知・リマインド機能、Stripeによる自動引き落とし・請求システム、レビュー・評価システムなどを提供しています。

開発は少人数のチームで行っており、大部分を私が担当しています。この記事では、限られたリソースでいかに効率的な開発環境を構築したかをお話ししていきます。

技術選定の背景

サービス立ち上げ時、スタートアップの初期段階で最も重要だったのは、初期コストを抑えながら、将来的な拡張性を見越した技術スタックを選ぶことでした。

少人数のチームで開発するため、コンテキストスイッチを最小化し、開発速度を上げる必要がありました。また、バックエンドもフロントエンドもTypeScriptで統一することで、全体の開発効率を高めたいと考えていました。

様々なサービスを検討した結果、Cloudflare Workers + Hono + TypeScriptモノレポという組み合わせにたどり着きました。

https://hono.dev/

https://www.cloudflare.com/

なぜCloudflare Workersなのか

当初、Vercel、AWS、GCPなども検討しました。

Vercelは商用利用にProプラン($20/月)が必要で、ファンクションの実行時間や帯域幅にも制限があります。他のクラウドサービスも、それぞれインフラの設定・管理の複雑さやコスト面での課題がありました。

Cloudflare Workersが魅力的だったのは、無料枠から始められ、必要に応じてPaid Plan($5/月)へ移行でき、さらに従量課金制でスケールできる点でした。世界中のデータセンターで実行されるため、ユーザーがどこにいても低レイテンシでアクセスできます。

特に印象的だったのは、Workers、KV、R2、Queuesといった必要な機能が同じプラットフォームで提供されていることです。AWSやGCPでも同様のサービスは提供されていますが、Cloudflareはよりシンプルで、設定も簡単でした。

ただし、Cloudflare WorkersにはEdge Runtimeの制約があります。Node.jsの一部のAPI(fs、child_processなど)が使えず、ネイティブバイナリを含むライブラリも動作しません。実際に利用を予定していたFirebaseなどのライブラリが動作せず苦労しました。

HonoとTypeScriptで統一した理由

開発を開始した当時は、Honoが流行り始めていたタイミングでした。「Edge Runtimeで動く」ことで注目を集めており、Cloudflare Workersとの相性の良さが話題になっていました。

Honoを選んだ理由は、薄く依存できるという点が大きかったです。フレームワーク自体が軽量で、将来的に他のフレームワークへ移行する必要が出てきても、ロックインが少ないという安心感がありました。

さらに、TypeScriptの型定義が充実しており、開発体験が素晴らしかったこと。そして、zod-openapiとの統合により、OpenAPIスキーマから型を自動生成できることが魅力的でした。

私たちはHonoをライトに使いつつ、Cloudflareとの相性の良さを最大限活かす形で活用しています

Hono × Drizzleで実現した高DXアーキテクチャ

実際のAPIファイルを見ていただくと、私たちの独自アーキテクチャの威力が分かります。


import { schema } from '@best-teach-web/schema/drizzle'
import { z } from '@hono/zod-openapi'
import { createSelectSchema } from 'drizzle-zod'

const app = newApp()


const GetTeacherWeeklySchedulesResponseSchema = createResponseSchema(
  z.array(
    createSelectSchema(schema.teacherWeeklySchedule)
      .pick({
        dayOfWeek: true,
        status: true,
      })
      .openapi('GetTeacherWeeklySchedulesResponse')
  )
)


const route = createApiRoute({
  method: 'get',
  path: '/api/teacher/v1/teachers/weekly-schedules',
  operationId: 'getTeacherWeeklySchedules',  
  responses: {
    [HTTP_STATUS.OK]: {
      description: HTTP_STATUS_MESSAGE[HTTP_STATUS.OK],
      content: {
        'application/json': {
          schema: GetTeacherWeeklySchedulesResponseSchema,
        },
      },
    },
  },
})


app.openapi(route, async (c) => {
  
  const authService = new TeacherAuthService(c)
  const teacherId = await authService.getId(c.var.provider, c.var.providerAccountId)

  const weeklyScheduleService = new WeeklyScheduleService(c.var.db)
  const weeklySchedules = await weeklyScheduleService.getTeacherWeeklySchedules(teacherId)

  
  return ok(c, {
    data: weeklySchedules,
  })
})

型安全性を実現する仕組み

Drizzle × zodによる型の自動導出

DBスキーマから直接zodスキーマを生成できるのが最大の特徴です。


const TeacherSchema = createSelectSchema(schema.teacher)
  .pick({
    id: true,
    firstName: true,
    lastName: true,
    email: true,
    
  })
  .extend({
    school: createSelectSchema(schema.school).pick({
      id: true,
      name: true,
    }),
  })

DBスキーマの変更が即座にAPIレスポンスの型に反映されます。型と実際のAPIレスポンスが一致していなければ、即座にTypeScriptのエラーとして検知されます。

c.varによるコンテキスト管理

Honoのc.varを活用して、ミドルウェアで設定した値をハンドラーで利用できます。


app.use('*', authMiddleware)


app.openapi(route, async (c) => {
  const userId = c.var.userId  
  const db = c.var.db          
  const env = c.var.env        
})

フロントエンドアーキテクチャ

shadcn/ui × Tailwind CSS

フロントエンドフレームワークにはNext.jsを採用していますが、正直なところNext.jsの強みを十分に活かせているとは言えません。

それよりも、UIコンポーネントライブラリとして採用したshadcn/uiが大きな成功でした。shadcn/uiの最大の特徴は、コンポーネントのコードを直接プロジェクトにコピーして使うという点です。これにより、1ファイルにまとまったコンポーネントをAIに読み込ませやすく、AI駆動開発との相性が抜群でした。

https://ui.shadcn.com/

デザインシステムとTailwindの相性

優秀なデザイナーがデザインシステムを構築してくれたことも大きかったです。Tailwind CSSは、デザインシステムとの相性が抜群でした。

デザイナーがFigmaで定義したデザイントークン(カラー、タイポグラフィ、スペーシングなど)を、そのままTailwindの設定ファイルに落とし込めます。これにより、デザインと実装の間にギャップが生まれません。


export const TEXT_STYLES = {
  
  system: {
    'h1-emphasized': { fontSize: '20px', fontWeight: '600' },
    'body': { fontSize: '14px', fontWeight: '400' },
    'caption': { fontSize: '12px', fontWeight: '400' },
  },
}


colors: {
  'color-gray': {
    25: 'rgb(var(--gray-25) / )',
    50: 'rgb(var(--gray-50) / )',
    
  },
  'color-blue': {
    dark: 'rgb(var(--blue-dark) / )',
    strong: 'rgb(var(--blue-strong) / )',
    light: 'rgb(var(--blue-light) / )',
    pale: 'rgb(var(--blue-pale) / )',
  },
  
  icon: {
    primary: 'rgb(var(--icon-primary) / )',
    secondary: 'rgb(var(--icon-secondary) / )',
  }
}

OrvalによるAPIクライアント自動生成

Orvalは、OpenAPIスキーマからTypeScriptのAPIクライアントを自動生成するツールです。私たちの構成では、TanStack QueryのHooksまで自動生成されます。

https://orval.dev/

Orvalの設定


export default {
  teacher: {
    input: {
      target: 'http://localhost:5000/openapi.json',
    },
    output: {
      client: 'react-query',
      override: {
        mutator: {
          path: './custom-instance.ts',
          name: 'customInstance'
        }
      }
    }
  }
}

生成されるコード


export function useGetTeacherWeeklySchedules
  TData = AwaitedReturnTypetypeof getTeacherWeeklySchedules>>,
  TError = ErrorResponse | ErrorResponse | ErrorResponse | ErrorResponse | ErrorResponse,
>(
  options: {
    query: Partial
      UseQueryOptionsAwaitedReturnTypetypeof getTeacherWeeklySchedules>>, TError, TData>
    > &
      Pick
        DefinedInitialDataOptions
          AwaitedReturnTypetypeof getTeacherWeeklySchedules>>,
          TError,
          AwaitedReturnTypetypeof getTeacherWeeklySchedules>>
        >,
        'initialData'
      >
    request?: SecondParametertypeof teacherClient>
  },
  queryClient?: QueryClient,
): DefinedUseQueryResultTData, TError> & { queryKey: DataTagQueryKey, TData, TError> }


const { data, isLoading } = useGetTeacherWeeklySchedules()

Orvalが優れているのは、単にAPIクライアントを生成するだけでなく、TanStack QueryのHooksまで自動生成してくれる点です。APIの型が自動的にフロントエンドに伝播し、完全な型安全性が保証されます。

さらに、axios instanceやエラーハンドリングをカスタマイズできるため、認証トークンの自動付与やエラー時のリトライ処理なども統一的に実装できます。

Prismaから Drizzleへの移行

開発初期、ORMとしてPrismaを採用し、Cloudflare Workersで動かすための様々な方法を模索しました。

バンドルサイズの問題やPrisma Data Proxyの設定など、様々な課題に直面しました。

一時期は私が以前書いた記事で紹介した方法も試しました。

https://zenn.dev/yu_3in/articles/c3787b3fc29546

この方法により、実際に開発環境では動作するようになりましたが、本番運用を見据えた時、複雑なインフラアーキテクチャは将来的な保守性に不安がありました。
Prisma Data ProxyやAccelerateのような追加レイヤーが必要になることで、システム全体の複雑性が増し、障害ポイントも増えてしまいます。

そこで、Cloudflare Workersでネイティブに動作するDrizzleへの移行を決断しました。

ただし、Prismaのスキーマ定義の書き心地は個人的に捨てがたく、既にPrismaで書いていたスキーマをDrizzleに移行するコストも高かったため、以下の方法を採用しました。

// Prismaでスキーマを定義
model Schedule {
  id         String   @id @default(cuid())
  teacherId  String
  date       DateTime
  startTime  String
  endTime    String
  status     ScheduleStatus

  @@index([teacherId, date])
}

drizzle-prisma-generatorを使って、PrismaスキーマからDrizzleスキーマを自動生成します。

https://github.com/drizzle-team/drizzle-prisma-generator


{
  "scripts": {
    "db:generate": "prisma generate && drizzle-prisma-generator"
  }
}

生成されたDrizzleスキーマは、createSelectSchemaと組み合わせることで、API定義で直接使えるzodスキーマになります。これにより、DB → ORM → API → クライアントまで完全に型安全な開発が実現できました。

モノレポ構成の威力

モノレポを採用したことで、開発効率が劇的に向上しました。

best-teach-web/
├── apps/
│   ├── parent-web/    # 保護者向け
│   ├── teacher-web/   # 講師向け
│   ├── admin-web/     # 管理者向け
│   └── server/        # API (Hono)
├── packages/
│   ├── api/           # 自動生成されたクライアント
│   ├── ui/            # 共通コンポーネント
│   └── schema/        # DB定義
└── CLAUDE.md          # 開発ガイドライン

コンポーネントの共通化

parent-webとteacher-webには、予約カレンダーやレッスン詳細画面など、似たようなUIが多く存在します。これらをpackages/uiに共通コンポーネントとして切り出すことで、一度の実装で両方のアプリに機能を提供できます。

例えば、レッスンステータスを表示するバッジコンポーネントは両アプリで使われていますが、デザインやロジックの変更は一箇所で行うだけで、全体に反映されます。

型の一貫性と即座のフィードバック

型の変更が即座に全体に伝播することの威力は計り知れません。

例えば、レッスンテーブルに新しいカラムを追加すると、その瞬間にサーバー側のAPI実装からフロントエンドのUIコンポーネントまで、関連する全ての箇所で型エラーが発生します。


model lesson {
  id String  (cuid(2)) 
  status LessonStatus 
  startAt DateTime? ("start_at") 
  endAt DateTime? ("end_at") 
  
  parentRating: Int? ("parent_rating"),  
}


AIとの相性の良さ

モノレポの真価は、AIを使った開発で最大限に発揮されます。

単一のリポジトリにすべてのコードが含まれているため、AIは全体のコンテキストを理解した上で、サーバーとUIの両方を同時に修正できます。「新しいAPIエンドポイントを追加して、それを使うUIも実装して」という要求に対して、AIは一貫性のあるコードを生成してくれます。

これは後述するCLAUDE.mdと組み合わせることで、さらに強力な開発支援となります。

CLAUDE.mdによるAI駆動開発

私たちはCLAUDE.mdという開発ガイドラインを整備し、Claude Codeとの協働開発を効率化しています。

CLAUDE.mdの内容例

## APIの実装規約
- 必ず既存の類似APIを参照
- createApiRouteヘルパーを使用
- API変更後は必ず `pnpm orval` を実行

## Drizzle ORMの使い方
- SQLクエリはDrizzle構文で記述
- トランザクションはサービスメソッドに渡す
- Prismaスキーマから自動生成される仕組み

## モノレポでの開発フロー
- 機能開発は server → api → frontend の順
- 型エラーは `pnpm typecheck` で検知
- 共通コンポーネントは packages/ui に配置

AIによる開発支援の実例

CLAUDE.mdがあることで、AIも独自アーキテクチャを理解してコードを生成します。

例えば、新しいAPIを作る際、CLAUDE.mdがない場合、AIは通常のHonoルートを書いてしまい、createApiRouteを使わないことがあります。すると、共通のエラーハンドリングが適用されず、ok()notFound()といった独自のヘルパー関数も使われません。結果として、一貫性のないコードになってしまいます。

また、Drizzleの代わりに生のSQLを書いてしまったり、Orvalの再生成を忘れてしまったりすることもあります。

CLAUDE.mdを整備することで、AIはプロジェクト固有のルールを理解し、一貫性のあるコードを生成してくれるようになりました。

まとめ

TypeScriptモノレポとCloudflareエコシステムの組み合わせは、少人数チームにとって最適な選択でした。月額数千円で3つのWebアプリケーションを安定運用できています。

何より、HonoとDrizzleによる型安全な開発、Orvalによる自動生成、CLAUDE.mdによるAI駆動開発により、開発体験が劇的に向上しました。

ベストティーチは今後も急速に成長していきます。この技術スタックがどこまでスケールするか、引き続き検証していきたいと思います。



Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -