effidevFlutter・Cloudflareエッジ・クラウドコスト最適化
日本語

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

Drizzle ORMとCloudflare D1エッジSQLiteアーキテクチャ図

なぜエッジで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.tsdriverdbCredentials はプロダクションのリモートマイグレーション専用となります。

スキーマ定義: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 generatetRPC の設定、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へ移行する際に確認すべき項目:

移行手順がやや煩雑に感じるかもしれませんが、一度Drizzleに慣れると、SQLを直接制御する感覚が非常に直感的であることがわかります。ORMがSQLを隠蔽するのではなく、SQLをTypeScriptで表現するツールであるという設計思想が活きています。

結論

Drizzle ORM + Cloudflare D1 の組み合わせは、2026年のエッジフルスタック開発において最も効率的な選択肢の1つです。

Prismaの1.6 MBというバンドルサイズがWorkers環境で懸念事項となっていた場合、Drizzleへの移行は単なるバンドルサイズの削減にとどまりません。ORMをブラックボックスとして扱うのではなく、SQLをTypeScriptで表現するという開発思想への切り替えでもあります。

関連記事: Hono RPCとCloudflare Workers:TanStack Query v5型安全で、Drizzle APIとHono RPCを組み合わせた完全なフルスタックの例を確認できます。