「データアクセスを型安全にしたい」——要件としては一行です。けれど実際に本番へ載せようとすると、判断すべきことが一気に増えます。スキーマをどこで宣言するのか。SQLライクに書くのか、リレーショナルに書くのか。マイグレーションをどう生成・レビュー・適用するのか。トランザクション境界はどこに引くのか。SQLインジェクションをどう防ぐのか。Edge/サーバーレスでコネクションをどう扱うのか。
この記事は、Drizzle ORM を本番品質で運用するための実装ガイドです。Drizzle は「TypeScript の薄い、しかし型が隅々まで通る SQL レイヤ」として設計されており、ORM の便利さとSQLの透明性を両立させようとしている点が他のツールと一線を画します。公式ドキュメントに忠実な事実を土台に、私自身が普段から徹底している「実行時の正しさを、できる限り型に押し込む」という設計思想を重ねて解説します。
この記事のルール:API仕様・コマンド・コードは Drizzle 公式ドキュメント(2026年6月時点) に基づきます。Drizzle は活発に進化しており、API・ヘルパー名は改定されることがあるため、本番投入前に必ず公式ドキュメントで最新仕様を確認してください。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テーブル
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) |
実務でよく書くのは、作成日時・更新日時を含む次のような形です。
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 入力の型を、追加の記述ゼロで取り出せます。
// 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 違反の典型)。
// この差分が型で表現される
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() に接続文字列(環境変数)を渡します。
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 の構造のまま記述できます。
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() で関連を宣言します。
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 が使えます。
// ユーザーと、その投稿群を「ネストしたまま」取得
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 設定ファイル
// 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(推奨フロー)
# 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(プロトタイプ専用)
# スキーマを直接DBへ反映(SQLファイルを生成しない)
npx drizzle-kit push
push はSQLファイルを残さず、スキーマとDBの現状を比較して直接適用します。ローカルのプロトタイピングでは速くて便利ですが、本番では使わないでください。履歴が残らず、破壊的変更(カラム削除=データ消失)が無監査で走るリスクがあるためです。本番は必ず generate → migrate、push は使い捨ての検証用、と線を引きます。
4.4 アプリ起動時のマイグレーション
ゼロダウンタイムデプロイやサーバーレスでは、migrate() 関数で起動時に適用する手もあります。
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に足す」のように複数の書き込みが全部成功するか全部失敗するかであってほしい操作は、トランザクションで囲みます。
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 条件付きロールバックと戻り値
// 残高不足なら明示的にロールバック
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 に推論される
});
ネストしたトランザクション(セーブポイント)もサポートされます。
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 で表現します。
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回に固定できます。
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でも安全です。
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 のパラメータ化は「注入されない」ことは保証しますが、「意味的に妥当な値か」までは保証しません。型と検証は二段構えです。
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)の好例です。
// 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)。
// データアクセスを関数として切り出す(テストしやすい・差し替えやすい)
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 で境界検証する——ランタイムの正しさをできる限り型に押し込む規律で開発しています(型安全を徹底したサブスク学習プラットフォームの実績がその一例です。※このプロジェクトが Drizzle を採用したという意味ではなく、「型でランタイムを守る」という同じ思想で組んだ案件です)。Drizzle はその思想をDB境界まで延長する、相性の良い道具です。
「自社のデータアクセスを、型安全に・マイグレーション安全に・本番運用に耐える形でどう設計するか」——その選定から実装・運用まで、一人 × 生成AI(Claude Code)の体制で、速く・安全に伴走します。 技術選定の段階からでも、お気軽にご相談ください。
参考(公式ドキュメント)
- Drizzle ORM — Overview — 2つのクエリAPI・出力1クエリ・対応DB・依存ゼロ
- Schema declaration(SQL schema) —
pgTable・カラムヘルパー・$inferSelect/$inferInsert - Relational Queries(db.query) —
relations/one/many・findMany/findFirst・with/columns/where - Migrations —
drizzle-kit generate/migrate/push・migrate()関数 - Transactions —
db.transaction・tx.rollback・ネスト(セーブポイント) - Prepared statements / performance —
prepare()/sql.placeholder()/execute()