Drizzle ORM + Cloudflare D1:Prismaより99%小さいバンドル

なぜエッジでORMが問題になるのか
Cloudflare WorkersでPrismaを使用していて、コールドスタート(Cold Start)時間が異常に遅くなった経験はありませんか?理由は単純です。Prismaのバンドルサイズは1.6 MBもあります。Workersのスクリプトサイズ制限が10 MBとはいえ、1.6 MBのORMを読み込むとコールドスタートで数 rational/hundreds ミリ秒が消失してしまいます。
Drizzle ORMは真逆の哲学で設計されています。バンドルサイズはわずか12.2 KB(min+gzip)。Prismaと比較して99.2%小型化されています。コード生成ステップがなく、Rustエンジンも使用していません。純粋なTypeScriptでSQLをラップしているため、エッジランタイムで理想的に動作します。
この記事では、Drizzle ORMとCloudflare D1を組み合わせて完全な型安全エッジデータベースアーキテクチャを構築する方法をステップバイステップで解説します。Honoを使用したREST APIの作成、drizzle-kitによるマイグレーション管理、ローカル開発環境のセットアップまで網羅します。
Drizzle ORM vs Prisma: エッジ環境の比較
実際のプロダクション環境で両方のORMを比較すると、その違いは明確です。
| 項目 | Drizzle ORM | Prisma 7+ |
|---|---|---|
| バンドルサイズ (min+gzip) | 12.2 KB | 1.6 MB |
| Cold Startへの影響 | 最小 (~50-100ms) | 低い (~80-150ms) |
| エッジランタイムサポート | First-class | v7からサポート |
| コード生成ステップ | なし | あり (prisma generate) |
| 型推論方式 | TypeScript直接推論 | 生成されたクライアント |
| SQL制御レベル | 高い (SQL-first) | 中間 (抽象化) |
| D1公式ドライバー | drizzle-orm/d1 |
なし (コミュニティ) |
| 学習曲線 | 中間 | 低い |
Prisma 7がRustエンジンを廃止し、純粋なTypeScript/WASMに移行したのは事実です。しかし、バンドルサイズには依然として130倍もの差があります。Workersのようにリクエストごとにコールドスタートが発生し得る環境では、この違いが決定的な差となります。
Cloudflare D1とは
D1はCloudflareのサーバーレスSQLiteデータベースです。Workersバインディングを通じて直接アクセスするため、別途TCP接続のオーバーヘッドが発生しません。グローバル読み取りレプリカをサポートし、無料プランでも1日あたり500万件のクエリを処理できます。
Cloudflare Workers リクエスト
│
▼
D1 Binding (env.DB)
│ (同一データセンター内のIPC)
▼
SQLite データベース
(D1 サーバーレスインスタンス)
重要なポイントは、HTTPリクエストなしでバインディング経由でD1にアクセスする点です。レイテンシは外部DB接続の10分の1レベルに抑えられます。
プロジェクトの初期設定
1. Hono + Drizzle + D1 スキャフォールディング
# Hono Worker テンプレートの作成
npm create cloudflare@latest my-d1-app -- --template hono
cd my-d1-app
# Drizzle のインストール
npm install drizzle-orm
npm install -D drizzle-kit
2. D1 データベースの作成
# D1 データベースの作成
npx wrangler d1 create my-app-db
# 出力例:
# [[d1_databases]]
# binding = "DB"
# database_name = "my-app-db"
# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
3. wrangler.jsonc の設定
// wrangler.jsonc
{
"name": "my-d1-app",
"main": "src/index.ts",
"compatibility_date": "2025-01-01",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app-db",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"migrations_dir": "drizzle/migrations"
}
]
}
4. drizzle.config.ts の設定
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./src/db/schema.ts",
out: "./drizzle/migrations",
driver: "d1-http",
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
token: process.env.CLOUDFLARE_D1_TOKEN!,
},
});
ローカル開発のヒント: ローカル環境では
wrangler d1 migrations applyを使用するため、drizzle.config.tsのdriverとdbCredentialsはプロダクションのリモートマイグレーション専用となります。
スキーマ定義:TypeScript-First
Drizzleの最大の強みは、スキーマ定義と型推論が一体化している点です。 prisma generate のような別途のステップを実行する必要はありません。
// src/db/schema.ts
import {
sqliteTable,
text,
integer,
real,
blob,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
// ユーザーテーブル
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
email: text("email").notNull().unique(),
name: text("name").notNull(),
role: text("role", { enum: ["admin", "user", "guest"] })
.notNull()
.default("user"),
createdAt: text("created_at")
.notNull()
.default(sql`(datetime('now'))`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(datetime('now'))`),
});
// 記事テーブル
export const posts = sqliteTable("posts", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
content: text("content").notNull(),
authorId: integer("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
published: integer("published", { mode: "boolean" }).default(false),
viewCount: integer("view_count").default(0),
publishedAt: text("published_at"),
createdAt: text("created_at")
.notNull()
.default(sql`(datetime('now'))`),
});
// タグテーブル (M:N関係)
export const tags = sqliteTable("tags", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique(),
slug: text("slug").notNull().unique(),
});
export const postTags = sqliteTable("post_tags", {
postId: integer("post_id")
.notNull()
.references(() => posts.id, { onDelete: "cascade" }),
tagId: integer("tag_id")
.notNull()
.references(() => tags.id, { onDelete: "cascade" }),
});
// 型推論 — コード生成なしで即座に使用可能
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
export type Tag = typeof tags.$inferSelect;
Prismaとの決定的な違いは、 prisma generate を実行する必要がないことです。スキーマファイル自体がTypeScript型の情報源となります。IDEの自動補完も即座に機能します。
マイグレーションワークフロー
SQLマイグレーションファイルの生成
npx drizzle-kit generate
このコマンドにより、 drizzle/migrations/ フォルダ内にSQLファイルが生成されます:
-- drizzle/migrations/0000_initial.sql
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`email` text NOT NULL,
`name` text NOT NULL,
`role` text DEFAULT 'user' NOT NULL,
`created_at` text DEFAULT (datetime('now')) NOT NULL,
`updated_at` text DEFAULT (datetime('now')) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
--> statement-breakpoint
CREATE TABLE `posts` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`title` text NOT NULL,
`slug` text NOT NULL,
`content` text NOT NULL,
`author_id` integer NOT NULL,
`published` integer DEFAULT false,
`view_count` integer DEFAULT 0,
`published_at` text,
`created_at` text DEFAULT (datetime('now')) NOT NULL,
FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
マイグレーションの適用
# ローカルD1に適用 (開発環境)
npx wrangler d1 migrations apply DB --local
# プロダクションD1に適用
npx wrangler d1 migrations apply DB --remote
Wranglerが d1_migrations テーブルで適用履歴を管理するため、重複適用の心配はありません。
スキーマ変更の例
# スキーマ修正後に新しいマイグレーションを生成
npx drizzle-kit generate
# 生成されたマイグレーションを確認して適用
npx wrangler d1 migrations apply DB --local
Workers + Hono 統合
DBクライアントの初期化
// src/db/client.ts
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export type Env = {
DB: D1Database;
};
export function createDb(d1: D1Database) {
return drizzle(d1, { schema });
}
export type DrizzleDb = ReturnType<typeof createDb>;
Honoアプリの設定
// src/index.ts
import { Hono } from "hono";
import { createDb, type Env } from "./db/client";
import { usersRouter } from "./routes/users";
import { postsRouter } from "./routes/posts";
const app = new Hono<{ Bindings: Env }>();
// DBミドルウェア
app.use("*", async (c, next) => {
c.set("db", createDb(c.env.DB));
await next();
});
app.route("/api/users", usersRouter);
app.route("/api/posts", postsRouter);
app.get("/health", (c) => c.json({ status: "ok" }));
export default app;
Usersルーター — 完全なCRUD
// src/routes/users.ts
import { Hono } from "hono";
import { eq, like, desc, count } from "drizzle-orm";
import { users, posts, type NewUser } from "../db/schema";
import type { DrizzleDb, Env } from "../db/client";
const usersRouter = new Hono<{
Bindings: Env;
Variables: { db: DrizzleDb };
}>();
// GET /api/users — ページネーション + 検索
usersRouter.get("/", async (c) => {
const db = c.get("db");
const { page = "1", limit = "20", q } = c.req.query();
const pageNum = Math.max(1, parseInt(page));
const limitNum = Math.min(100, parseInt(limit));
const offset = (pageNum - 1) * limitNum;
// WHERE条件を動的に構築
const whereClause = q ? like(users.name, `%${q}%`) : undefined;
const [userList, totalResult] = await Promise.all([
db
.select()
.from(users)
.where(whereClause)
.orderBy(desc(users.createdAt))
.limit(limitNum)
.offset(offset)
.all(),
db
.select({ count: count() })
.from(users)
.where(whereClause)
.get(),
]);
return c.json({
data: userList,
pagination: {
page: pageNum,
limit: limitNum,
total: totalResult?.count ?? 0,
hasNext: offset + limitNum < (totalResult?.count ?? 0),
},
});
});
// GET /api/users/:id — ユーザー + 投稿数の取得
usersRouter.get("/:id", async (c) => {
const db = c.get("db");
const id = parseInt(c.req.param("id"));
const user = await db
.select({
id: users.id,
email: users.email,
name: users.name,
role: users.role,
createdAt: users.createdAt,
postCount: count(posts.id),
})
.from(users)
.leftJoin(posts, eq(posts.authorId, users.id))
.where(eq(users.id, id))
.groupBy(users.id)
.get();
if (!user) {
return c.json({ error: "User not found" }, 404);
}
return c.json(user);
});
// POST /api/users — ユーザー作成
usersRouter.post("/", async (c) => {
const db = c.get("db");
const body = await c.req.json<NewUser>();
// メールアドレスの重複チェック
const existing = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, body.email))
.get();
if (existing) {
return c.json({ error: "Email already exists" }, 409);
}
const [newUser] = await db
.insert(users)
.values({
email: body.email,
name: body.name,
role: body.role ?? "user",
})
.returning();
return c.json(newUser, 201);
});
// PATCH /api/users/:id — 一部更新
usersRouter.patch("/:id", async (c) => {
const db = c.get("db");
const id = parseInt(c.req.param("id"));
const body = await c.req.json<Partial<NewUser>>();
const [updated] = await db
.update(users)
.set({
...body,
updatedAt: new Date().toISOString(),
})
.where(eq(users.id, id))
.returning();
if (!updated) {
return c.json({ error: "User not found" }, 404);
}
return c.json(updated);
});
// DELETE /api/users/:id
usersRouter.delete("/:id", async (c) => {
const db = c.get("db");
const id = parseInt(c.req.param("id"));
const [deleted] = await db
.delete(users)
.where(eq(users.id, id))
.returning({ id: users.id });
if (!deleted) {
return c.json({ error: "User not found" }, 404);
}
return c.json({ message: "Deleted", id: deleted.id });
});
export { usersRouter };
高度なクエリ:リレーションの取得
// src/routes/posts.ts — リレーションデータのJOIN
import { Hono } from "hono";
import { eq, and, desc, inArray } from "drizzle-orm";
import { posts, users, tags, postTags } from "../db/schema";
import type { DrizzleDb, Env } from "../db/client";
const postsRouter = new Hono<{
Bindings: Env;
Variables: { db: DrizzleDb };
}>();
// GET /api/posts/:slug — 記事詳細 (ユーザー + タグを含む)
postsRouter.get("/:slug", async (c) => {
const db = c.get("db");
const slug = c.req.param("slug");
// 1. 記事 + 著者情報の取得
const post = await db
.select({
id: posts.id,
title: posts.title,
slug: posts.slug,
content: posts.content,
published: posts.published,
viewCount: posts.viewCount,
publishedAt: posts.publishedAt,
author: {
id: users.id,
name: users.name,
email: users.email,
},
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(and(eq(posts.slug, slug), eq(posts.published, true)))
.get();
if (!post) {
return c.json({ error: "Post not found" }, 404);
}
// 2. タグの取得 (個別クエリ — D1では複雑なサブクエリよりシンプルな2つのクエリの方が高速)
const postTagList = await db
.select({ name: tags.name, slug: tags.slug })
.from(tags)
.innerJoin(postTags, eq(postTags.tagId, tags.id))
.where(eq(postTags.postId, post.id))
.all();
// 3. 閲覧数のインクリメント (fire-and-forget)
c.executionCtx.waitUntil(
db
.update(posts)
.set({ viewCount: post.viewCount + 1 })
.where(eq(posts.id, post.id))
.run()
);
return c.json({ ...post, tags: postTagList });
});
export { postsRouter };
c.executionCtx.waitUntil() を使用した閲覧数のインクリメントは、Workersの重要なパターンです。レスポンスを返却した後もバックグラウンドでDB更新を実行します。
ローカル開発:D1ローカルSQLite連携
# ローカル開発サーバーの起動
npx wrangler dev
# 別のターミナルでマイグレーションを適用
npx wrangler d1 migrations apply DB --local
WranglerはローカルD1の状態を .wrangler/state/v3/d1/ フォルダ内にSQLiteファイルとして保存します。開発サーバーを再起動してもデータは保持されます。
ローカルDBの直接参照:
npx wrangler d1 execute DB --local --command "SELECT * FROM users LIMIT 5;"
ローカルシードデータの挿入:
npx wrangler d1 execute DB --local --file ./drizzle/seed.sql
トランザクションとバッチ処理
D1はサーバーレスSQLiteであるためトランザクションをサポートしていますが、Drizzleを使用することでより安全に利用できます。
// トランザクション: ユーザー + 記事の同時作成
async function createUserWithPost(
db: DrizzleDb,
userData: NewUser,
postData: Omit<NewPost, "authorId">
) {
return await db.transaction(async (tx) => {
const [user] = await tx
.insert(users)
.values(userData)
.returning();
const [post] = await tx
.insert(posts)
.values({ ...postData, authorId: user.id })
.returning();
return { user, post };
});
}
D1 Batch APIを活用したマルチクエリの最適化:
// D1 Batch API — ネットワーク往復1回で複数のクエリを実行
async function batchInsertPosts(db: DrizzleDb, postsData: NewPost[]) {
// Drizzleのbatchヘルパー (D1特化)
const statements = postsData.map((post) =>
db.insert(posts).values(post)
);
// 単一のHTTPリクエストで一括バッチ処理
await db.batch(statements);
}
db.batch() はD1のHTTP APIをわずか1回だけ呼び出して複数のクエリを実行します。 INSERT 1,000件を個別に実行すると1,000回の往復が必要になりますが、バッチ処理なら1回で済みます。
コスト最適化:D1無料プランの制限内で運用する
D1無料プランの制限と、Drizzleによる最適化手法:
| 項目 | 無料制限 | 有料 (Workers Paid) |
|---|---|---|
| 読み取りリクエスト | 1日 500万件 | 無制限 |
| 書き込みリクエスト | 1日 10万件 | 無制限 |
| ストレージ容量 | 5 GB | 25 GB |
| DB数 | 10個 | 無制限 |
クエリ数を削減するDrizzleパターン:
// ❌ N+1 クエリ問題
const postList = await db.select().from(posts).all();
for (const post of postList) {
const author = await db
.select()
.from(users)
.where(eq(users.id, post.authorId))
.get(); // 記事の数だけクエリが発生
}
// ✅ JOINで1回に解決
const postListWithAuthor = await db
.select({
postId: posts.id,
title: posts.title,
authorName: users.name,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.all();
// ✅ 頻繁に参照されるデータ → KVキャッシュでD1読み取りリクエストを削減
export type CacheEnv = {
DB: D1Database;
KV: KVNamespace;
};
async function getPostWithCache(
db: DrizzleDb,
kv: KVNamespace,
slug: string
) {
const cacheKey = `post:${slug}`;
// キャッシュヒット
const cached = await kv.get(cacheKey, "json");
if (cached) return cached;
// キャッシュミス → D1を参照してキャッシュ保存
const post = await db
.select()
.from(posts)
.where(eq(posts.slug, slug))
.get();
if (post) {
// 5分間のTTLキャッシュ
await kv.put(cacheKey, JSON.stringify(post), { expirationTtl: 300 });
}
return post;
}
D1の読み取りリクエスト単価は100万件あたり$0.001です。頻繁に読み取られるデータをKVでキャッシュすることで、D1のコストを90%以上削減できます。
型安全なAPIクライアント:Hono RPCとの結合
Drizzleの型をHono RPC経由でフロントエンドまで伝播させることで、真のEnd-to-End型安全性を実現できます。
// src/index.ts — Hono RPC型の指名エクスポート
import { Hono } from "hono";
import type { User, Post } from "./db/schema";
const app = new Hono<{ Bindings: Env }>();
const routes = app
.get("/api/users", async (c) => {
// ... 実装
return c.json({ data: [] as User[] });
})
.post("/api/users", async (c) => {
// ... 実装
return c.json({} as User, 201);
});
export type AppType = typeof routes;
export default app;
// フロントエンド (React / Next.js)
import { hc } from "hono/client";
import type { AppType } from "../worker/src/index";
const client = hc<AppType>("https://my-app.workers.dev");
// 完全な型推論 — D1スキーマからフロントエンドまで直接連動
const response = await client.api.users.$get();
const { data } = await response.json();
// dataはUser[]型 — IDEの補完が100%動作
スキーマ定義 → Drizzle型 → Honoレスポンス → RPCクライアントへと型が自動的に伝播します。 prisma generate や tRPC の設定、GraphQLスキーマを必要とせず、純粋なTypeScriptのみでEnd-to-Endの型安全性を構築できます。
CI/CD: GitHub Actionsデプロイパイプライン
# .github/workflows/deploy.yml
name: Deploy to Cloudflare Workers
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: TypeScript型チェック
run: npx tsc --noEmit
- name: D1マイグレーション適用 (プロダクション)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: npx wrangler d1 migrations apply DB --remote
- name: Workersデプロイ
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npx wrangler deploy
マイグレーション → デプロイの順序が重要です。マイグレーションを先に適用することで、新しいWorkersコードが新しいスキーマを正しく参照できるようになります。
Drizzle Studio:GUIでDBを可視化
# ローカルD1に接続されたDrizzle Studioの実行
npx drizzle-kit studio
ブラウザで https://local.drizzle.studio を開くと、D1データをGUI上で閲覧・編集できます。Prisma Studioに慣れている方であれば、まったく同じ感覚で操作できます。
パフォーマンスベンチマーク:D1の実際のレイテンシ
WorkersからD1を使用する際の実際の測定値(Cloudflare公式ドキュメント基準):
| クエリタイプ | 平均レイテンシ |
|---|---|
| 単純 SELECT (インデックス) | 1-5ms |
| JOIN クエリ | 5-15ms |
| INSERT | 5-10ms |
| 複雑な集計クエリ | 10-50ms |
| バッチ INSERT (100件) | 10-20ms |
外部PostgreSQL(Neon, Supabaseなど)と比較すると、Workers内部からのD1バインディング呼び出しはTCP接続なしで行われるため、レイテンシが3〜5倍低く抑えられます。TTFB 100ms以下を目指す場合、D1は最善の選択肢です。
D1 vs 他のエッジDBの選定基準
| シナリオ | おすすめ |
|---|---|
| Cloudflare オールインワンスタック | D1 + Drizzle |
| マルチクラウド (Vercel + CF併用) | Turso + Drizzle |
| PostgreSQL機能が必要 (JSONB, Full-text) | Neon + Drizzle |
| リアルタイム同期が必要 | Supabase + Drizzle |
| 複雑な分析クエリ | 外部 PostgreSQL |
Cloudflareエコシステム内に閉じているのであれば、D1が間違いなく最良の選択です。マルチクラウド環境が必要な場合は、libSQLベースのTursoを選択することで、D1と同じDrizzle APIを使用しながらポータブルな運用が可能になります。
マイグレーションチェックリスト
PrismaからDrizzleへ移行する際に確認すべき項目:
schema.prisma→src/db/schema.tsの変換prisma generateスクリプトの削除PrismaClientインスタンス →drizzle(env.DB)への置き換え- Prismaの
findMany,findUnique→ Drizzleのselect().from()パターンへ変換 - Prismaの
include(リレーション取得) → DrizzleのinnerJoin/leftJoinへ変換 prisma migrate dev→drizzle-kit generate+wrangler d1 migrations apply
移行手順がやや煩雑に感じるかもしれませんが、一度Drizzleに慣れると、SQLを直接制御する感覚が非常に直感的であることがわかります。ORMがSQLを隠蔽するのではなく、SQLをTypeScriptで表現するツールであるという設計思想が活きています。
結論
Drizzle ORM + Cloudflare D1 の組み合わせは、2026年のエッジフルスタック開発において最も効率的な選択肢の1つです。
- バンドルサイズ 12.2 KB: WorkersのCold Startへの影響が実質ゼロ
- コード生成なしの型推論: スキーマファイル1つで型システム全体を構成
- D1バインディング: 外部DB接続なしのIPCによる超低レイテンシ・アクセス
- Hono RPC連携: DBスキーマからフロントエンドまで型が自動伝播
Prismaの1.6 MBというバンドルサイズがWorkers環境で懸念事項となっていた場合、Drizzleへの移行は単なるバンドルサイズの削減にとどまりません。ORMをブラックボックスとして扱うのではなく、SQLをTypeScriptで表現するという開発思想への切り替えでもあります。
関連記事: Hono RPCとCloudflare Workers:TanStack Query v5型安全で、Drizzle APIとHono RPCを組み合わせた完全なフルスタックの例を確認できます。