はじめに
こんにちは、株式会社bestieeでエンジニアをしているyuuuminです。
私たちは「ベストティーチ」という家庭教師サービスを運営しています。保護者向け・講師向け・管理者向けの3つのWebアプリケーションをLINE上で提供しています。
主な機能として、認証、講師の検索、授業依頼〜完了までの一連のフロー、チャット機能(画像・PDF添付対応)、LINE通知・リマインド機能、Stripeによる自動引き落とし・請求システム、レビュー・評価システムなどを提供しています。
開発は少人数のチームで行っており、大部分を私が担当しています。この記事では、限られたリソースでいかに効率的な開発環境を構築したかをお話ししていきます。
技術選定の背景
サービス立ち上げ時、スタートアップの初期段階で最も重要だったのは、初期コストを抑えながら、将来的な拡張性を見越した技術スタックを選ぶことでした。
少人数のチームで開発するため、コンテキストスイッチを最小化し、開発速度を上げる必要がありました。また、バックエンドもフロントエンドもTypeScriptで統一することで、全体の開発効率を高めたいと考えていました。
様々なサービスを検討した結果、Cloudflare Workers + Hono + TypeScriptモノレポという組み合わせにたどり着きました。
なぜ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駆動開発との相性が抜群でした。
デザインシステムと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まで自動生成されます。
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の設定など、様々な課題に直面しました。
一時期は私が以前書いた記事で紹介した方法も試しました。
この方法により、実際に開発環境では動作するようになりましたが、本番運用を見据えた時、複雑なインフラアーキテクチャは将来的な保守性に不安がありました。
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スキーマを自動生成します。
{
"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駆動開発により、開発体験が劇的に向上しました。
ベストティーチは今後も急速に成長していきます。この技術スタックがどこまでスケールするか、引き続き検証していきたいと思います。
Views: 0