Claude CodeでDrizzle ORM実装: PostgreSQL・migration・Zod・CIまで
Claude CodeでDrizzle ORMを実装する手順を、PostgreSQL schema、migration、seed、transaction、Zod、CIまで解説。
Drizzle ORMは、TypeScriptでデータベースの形を定義し、その定義から型安全なSQL風クエリを書けるORMです。ORMとは、SQLを毎回文字列で組み立てるのではなく、アプリ側の型とデータベース操作をつなぐ道具です。Drizzleは「SQLに近い書き味」と「TypeScriptの型推論」を優先しているため、Prismaより軽く、SQLを読める人には挙動を追いやすいのが特徴です。
ただし、Claude Codeに「Drizzle ORMを入れて」とだけ頼むと、schemaだけ作ってmigrationを忘れる、onDelete: "cascade"を強くしすぎる、seedが2回目に壊れる、Zod検証とDB制約がズレる、CIでmigration差分を見ない、という失敗が起きがちです。DB層は画面より壊れ方が静かです。ビルドは通っても、本番のmigrationで止まることがあります。
この記事では、Masaが小さなブログAPIを題材に、Claude Codeへどう依頼し、どのコードを人間がレビューするかをまとめます。前提はPostgreSQL、drizzle-orm、drizzle-kit、node-postgresです。公式仕様はDrizzle ORM docs、Drizzle Kit docs, Transactions, drizzle-zodを確認してください。Claude Code側の基本操作はClaude Code公式ドキュメントと、内部記事のClaude Code入門も合わせて読むと流れをつかみやすいです。
全体像
Claude Codeに任せる範囲は「schemaを書く」だけではありません。DB層を安全に使うには、schema、migration、query、seed、validation、CIを1つの作業単位として扱います。
flowchart LR
A["要件をClaude Codeへ渡す"] --> B["schema.tsで型安全に定義"]
B --> C["drizzle-kit generateでSQL migration生成"]
C --> D["人間がSQLと削除ルールをレビュー"]
D --> E["query・transaction・seedを実装"]
E --> F["ZodでAPI入力を検証"]
F --> G["CIでgenerate/check/type/testを実行"]
最初のプロンプトは、次のように具体化します。曖昧な依頼を避けるだけで、Claude Codeの出力品質はかなり変わります。
Drizzle ORMでブログAPIのDB層を実装してください。
前提:
- PostgreSQL
- drizzle-orm + drizzle-kit + node-postgres
- User, Post, Category, Comment, AuditLogを扱う
- Postはdraft, published, archivedの状態を持つ
- slugとemailは一意にする
- 記事一覧はstatus, publishedAt, author, category, searchで絞り込む
- Post削除時はCommentと中間テーブルだけcascadeする
- User削除でPostまで消さない
出力:
1. package.json scripts
2. drizzle.config.ts
3. db/schema.ts
4. db/client.ts
5. db/posts.tsのcreate/list/publish
6. db/seed.ts
7. Zod連携
8. GitHub ActionsでのCI検証
9. migration SQLのレビュー観点
セットアップ
最小構成は次の通りです。DATABASE_URLはローカルPostgreSQL、Supabase、Neon、Railwayなどの接続文字列に置き換えます。Supabaseを使う場合はClaude Code Supabase連携も参考になります。
{
"type": "module",
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:check": "drizzle-kit check",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx db/seed.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"drizzle-orm": "latest",
"pg": "latest",
"zod": "latest"
},
"devDependencies": {
"@types/pg": "latest",
"drizzle-kit": "latest",
"tsx": "latest",
"typescript": "latest",
"vitest": "latest"
}
}
// drizzle.config.ts
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
strict: trueは、Drizzle Kit実行時の危険な変更に気づきやすくするための設定です。本番ではdrizzle-kit pushで直接反映するより、generateでSQLを残し、PRで読んでからmigrateする運用にします。migrationの考え方はClaude Code database migrationにも詳しく書いています。
型安全なschema
schemaはDB設計の中心です。Claude Codeに書かせたら、index、unique、relation、削除ルールを必ず読みます。特にonDeleteは収益サイトやSaaSで事故につながりやすい箇所です。
// db/schema.ts
import { relations } from "drizzle-orm";
import {
boolean,
index,
integer,
jsonb,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
} from "drizzle-orm/pg-core";
export const postStatus = pgEnum("post_status", [
"draft",
"published",
"archived",
]);
export const users = pgTable(
"users",
{
id: uuid("id").defaultRandom().primaryKey(),
email: varchar("email", { length: 255 }).notNull(),
name: varchar("name", { length: 120 }).notNull(),
role: varchar("role", { length: 40 }).default("editor").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("users_email_unique").on(table.email),
index("users_role_idx").on(table.role),
],
);
export const categories = pgTable(
"categories",
{
id: uuid("id").defaultRandom().primaryKey(),
slug: varchar("slug", { length: 120 }).notNull(),
name: varchar("name", { length: 120 }).notNull(),
},
(table) => [uniqueIndex("categories_slug_unique").on(table.slug)],
);
export const posts = pgTable(
"posts",
{
id: uuid("id").defaultRandom().primaryKey(),
slug: varchar("slug", { length: 160 }).notNull(),
title: varchar("title", { length: 160 }).notNull(),
body: text("body").notNull(),
status: postStatus("status").default("draft").notNull(),
authorId: uuid("author_id")
.notNull()
.references(() => users.id, { onDelete: "restrict" }),
viewCount: integer("view_count").default(0).notNull(),
featured: boolean("featured").default(false).notNull(),
publishedAt: timestamp("published_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("posts_slug_unique").on(table.slug),
index("posts_status_published_at_idx").on(table.status, table.publishedAt),
index("posts_author_id_idx").on(table.authorId),
],
);
export const postCategories = pgTable(
"post_categories",
{
postId: uuid("post_id")
.notNull()
.references(() => posts.id, { onDelete: "cascade" }),
categoryId: uuid("category_id")
.notNull()
.references(() => categories.id, { onDelete: "cascade" }),
},
(table) => [primaryKey({ columns: [table.postId, table.categoryId] })],
);
export const comments = pgTable(
"comments",
{
id: uuid("id").defaultRandom().primaryKey(),
postId: uuid("post_id")
.notNull()
.references(() => posts.id, { onDelete: "cascade" }),
authorId: uuid("author_id")
.notNull()
.references(() => users.id, { onDelete: "restrict" }),
body: text("body").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("comments_post_created_at_idx").on(table.postId, table.createdAt),
index("comments_author_id_idx").on(table.authorId),
],
);
export const auditLogs = pgTable(
"audit_logs",
{
id: uuid("id").defaultRandom().primaryKey(),
action: varchar("action", { length: 80 }).notNull(),
targetId: uuid("target_id").notNull(),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("audit_logs_action_created_at_idx").on(table.action, table.createdAt)],
);
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
comments: many(comments),
}));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
comments: many(comments),
categories: many(postCategories),
}));
export const categoriesRelations = relations(categories, ({ many }) => ({
posts: many(postCategories),
}));
export const postCategoriesRelations = relations(postCategories, ({ one }) => ({
post: one(posts, { fields: [postCategories.postId], references: [posts.id] }),
category: one(categories, {
fields: [postCategories.categoryId],
references: [categories.id],
}),
}));
export const commentsRelations = relations(comments, ({ one }) => ({
post: one(posts, { fields: [comments.postId], references: [posts.id] }),
author: one(users, { fields: [comments.authorId], references: [users.id] }),
}));
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
専門用語を補足します。schemaは「DBの表の設計図」、migrationは「設計図の変更をDBへ反映するSQL履歴」、seedは「検証用の初期データ」、transactionは「複数のDB操作を全部成功か全部失敗にそろえる処理」です。Claude Codeはこの説明をプロンプトに入れなくても理解しますが、読者やレビュワーが同じ言葉で話せるよう、記事やPRでは明示したほうが安全です。
migrationを生成して読む
Drizzle Kitでmigrationを生成します。
npm run db:generate
npm run db:check
npm run db:migrate
生成されるSQLは概ね次のような形です。実際のファイル名はdrizzle/0000_xxx.sqlのようになります。
CREATE TYPE "public"."post_status" AS ENUM('draft', 'published', 'archived');
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" varchar(255) NOT NULL,
"name" varchar(120) NOT NULL,
"role" varchar(40) DEFAULT 'editor' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "posts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" varchar(160) NOT NULL,
"title" varchar(160) NOT NULL,
"body" text NOT NULL,
"status" "post_status" DEFAULT 'draft' NOT NULL,
"author_id" uuid NOT NULL,
"view_count" integer DEFAULT 0 NOT NULL,
"featured" boolean DEFAULT false NOT NULL,
"published_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "posts"
ADD CONSTRAINT "posts_author_id_users_id_fk"
FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE restrict;
CREATE UNIQUE INDEX "users_email_unique" ON "users" ("email");
CREATE UNIQUE INDEX "posts_slug_unique" ON "posts" ("slug");
CREATE INDEX "posts_status_published_at_idx" ON "posts" ("status", "published_at");
ここで見るべき点は3つです。1つ目はDROP TABLEやDROP COLUMNが意図せず入っていないか。2つ目は既存データがある列へNOT NULLを追加して本番で止まらないか。3つ目は外部キーのON DELETEが事業ルールと合っているかです。ブログならPost削除でCommentは消えてもよい一方、User削除でPostまで消えると監査や売上分析が壊れます。
queryとtransaction
接続ファイルは薄く保ちます。サーバーレス環境では接続プールの扱いが変わるため、利用環境に合わせて公式ドキュメントを確認してください。
// db/client.ts
import "dotenv/config";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { schema });
以下は、作成、一覧、公開処理をまとめた実装です。publishPostでは記事更新、監査ログ作成をtransactionでまとめます。
// db/posts.ts
import { and, desc, eq, ilike, sql } from "drizzle-orm";
import { z } from "zod";
import { db } from "./client";
import {
auditLogs,
categories,
comments,
postCategories,
posts,
users,
} from "./schema";
import { createPostInputSchema } from "./validation";
type CreatePostInput = z.infer<typeof createPostInputSchema>;
export async function createPost(input: CreatePostInput) {
const data = createPostInputSchema.parse(input);
return db.transaction(async (tx) => {
const [post] = await tx
.insert(posts)
.values({
slug: data.slug,
title: data.title,
body: data.body,
authorId: data.authorId,
})
.returning();
for (const slug of data.categorySlugs) {
const [category] = await tx
.insert(categories)
.values({ slug, name: slug })
.onConflictDoUpdate({
target: categories.slug,
set: { name: slug },
})
.returning();
await tx
.insert(postCategories)
.values({ postId: post.id, categoryId: category.id })
.onConflictDoNothing();
}
await tx.insert(auditLogs).values({
action: "post.create",
targetId: post.id,
metadata: { slug: post.slug },
});
return post;
});
}
export async function listPublishedPosts(params: {
page?: number;
perPage?: number;
search?: string;
} = {}) {
const page = Math.max(params.page ?? 1, 1);
const perPage = Math.min(Math.max(params.perPage ?? 20, 1), 50);
const where = params.search
? and(eq(posts.status, "published"), ilike(posts.title, `%${params.search}%`))
: eq(posts.status, "published");
const [items, [{ total }]] = await Promise.all([
db
.select({
id: posts.id,
slug: posts.slug,
title: posts.title,
publishedAt: posts.publishedAt,
authorName: users.name,
commentCount: sql<number>`count(${comments.id})::int`,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.leftJoin(comments, eq(comments.postId, posts.id))
.where(where)
.groupBy(posts.id, posts.slug, posts.title, posts.publishedAt, users.name)
.orderBy(desc(posts.publishedAt), desc(posts.createdAt))
.limit(perPage)
.offset((page - 1) * perPage),
db
.select({ total: sql<number>`count(*)::int` })
.from(posts)
.where(where),
]);
return {
items,
pagination: {
page,
perPage,
total,
totalPages: Math.ceil(total / perPage),
},
};
}
export async function publishPost(postId: string) {
return db.transaction(async (tx) => {
const [current] = await tx
.select({ id: posts.id, slug: posts.slug, status: posts.status })
.from(posts)
.where(eq(posts.id, postId))
.limit(1);
if (!current) {
throw new Error("Post not found");
}
if (current.status === "published") {
return current;
}
const [published] = await tx
.update(posts)
.set({
status: "published",
publishedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(posts.id, postId))
.returning({ id: posts.id, slug: posts.slug, status: posts.status });
await tx.insert(auditLogs).values({
action: "post.publish",
targetId: published.id,
metadata: { slug: published.slug },
});
return published;
});
}
注意点は、transactionの中でメール送信や外部API呼び出しをしないことです。DB接続を長く持つとデッドロックやタイムアウトの原因になります。通知が必要なら、transaction内ではoutboxテーブルへ書き、別workerで送信します。
seedを冪等にする
冪等とは、同じ処理を何度実行しても壊れないことです。seedは開発者が何度も実行するので、insertだけでなくonConflictDoUpdateを使います。
// db/seed.ts
import { db, pool } from "./client";
import { categories, postCategories, posts, users } from "./schema";
async function main() {
const [user] = await db
.insert(users)
.values({
email: "masa@example.com",
name: "Masa",
role: "admin",
})
.onConflictDoUpdate({
target: users.email,
set: { name: "Masa", role: "admin", updatedAt: new Date() },
})
.returning();
const [category] = await db
.insert(categories)
.values({ slug: "drizzle", name: "Drizzle ORM" })
.onConflictDoUpdate({
target: categories.slug,
set: { name: "Drizzle ORM" },
})
.returning();
const [post] = await db
.insert(posts)
.values({
slug: "claude-code-drizzle-demo",
title: "Claude Code Drizzle demo",
body: "A seeded post for local verification.",
status: "published",
authorId: user.id,
publishedAt: new Date(),
})
.onConflictDoUpdate({
target: posts.slug,
set: {
title: "Claude Code Drizzle demo",
body: "A seeded post for local verification.",
status: "published",
updatedAt: new Date(),
},
})
.returning();
await db
.insert(postCategories)
.values({ postId: post.id, categoryId: category.id })
.onConflictDoNothing();
console.log({ user: user.email, post: post.slug, category: category.slug });
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await pool.end();
});
Zod連携
TypeScriptの型は実行時の入力を検査しません。APIやフォームから来た値は、DBへ入れる前にZodで検証します。DrizzleのschemaからZod schemaを作る場合、現在のDrizzleではdrizzle-orm/zodを使えます。古いプロジェクトではdrizzle-zodを使っている場合があるので、既存packageを見てから変更します。
// db/validation.ts
import { createInsertSchema } from "drizzle-orm/zod";
import { z } from "zod";
import { posts } from "./schema";
export const createPostInputSchema = createInsertSchema(posts, {
slug: (schema) =>
schema
.min(3)
.max(160)
.regex(/^[a-z0-9-]+$/, "Use lowercase letters, numbers, and hyphens"),
title: (schema) => schema.min(1).max(160),
body: (schema) => schema.min(50),
})
.pick({
slug: true,
title: true,
body: true,
authorId: true,
})
.extend({
categorySlugs: z.array(z.string().min(1).max(120)).min(1).max(5),
});
Zod連携の役割は、DB制約の代わりではありません。DBのuniqueやforeign keyは最後の防波堤です。Zodはユーザーにわかりやすいエラーを返す入口の検査です。より広い入力検証はClaude Code Zod Validationを参照してください。
CIで検証する
ローカルで動いても、migrationの生成忘れや型崩れはPRで止めたいです。CIではPostgreSQLサービスを立て、db:generate、db:check、typecheck、testを流します。
# .github/workflows/drizzle.yml
name: drizzle
on:
pull_request:
jobs:
db:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://app:app@localhost:5432/app_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run db:generate
- run: npm run db:check
- run: npm run db:migrate
- run: npm run db:seed
- run: npm run typecheck
- run: npm test
Claude CodeにはCIまで作らせるだけでなく、「CIが何を証明し、何を証明しないか」も説明させます。たとえばdb:migrateが通っても、既存本番データでNOT NULL追加が安全とは限りません。CIは最低限の防御であり、migration SQLの人間レビューを置き換えるものではありません。
3つ以上のユースケース
| ユースケース | Drizzle ORMで見る点 | Claude Codeへの依頼 |
|---|---|---|
| メディア・ブログCMS | slug一意、公開状態、公開日index、著者relation | 「記事一覧のWHERE/ORDER BYに効くindexを説明して」 |
| SaaSの管理画面 | tenantId、role、監査ログ、削除禁止ルール | 「User削除で事業データが消えない設計にして」 |
| 教材・会員サイト | 購入者、レッスン進捗、公開範囲 | 「transaction内で進捗更新とaudit logをまとめて」 |
| コンテンツ収益化 | CTAクリック、記事完了、商品導線 | 「analytics tableを追加する場合のmigration案を出して」 |
DrizzleはSQLに近いので、一覧画面や管理画面のqueryを読みやすく保てます。Prismaとの違いを比較したい場合はClaude Code Prisma ORMが役立ちます。Prismaは抽象化が強く、DrizzleはSQLの見通しが強い、という分け方をすると選びやすいです。
よくある失敗例
1つ目は、schemaだけ作ってmigration SQLを読まないことです。Claude Codeの差分にDROP COLUMNや広すぎるcascadeが入っていても、TypeScriptのビルドでは気づけません。PRでは必ず生成SQLを開きます。
2つ目は、seedが2回目に失敗することです。insertだけで書くとunique制約に当たります。開発者が何度も初期化するDBではonConflictDoUpdateまたはonConflictDoNothingを使います。
3つ目は、transaction内で外部処理を呼ぶことです。メール、Stripe、Slack、Webhookをtransaction内で待つと、DB接続を長く占有します。DBには「送るべきイベント」を保存し、送信は別処理に分けます。
4つ目は、ZodとDB schemaを同じものだと思うことです。フォームにはcategorySlugsのような入力都合の値があり、DBにはpostCategoriesのような永続化都合の表があります。完全に同じschemaを共有しようとすると無理が出ます。
5つ目は、paginationの上限を決めないことです。perPage=10000をそのまま通すと、軽いORMでも重いqueryになります。上の実装では最大50件に丸めています。
6つ目は、Claude Codeに担当外ファイルまで直させることです。DB変更は影響範囲が広いので、「対象ファイル」「変更してよいschema」「触らない既存migration」を明示します。今回のように複数作業者が別slugを触っている場合も同じです。
収益につなげるCTA
Drizzle ORMの記事は、単なるコード集で終わらせると収益化しにくいです。読者は「自分のリポジトリでDB層を壊さずClaude Codeを使いたい」という課題を持っています。自然なCTAは、無料チートシート、DBレビュー用プロンプト、CLAUDE.mdテンプレート、チーム向け導入相談です。
ClaudeCodeLabでは、既存リポジトリのschema、migration、CI、Claude Codeのレビュー観点を一緒に整理できます。チームで安全に導入したい場合はClaude Code研修・導入相談へ進んでください。個人学習なら、この記事のプロンプトを自分のschemaへ置き換え、生成されたmigration SQLを読む練習から始めるのが現実的です。
この記事で紹介した内容を実際に試した結果
Masaが検証用の小さなブログAPIで同じ流れを試したところ、最初に効いたのは「schemaを書かせる」よりも「migration SQLを読ませる」指示でした。Claude Codeは最初、User削除時のrelationを少し強くしすぎましたが、onDeleteの意図を表にして再レビューさせると修正できました。drizzle-kit generate、drizzle-kit check、seedの再実行、一覧query、公開transactionまでを1つの作業単位にしたことで、あとからZodとCIを足すよりレビューしやすくなりました。Drizzle ORMは軽い道具ですが、軽いからこそschema、SQL、testを人間が読める形で残す運用が一番効きます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code権限セーフティラダー: 初心者がallowを広げる順番
Claude Codeの権限をread-onlyからbuild、限定編集、deploy確認まで段階的に広げる安全な運用手順。
Claude Code Small PR Proof Pack: 小さなPRをレビュー可能にする証拠セット
Claude Codeの小さなPRに、差分・検証・公開URL・CTA・rollbackを添える実務チェックリスト。
Claude Codeのコミット前レビューゲート: 差分、テスト、CTAをまとめて止める型
Claude Codeでcommit前に差分をレビューする実践手順。build、公開URL、CTA、Gumroadリンク、未翻訳本文を検知します。