🔥

少人数で3つのWebアプリを支える技術 - 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ファイルを見ていただくと、私たちの独自アーキテクチャの威力が分かります。

// apps/server/src/routes/api/teacher/v1/weekly-schedules/getTeacherWeeklySchedules.ts
import { schema } from '@best-teach-web/schema/drizzle'
import { z } from '@hono/zod-openapi'
import { createSelectSchema } from 'drizzle-zod'

const app = newApp()

// Drizzleスキーマから型安全にレスポンススキーマを生成
const GetTeacherWeeklySchedulesResponseSchema = createResponseSchema(
  z.array(
    createSelectSchema(schema.teacherWeeklySchedule)
      .pick({
        dayOfWeek: true,
        status: true,
      })
      .openapi('GetTeacherWeeklySchedulesResponse')
  )
)

// 独自のcreateApiRouteでルート定義
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) => {
  // c.varで認証情報やDBインスタンスを引き回す
  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)

  // ok関数でレスポンスを返す(型チェックされる)
  return ok(c, {
    data: weeklySchedules,
  })
})

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

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

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

// DBスキーマから直接zodスキーマを生成
const TeacherSchema = createSelectSchema(schema.teacher)
  .pick({
    id: true,
    firstName: true,
    lastName: true,
    email: true,
    // 必要なフィールドだけpick
  })
  .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          // DBインスタンス
  const env = c.var.env        // Cloudflare環境変数
})

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

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の設定ファイルに落とし込めます。これにより、デザインと実装の間にギャップが生まれません。

// packages/tailwind-config/tailwind.config.ts
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) / <alpha-value>)',
    50: 'rgb(var(--gray-50) / <alpha-value>)',
    // ... 13段階のグレースケール
  },
  'color-blue': {
    dark: 'rgb(var(--blue-dark) / <alpha-value>)',
    strong: 'rgb(var(--blue-strong) / <alpha-value>)',
    light: 'rgb(var(--blue-light) / <alpha-value>)',
    pale: 'rgb(var(--blue-pale) / <alpha-value>)',
  },
  // セマンティックトークン
  icon: {
    primary: 'rgb(var(--icon-primary) / <alpha-value>)',
    secondary: 'rgb(var(--icon-secondary) / <alpha-value>)',
  }
}

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

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

https://orval.dev/

Orvalの設定

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

生成されるコード

// packages/api/src/teacher/v1/weekly-schedules.ts (自動生成)
export function useGetTeacherWeeklySchedules<
  TData = Awaited<ReturnType<typeof getTeacherWeeklySchedules>>,
  TError = ErrorResponse | ErrorResponse | ErrorResponse | ErrorResponse | ErrorResponse,
>(
  options: {
    query: Partial<
      UseQueryOptions<Awaited<ReturnType<typeof getTeacherWeeklySchedules>>, TError, TData>
    > &
      Pick<
        DefinedInitialDataOptions<
          Awaited<ReturnType<typeof getTeacherWeeklySchedules>>,
          TError,
          Awaited<ReturnType<typeof getTeacherWeeklySchedules>>
        >,
        'initialData'
      >
    request?: SecondParameter<typeof teacherClient>
  },
  queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, 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

// package.json
{
  "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コンポーネントまで、関連する全ての箇所で型エラーが発生します。

// Drizzleスキーマのレッスンテーブル
model lesson {
  id String  (cuid(2)) // 授業ID
  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駆動開発により、開発体験が劇的に向上しました。

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

GitHubで編集を提案

Discussion