# Drizzle ORM 本番運用ガイド：スキーマから型を生成し、マイグレーション・トランザクション・Edge までを型安全に固める

> Drizzle ORM（TypeScript）を本番運用する実装ガイド。スキーマ・コードからの型推論（$inferSelect/$inferInsert）、SQLライクなクエリビルダとリレーショナルクエリ、drizzle-kitのマイグレーション、トランザクション、prepared statement、Edge互換、そしてPrismaとの使い分けを、すべて実コードで解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: TypeScript, Drizzle, PostgreSQL, 型安全, アーキテクチャ設計
- URL: https://tomodahinata.com/blog/drizzle-orm-typescript-type-safe-database-production-guide

## 要点

- Drizzleはスキーマを唯一の真実源とし、$inferSelect / $inferInsertで型を機械生成する。手書きDTOとasキャストは禁止
- クエリAPIは2系統を適材適所で併用する。ネスト取得はdb.query、集計・複雑検索はdb.select。どちらも常に1クエリでN+1が出ない
- マイグレーションは本番ではgenerate（SQLをレビュー）→migrate。pushは履歴が残らず破壊的変更が無監査なので使い捨て検証専用
- 冪等性はonConflictDoUpdate / onConflictDoNothing、トランザクションは中で必ずtxを使い外部API呼び出しは境界の外へ
- セキュリティは値が全てパラメータ化され注入されない。生SQLはsqlテンプレートに埋め、sql.rawはユーザー入力に使わない

---

「データアクセスを型安全にしたい」——要件としては一行です。けれど実際に本番へ載せようとすると、判断すべきことが一気に増えます。**スキーマをどこで宣言するのか。SQLライクに書くのか、リレーショナルに書くのか。マイグレーションをどう生成・レビュー・適用するのか。トランザクション境界はどこに引くのか。SQLインジェクションをどう防ぐのか。Edge/サーバーレスでコネクションをどう扱うのか。**

この記事は、**Drizzle ORM** を**本番品質**で運用するための実装ガイドです。Drizzle は「TypeScript の薄い、しかし型が隅々まで通る SQL レイヤ」として設計されており、ORM の便利さとSQLの透明性を両立させようとしている点が他のツールと一線を画します。公式ドキュメントに忠実な事実を土台に、私自身が普段から徹底している「**実行時の正しさを、できる限り型に押し込む**」という設計思想を重ねて解説します。

> **この記事のルール**：API仕様・コマンド・コードは **Drizzle 公式ドキュメント（2026年6月時点）** に基づきます。Drizzle は活発に進化しており、API・ヘルパー名は改定されることがあるため、本番投入前に必ず[公式ドキュメント](https://orm.drizzle.team/docs/overview)で最新仕様を確認してください。DB接続情報（接続文字列・認証情報）は**環境変数前提**で扱います（ハードコード厳禁）。

---

## 0. メンタルモデル：ORM境界も「型」で固める

本題に入る前に、この記事を貫く一本の軸を共有させてください。

私は型安全を徹底したサブスク課金基盤の開発で、`as` / `any` / `enum` / non-null assertion を**全面禁止**しました。代わりに Union 型＋`satisfies`＋`NeverError(value: never)` を組み合わせ、「**分岐の取りこぼしがあればコンパイルが落ちる**」状態を作り、外部入力の境界はすべて Zod で検証しました（決済金額は丸め誤差を避けるため整数のマイナー単位で扱う、といった細部まで型で守ります）。

この思想——「**ランタイムの不正を、できるだけコンパイル時に倒す**」——は、ORM境界（DBアクセス）にもそのまま当てはまります。むしろデータベースは、アプリの中で**最も型が崩れやすい境界**です。`SELECT *` の結果が `any` のまま流れてくれば、その先のロジックはいくら丁寧に書いても砂上の楼閣になります。

Drizzle のメンタルモデルはシンプルです。

> **スキーマを TypeScript で宣言する → `$inferSelect` / `$inferInsert` で型が自動生成される → クエリ結果まで型が通る = SQL の「型安全な薄いレイヤ」。**

スキーマが**唯一の真実（single source of truth）**になり、そこから型もマイグレーションも導出される。これが Drizzle の中核であり、私の設計原則とまっすぐ噛み合う理由です。

---

## 1. スキーマ・アズ・コード：pgTable で宣言する

Drizzle は PostgreSQL / MySQL / SQLite / SingleStore を方言ごとにサポートします。本記事は PostgreSQL（`drizzle-orm/pg-core`）を題材にします。

### 1.1 まずは1テーブル

```ts
import { pgTable, integer, varchar } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: integer().primaryKey().generatedAlwaysAsIdentity(),
  name: varchar().notNull(),
  email: varchar().notNull().unique(),
});
```

ここで重要なのは、**これがマイグレーションファイルでも型定義でもなく、ただの TypeScript** だという点です。`pgTable("users", {...})` という1つの宣言から、テーブル定義・型・マイグレーションのすべてが導出されます。`.primaryKey()` / `.notNull()` / `.unique()` はカラムに直接チェーンする制約で、`generatedAlwaysAsIdentity()` は PostgreSQL の `GENERATED ALWAYS AS IDENTITY`（推奨される自動採番）を表します。

### 1.2 よく使うカラムヘルパー

| ヘルパー | 用途 |
| --- | --- |
| `integer()` | 整数 |
| `varchar()` | 可変長文字列（長さ指定可） |
| `text()` | 長文テキスト |
| `timestamp()` | 日時 |
| `boolean()` | 真偽値 |
| `serial()` | 自動採番整数（PostgreSQL） |

実務でよく書くのは、作成日時・更新日時を含む次のような形です。

```ts
import { pgTable, integer, varchar, text, timestamp, boolean } from "drizzle-orm/pg-core";

export const posts = pgTable("posts", {
  id: integer().primaryKey().generatedAlwaysAsIdentity(),
  authorId: integer("author_id")
    .notNull()
    .references(() => users.id),
  title: varchar({ length: 200 }).notNull(),
  body: text().notNull(),
  published: boolean().default(false).notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});
```

`.references(() => users.id)` で外部キーを宣言します。コールバックで遅延評価しているのは、テーブル間の相互参照（循環）を解決するためです。`.default(false)` / `.defaultNow()` はDB側のデフォルト値で、`.notNull()` と組み合わせることで「アプリが値を渡さなくても、DBレベルで NOT NULL が保証される」状態を作れます。

> **設計のコツ（SRP）**：スキーマ定義は `db/schema.ts`（あるいはテーブルごとに分割）に集約し、アプリのロジックから物理的に分離します。スキーマは「データの形」という1つの責務だけを持つべきで、ここにビジネスロジックを混ぜないこと。

---

## 2. 型推論：$inferSelect / $inferInsert が主役

ここが Drizzle の真価です。スキーマから、**SELECT 結果の型**と**INSERT 入力の型**を、追加の記述ゼロで取り出せます。

```ts
// users テーブルの SELECT 結果の型（id は number、email は string、...）
type User = typeof users.$inferSelect;

// INSERT 入力の型（id は省略可、generatedAlwaysAsIdentity なので渡せない）
type NewUser = typeof users.$inferInsert;
```

この2つの型が決定的に重要なのは、**「DBが返す形」と「DBが受け取れる形」が別物だから**です。`id` は `generatedAlwaysAsIdentity()` なので INSERT 時には渡せません。`createdAt` は `defaultNow()` なので省略できます。`$inferInsert` はこうした制約を**型レベルで正確に反映**します。手書きの DTO 型では、この差分を人間が同期し続けることになり、必ずどこかでズレます（DRY 違反の典型）。

```ts
// この差分が型で表現される
const ok: NewUser = { name: "Hinata", email: "h@example.com" }; // ✅ id は不要
// const ng: NewUser = { id: 1, name: "x", email: "y" };        // ❌ 型エラー
```

私の設計原則に翻訳すると、`$inferSelect` / `$inferInsert` は「**スキーマという single source of truth から型を機械生成する**」仕組みそのものです。ここで `as User` のような手動キャストを挟んだ瞬間、型安全は崩れます。**境界の型は推論させ、自分で名付けない**——これが Drizzle を使ううえでの第一の規律です。

---

## 3. 2つのクエリAPI：SQLライク vs リレーショナル

Drizzle には性質の異なる2つのクエリAPIがあり、これを混同すると設計がブレます。公式は「Drizzle は常に**ちょうど1つのSQLクエリ**を出力する」と明言しており、どちらのAPIでもN+1問題が暗黙に発生しない設計になっています。

### 3.1 接続（drizzle()）

まず接続です。`drizzle()` に接続文字列（環境変数）を渡します。

```ts
import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "./schema";

// DATABASE_URL は環境変数。ハードコード厳禁。
export const db = drizzle(process.env.DATABASE_URL!, { schema });
```

`{ schema }` を渡しておくと、後述の**リレーショナルクエリ（`db.query`）**が使えるようになります。

### 3.2 SQLライク：db.select().from().where()

SQL をそのまま TypeScript に写したような書き味です。JOIN・集約・複雑な条件を、SQL の構造のまま記述できます。

```ts
import { eq } from "drizzle-orm";

// SELECT ... FROM users WHERE id = 1
const rows = await db.select().from(users).where(eq(users.id, 1));
// rows の型は User[]（$inferSelect 由来）— 自分で型注釈は書かない

// JOIN もSQLの形のまま
const joined = await db
  .select()
  .from(countries)
  .leftJoin(cities, eq(cities.countryId, countries.id))
  .where(eq(countries.id, 10));
```

`eq` / `and` / `or` / `lt` / `gt` などの条件ヘルパーは `drizzle-orm` から import します。これらは文字列連結ではなく**パラメータ化されたSQL**を生成するため、値はすべてプレースホルダ経由で渡り、**SQLインジェクションが構造的に発生しません**（第7章で詳述）。

### 3.3 リレーショナル：db.query.<table>.findMany()

ネストした関連データを「ツリーのまま」取りたいときは、リレーショナルクエリAPIが圧倒的に読みやすくなります。まず `relations()` で関連を宣言します。

```ts
import { relations } from "drizzle-orm";

export const usersRelations = relations(users, ({ one, many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));
```

`one()` は単数参照（投稿 → 著者）、`many()` は複数参照（ユーザー → 投稿群）です。`fields` / `references` で結合キーを明示します。これで `db.query` が使えます。

```ts
// ユーザーと、その投稿群を「ネストしたまま」取得
const usersWithPosts = await db.query.users.findMany({
  with: { posts: true },
});

// 単一取得・部分カラム・絞り込み・並び順を組み合わせる
const post = await db.query.posts.findFirst({
  columns: { id: true, title: true },     // 必要なカラムだけ
  where: (posts, { eq }) => eq(posts.id, 1),
  with: {
    author: { columns: { name: true } },  // 関連先も部分選択
  },
});

// ネストの絞り込み・並び順・件数制限も宣言的に
const feed = await db.query.posts.findMany({
  limit: 5,
  offset: 0,
  orderBy: (posts, { desc }) => [desc(posts.createdAt)],
  with: {
    author: true,
  },
});
```

`findMany` / `findFirst` の返り値も、`with` や `columns` の指定に応じて**型が正確に変化**します。`columns: { id: true, title: true }` と書けば、返り値の型からは `body` が消えます。これは「取得したカラムしかアクセスできない」という、ランタイムの事実を型で保証する挙動です。

### 3.4 どちらを使うか（決定表）

| 観点 | SQLライク（`db.select`） | リレーショナル（`db.query`） |
| --- | --- | --- |
| 得意なこと | 複雑なJOIN・集約・GROUP BY・サブクエリ・ウィンドウ関数 | 親子・多階層のネスト取得を「ツリーのまま」 |
| 読みやすさ | SQLが読める人には直感的 | 関連の取得が宣言的で短い |
| 部分選択 | `db.select({ ... })` で明示 | `columns: { ... }` で宣言 |
| 集約・分析クエリ | ◎ | △（リレーショナル取得に特化） |
| N+1 | 出ない（1クエリ） | 出ない（1クエリ） |
| 向く場面 | 管理画面の集計、レポート、複雑検索 | 詳細画面・APIレスポンスのネストDTO |

実務では**両方を併用**します。「画面に出す親子データ」は `db.query` で短く、「集計レポートや複雑な検索」は `db.select` でSQLの表現力を使う。**1つのAPIに統一しようとしない**ことが、かえって読みやすさを保ちます（KISS：道具を適材適所で）。

---

## 4. マイグレーション：drizzle-kit で「生成 → レビュー → 適用」

スキーマを変えたら、DBに反映する必要があります。Drizzle はここを `drizzle-kit` という別パッケージで担い、**マイグレーションを成果物として残す**ワークフローを推奨しています。

### 4.1 設定ファイル

```ts
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "postgresql",
  schema: "./src/db/schema.ts",
  out: "./drizzle",                       // 生成物の出力先
  dbCredentials: {
    url: process.env.DATABASE_URL!,        // 環境変数から（ハードコード厳禁）
  },
});
```

### 4.2 generate → migrate（推奨フロー）

```bash
# 1. スキーマと既存マイグレーションを比較し、差分のSQLを生成する
#    out/ に timestamp 付きフォルダ（migration.sql + snapshot.json）が出来る
npx drizzle-kit generate

# 2. 未適用のマイグレーションをDBに適用する
#    適用履歴はDB側のテーブルで管理され、二重適用されない
npx drizzle-kit migrate
```

このフローの肝は、**`generate` が生成する SQL を、適用前に人間がレビューできる**点です。`./drizzle/` 配下の `migration.sql` は Git にコミットされる成果物であり、`snapshot.json` がスキーマの履歴を追跡します。**マイグレーションをコードレビューの対象に載せる**——これが本番運用での絶対条件です（後述）。

### 4.3 push（プロトタイプ専用）

```bash
# スキーマを直接DBへ反映（SQLファイルを生成しない）
npx drizzle-kit push
```

`push` はSQLファイルを残さず、スキーマとDBの現状を比較して直接適用します。**ローカルのプロトタイピングでは速くて便利**ですが、**本番では使わないでください**。履歴が残らず、破壊的変更（カラム削除＝データ消失）が無監査で走るリスクがあるためです。本番は必ず `generate → migrate`、`push` は使い捨ての検証用、と線を引きます。

### 4.4 アプリ起動時のマイグレーション

ゼロダウンタイムデプロイやサーバーレスでは、`migrate()` 関数で起動時に適用する手もあります。

```ts
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";

const db = drizzle(process.env.DATABASE_URL!);

await migrate(db, { migrationsFolder: "./drizzle" });
```

公式はこれを「モノリシックなアプリのゼロダウンタイムデプロイや、マイグレーションを一度だけ実行するサーバーレス環境（カスタムリソース経由）」向けとしています。ただし**複数インスタンスが同時に起動する構成では、起動時マイグレーションが競合し得る**ため、CI/CDのデプロイ手順で1回だけ走らせる方が安全な場面も多い。ここはアーキテクチャ次第なので、要件に合わせて選んでください。

### 4.5 破壊的変更の検査（運用の知恵）

これは Drizzle 固有機能ではなく**運用の規律**です。生成された `migration.sql` には、`DROP COLUMN` / `DROP TABLE` / `ALTER COLUMN ... NOT NULL`（既存行があると失敗）/ ロックを伴う `ALTER` などが紛れ込みます。PostgreSQL では、こうした「危険なマイグレーション」を検査するツール（`squawk` など）をCIに組み込み、**人間のレビューに加えて機械でも破壊的変更を検出する**のが堅牢です。

> **原則**：マイグレーションは「実行されるコード」です。アプリのコードに `--fix` を盲信しないのと同じで、**生成されたSQLを必ず読む**。差分が読めない量になっているなら、それは1つのマイグレーションに変更を詰め込みすぎているサインです（SRP）。

---

## 5. トランザクション：境界を1つの関数に閉じ込める

「Aを引いてBに足す」のように**複数の書き込みが全部成功するか全部失敗するか**であってほしい操作は、トランザクションで囲みます。

```ts
import { sql, eq } from "drizzle-orm";

await db.transaction(async (tx) => {
  await tx
    .update(accounts)
    .set({ balance: sql`${accounts.balance} - 100.00` })
    .where(eq(users.name, "Dan"));

  await tx
    .update(accounts)
    .set({ balance: sql`${accounts.balance} + 100.00` })
    .where(eq(users.name, "Andrew"));
});
```

コールバックが正常終了すれば COMMIT、例外を投げれば自動 ROLLBACK されます。**トランザクション内では `db` ではなく `tx` を使う**のが鉄則です。`tx` を使い忘れて `db` で書くと、その操作はトランザクションの外で走ってしまい、原子性が壊れます。

### 5.1 条件付きロールバックと戻り値

```ts
// 残高不足なら明示的にロールバック
await db.transaction(async (tx) => {
  const [account] = await tx
    .select({ balance: accounts.balance })
    .from(accounts)
    .where(eq(users.name, "Dan"));

  if (account.balance < 100) {
    tx.rollback(); // 以降を中断してロールバック
  }
  // ... 残りの処理
});

// トランザクションの結果を値として返せる（型も通る）
const newBalance: number = await db.transaction(async (tx) => {
  await tx
    .update(accounts)
    .set({ balance: sql`${accounts.balance} - 100.00` })
    .where(eq(users.name, "Dan"));

  const [account] = await tx
    .select({ balance: accounts.balance })
    .from(accounts)
    .where(eq(users.name, "Dan"));

  return account.balance; // newBalance: number に推論される
});
```

ネストしたトランザクション（セーブポイント）もサポートされます。

```ts
await db.transaction(async (tx) => {
  await tx.update(accounts).set({ balance: sql`${accounts.balance} - 100.00` })
    .where(eq(users.name, "Dan"));

  await tx.transaction(async (tx2) => {
    await tx2.update(users).set({ name: "Mr. Dan" }).where(eq(users.name, "Dan"));
  });
});
```

各方言では分離レベル（isolation level）やアクセスモードの設定もできます。**境界の引き方**が設計の勘所です。トランザクションは短く保ち、**中で外部API呼び出し（決済ゲートウェイ等）をawaitしない**——ロックを長時間握ったまま外部の遅延に引きずられると、接続枯渇やデッドロックの温床になります。外部呼び出しはトランザクションの外、DBの確定処理だけを中に、と分離します（SRP）。

---

## 6. 冪等な upsert と prepared statement

### 6.1 冪等な upsert（二重実行に強くする）

「同じリクエストが2回来ても、結果が1回分にしかならない」性質＝冪等性は、Webhook受信やリトライ前提のジョブで必須です。Drizzle では `onConflictDoUpdate` / `onConflictDoNothing` で表現します。

```ts
import { sql } from "drizzle-orm";

// email が衝突したら name を上書き（INSERT ... ON CONFLICT DO UPDATE）
await db
  .insert(users)
  .values({ name: "Hinata", email: "h@example.com" })
  .onConflictDoUpdate({
    target: users.email,
    set: { name: sql`excluded.name` },
  });

// 衝突したら何もしない（重複登録を黙って弾く）
await db
  .insert(users)
  .values({ name: "Hinata", email: "h@example.com" })
  .onConflictDoNothing();
```

`onConflictDoNothing` は「Webhookの再送で同じイベントが2回届いても、2行目を作らない」といったケースで効きます。**冪等性はリトライ設計の前提**であり、これがないと「失敗したかもしれないから再送」が即・二重登録に化けます（信頼性）。

### 6.2 prepared statement：ホットパスを速くする

同じ形のクエリを高頻度で叩くなら、prepared statement でSQLのコンパイルを1回に固定できます。

```ts
import { sql, eq } from "drizzle-orm";

// プレースホルダで値を後から差し込む
const userById = db
  .select()
  .from(users)
  .where(eq(users.id, sql.placeholder("id")))
  .prepare("user_by_id");

// 実行時に値をバインド（SQLの再パースが起きない）
const a = await userById.execute({ id: 10 });
const b = await userById.execute({ id: 20 });
```

公式は「prepared statement では SQL の連結を Drizzle 側で一度だけ行い、あとはドライバがプリコンパイル済みのバイナリSQLを再利用できる。大きなSQLでは絶大な性能効果がある」と説明しています。SQLite では `.execute()` の代わりに `.all()` / `.get()` を使います。

> **YAGNI 注意**：prepared statement は**計測してボトルネックだと分かったホットパスにだけ**入れてください。全クエリを先回りで prepared 化するのは、可読性を下げるだけの早すぎる最適化です。まず素直に書き、プロファイルしてから効かせる。

---

## 7. セキュリティ：パラメータ化で注入を構造的に防ぐ

Drizzle のクエリビルダ（`eq`, `where`, `db.query` の条件など）は、値をすべて**プレースホルダ**として渡します。文字列連結でSQLを組まないため、ユーザー入力が**SQL構文として解釈される余地がそもそもありません**。これは設計レベルでのインジェクション対策です。

問題になるのは、生SQLを書きたくなったときです。Drizzle は `sql` テンプレートタグを提供しており、**ここを正しく使えば生SQLでも安全**です。

```ts
import { sql } from "drizzle-orm";

// ❌ 絶対にやってはいけない：文字列連結（インジェクション）
// const bad = sql.raw(`SELECT * FROM users WHERE name = '${userInput}'`);

// ✅ 正しい：s`` テンプレートに値を埋めると、自動でパラメータ化される
const safe = await db.execute(
  sql`SELECT * FROM users WHERE name = ${userInput}`,
);
```

`sql\`... ${userInput}\`` の `${}` に入れた値は、文字列に**直接埋め込まれるのではなく、バインドパラメータ**になります。これが安全の本体です。`sql.raw()` は値をそのまま埋め込むので、**ユーザー入力には絶対に使わない**（テーブル名のような信頼できる定数にのみ限定）。

ここでも私の原則が効きます。**外部入力は境界で検証する**。`userInput` は Drizzle に渡す前に Zod 等で「期待する形か」を検証し、型を narrow しておくこと。Drizzle のパラメータ化は「注入されない」ことは保証しますが、「**意味的に妥当な値か**」までは保証しません。型と検証は二段構えです。

```ts
import { z } from "zod";

const SearchInput = z.object({ name: z.string().min(1).max(100) });

// 境界で検証 → 検証済みの値だけを Drizzle に渡す
const { name } = SearchInput.parse(req.body);
const rows = await db.select().from(users).where(eq(users.name, name));
```

---

## 8. Edge / サーバーレス互換：コネクションをどう扱うか

Drizzle 自体は依存ゼロ・約31kbの軽量ライブラリで、Edge/サーバーレスを意識した設計です。ただし**型の話とコネクションの話は別**で、本番で詰まるのはほぼ後者です。

サーバーレス（Vercel Functions / Lambda 等）やEdgeランタイムでは、関数インスタンスが大量に並ぶため、**従来型の「1接続=1TCPコネクション」を素朴に使うとDBの接続上限を即座に食い潰します**。対策は使うドライバで変わります。

| 環境 | 推奨ドライバ / 接続戦略 |
| --- | --- |
| Node サーバー（常駐） | `node-postgres`（`pg`）+ コネクションプール |
| サーバーレス（Lambda/Vercel） | プーラ（PgBouncer / RDS Proxy / Supabase pooler）経由、または HTTP ドライバ |
| Edge（Cloudflare Workers 等） | **HTTP/WebSocket ベースのドライバ**（`neon-http`, `postgres.js` など、TCP非依存のもの） |

Edge ランタイムでは Node の TCP ソケットが使えないため、**HTTPベースのドライバ**（例：Neon の HTTP ドライバ）を選びます。Drizzle はドライバ非依存なので、`drizzle(...)` に渡すクライアントを差し替えるだけで、**クエリの書き方（`db.select` / `db.query`）は一切変わりません**。これは「データアクセスの記述」と「接続の物理」を分離できている、ETC（Easy To Change）の好例です。

```ts
// Edge: HTTP ドライバに差し替えても、db のAPIは同じ
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";

const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });
// → 以降の db.select().from(...) / db.query.users.findMany(...) はそのまま動く
```

> **原則（ETC）**：接続戦略は環境ごとに変わるが、**ドメインのクエリは変わってはいけない**。`db` を1箇所で組み立て、アプリ側はそのインスタンスだけに依存させる。これで「Node → Edge 移行」がドライバ差し替えだけで済みます。

---

## 9. テスト容易性：型だけでなく挙動も守る

型が通ることは「正しさ」の半分でしかありません。残り半分——**クエリが意図どおりのデータを返すか**——はテストで守ります。Drizzle はプレーンな関数の集合なので、テストは素直です。

- **実DBに対するテスト**が最も信頼できます。CIでは Testcontainers 等で使い捨ての PostgreSQL を立て、マイグレーションを適用してから検証する。**本番と同じSQL方言で検証できる**のが最大の利点です。
- **軽量にしたい単体テスト**では、SQLite（`better-sqlite3` の in-memory）に対して回す手もあります。ただし方言差（PostgreSQL 固有の型・関数）でズレるため、**最終防衛線は本番と同じDBでのテスト**にすること。
- データアクセス層を**リポジトリ関数**に切り出し、引数と戻り値を `$inferSelect` / `$inferInsert` 由来の型で固めておくと、呼び出し側はモックしやすく、層の責務も明確になります（SRP / ETC）。

```ts
// データアクセスを関数として切り出す（テストしやすい・差し替えやすい）
export async function findUserById(id: number): Promise<User | undefined> {
  const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
  return user; // 戻り値は $inferSelect 由来の型
}
```

---

## 10. Drizzle vs Prisma：正直な使い分け

Drizzle を推す記事だからといって「Prismaは時代遅れ」などと言うつもりはありません。両者は思想が違うだけで、**向く場面が違います**。

| 観点 | Drizzle | Prisma |
| --- | --- | --- |
| スキーマ定義 | **TypeScript**（`pgTable`）。型と地続き | 独自DSL（`schema.prisma`）。生成ステップが要る |
| クエリの抽象度 | SQLに近い（薄い）。SQLが透けて見える | 高レベル抽象。SQLを意識させない |
| 生成物 | 不要（型は推論で出る） | `prisma generate` でクライアント生成が必要 |
| 学習コスト | SQL知識が前提。SQLを知る人には速い | SQLを知らなくても書ける親切設計 |
| バンドルサイズ | 軽量（依存ゼロ・約31kb） | 相対的に重い（エンジン同梱の歴史的経緯） |
| Edge/サーバーレス | ドライバ差し替えで柔軟 | 改善されたが構成に配慮が要る |
| エコシステム | 比較的新しい。急速に成熟中 | 成熟・ドキュメント・GUI（Studio）が厚い |
| 生SQLの混在 | `sql\`\`` で自然に混ぜられる | 可能だが Drizzle ほど地続きではない |

**Drizzle が向く**：SQLを理解しているチーム、SQLの表現力（複雑なJOIN・ウィンドウ関数）をフルに使いたい、バンドルを軽く保ちたい、Edge/サーバーレスで動かす、生成ステップを排したい。

**Prisma が向く**：SQLに不慣れなメンバーが多い、宣言的で親切なAPIとGUI（Studio）を重視、エコシステムの厚さ・実績を優先したい、CRUDが主でSQLの深い表現力は不要。

私自身の選好を正直に言えば、**「型は推論で出るべき、SQLは隠さず透けて見えるべき、生成ステップは少ないほどよい」**という価値観から Drizzle に強く惹かれます。けれどそれは**私のチームと案件の前提**での話です。SQLに不慣れなメンバーが主力なら、Prisma の親切さが生産性で勝つ場面は普通にあります。**道具は前提条件で選ぶもので、信仰で選ぶものではありません。**

---

## 11. まとめ：チートシート

最後に、迷ったときの早見表です。

- **スキーマ**：`pgTable("name", { ... })` を `db/schema.ts` に集約。制約はカラムにチェーン。
- **型**：`typeof table.$inferSelect` / `$inferInsert` で導出。**手書きDTOと `as` は禁止**。
- **クエリ選択**：ネスト取得は `db.query.*.findMany({ with })`、集計・複雑検索は `db.select().from()`。どちらも1クエリ。
- **マイグレーション**：本番は `drizzle-kit generate`（SQLをレビュー）→ `migrate`。`push` は使い捨て検証専用。破壊的変更はCIで検査。
- **トランザクション**：`db.transaction(async (tx) => ...)`。**中では必ず `tx`**。外部API呼び出しは境界の外へ。
- **冪等性**：`onConflictDoUpdate` / `onConflictDoNothing` でリトライに強くする。
- **性能**：計測した上で、ホットパスに `prepare()` + `sql.placeholder()`。先回り最適化はしない。
- **セキュリティ**：値はすべてパラメータ化され注入されない。生SQLは `sql\`${value}\``、`sql.raw()` はユーザー入力に使わない。入力はZodで境界検証。
- **Edge**：ドライバを差し替える。クエリの書き方は変えない（ETC）。

データアクセスは「一行の要件」に見えて、**型安全・マイグレーションの安全・トランザクション境界・接続戦略・注入対策を同時に設計する仕事**です。Drizzle は「スキーマを真実とし、そこから型を推論し、SQLを隠さず薄く包む」という一貫した思想で、この設計をきれいに支えてくれます。

私は普段から、`as` / `any` / `enum` を排し、`satisfies` と網羅性チェック（`NeverError`）で「**分岐の取りこぼしはコンパイルが落ちる**」状態を作り、外部入力は Zod で境界検証する——**ランタイムの正しさをできる限り型に押し込む**規律で開発しています（[型安全を徹底したサブスク学習プラットフォームの実績](/case-studies/subscription-learning-platform)がその一例です。※このプロジェクトが Drizzle を採用したという意味ではなく、「型でランタイムを守る」という同じ思想で組んだ案件です）。Drizzle はその思想を**DB境界まで**延長する、相性の良い道具です。

**「自社のデータアクセスを、型安全に・マイグレーション安全に・本番運用に耐える形でどう設計するか」——その選定から実装・運用まで、一人 × 生成AI（Claude Code）の体制で、速く・安全に伴走します。** 技術選定の段階からでも、お気軽にご相談ください。

---

### 参考（公式ドキュメント）

- [Drizzle ORM — Overview](https://orm.drizzle.team/docs/overview) — 2つのクエリAPI・出力1クエリ・対応DB・依存ゼロ
- [Schema declaration（SQL schema）](https://orm.drizzle.team/docs/sql-schema-declaration) — `pgTable`・カラムヘルパー・`$inferSelect` / `$inferInsert`
- [Relational Queries（db.query）](https://orm.drizzle.team/docs/rqb) — `relations` / `one` / `many`・`findMany` / `findFirst`・`with` / `columns` / `where`
- [Migrations](https://orm.drizzle.team/docs/migrations) — `drizzle-kit generate` / `migrate` / `push`・`migrate()` 関数
- [Transactions](https://orm.drizzle.team/docs/transactions) — `db.transaction`・`tx.rollback`・ネスト（セーブポイント）
- [Prepared statements / performance](https://orm.drizzle.team/docs/perf-queries) — `prepare()` / `sql.placeholder()` / `execute()`
