1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Drizzle ORM + drizzle-zod でスキーマ駆動開発:DB を Single Source of Truth にする設計

1
Last updated at Posted at 2026-01-06

DB スキーマを型とバリデーションの唯一の源泉にする設計パターンを解説します。enum/nullability/default のズレを防ぎ、drizzle-zod の自動生成も見据えた「破綻しにくい設計」に寄せます。

前提要件公式ドキュメントより)

  • drizzle-zod v0.8.0 以上
  • drizzle-orm v0.36.0 以上
  • zod v3.25.1 以上

なぜ「DB スキーマ駆動(Schema-first)」が必要か

フルスタック TypeScript 開発では、同じデータ構造を複数の場所で定義しがちです:

  • DB スキーマ(Drizzle)
  • API バリデーション(Zod)
  • フロントエンド型定義

これらが乖離すると、実行時エラーや型の不整合が発生します。

この記事での SSOT の範囲

SSOT に含める SSOT に含めない
enum 値、nullability、default、カラム型 UI 都合の制約(最大文字数、正規表現など)

課題:分散した型定義

Before:db + shared パッケージ構成

my-app/
├── db/          # @my/db - Drizzle スキーマ
├── shared/      # @my/shared - 手書き Zod スキーマ
├── backend/
└── frontend/

問題点:

  1. db/schema.tsshared/schemas.ts で同じ構造を二重定義
  2. enum 値の追加時に 2 ファイル修正が必要
  3. nullable/optional の解釈ズレ
// db/src/schema.ts
export const projects = sqliteTable('projects', {
  status: text('status').notNull().default('active'),
})

// shared/src/schemas.ts - 別ファイルで再定義
export const projectStatusSchema = z.enum(['active', 'archived'])
// ↑ 'archived' を追加し忘れるリスク

解決策:model パッケージへの統合

After:統合された model パッケージ

my-app/
├── model/       # @my/model - Drizzle + Zod(統合)
├── backend/
├── frontend/
└── realtime/

依存関係:

frontend  →  backend (型のみ), model
backend   →  model
model     →  (standalone)
realtime  →  model

実装パターン:enum 値の共有

Step 1: テーブル定義で enum 値を export

// model/src/schema.ts
import { sql } from 'drizzle-orm'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { nanoid } from 'nanoid'

// enum 値を Single Source of Truth として定義
export const projectStatusValues = ['active', 'archived'] as const
export const sourceStatusValues = ['draft', 'in_progress', 'completed'] as const
export const articleStatusValues = ['draft', 'review', 'published', 'archived'] as const
export const chatRoleValues = ['user', 'assistant'] as const

export const projects = sqliteTable('projects', {
  id: text('id').primaryKey().$defaultFn(() => nanoid()),
  userId: text('user_id').notNull(),
  name: text('name').notNull(),
  goal: text('goal'),
  projectPrompt: text('project_prompt'),
  // enum オプションでテーブル定義に組み込む
  status: text('status', { enum: projectStatusValues }).notNull().default('active'),
  createdAt: integer('created_at', { mode: 'timestamp' })
    .notNull()
    .$defaultFn(() => new Date()),
  updatedAt: integer('updated_at', { mode: 'timestamp' })
    .notNull()
    .$defaultFn(() => new Date()),
})

Step 2: Zod スキーマで enum 値を参照

// model/src/schemas.ts
import { z } from 'zod'
import {
  projectStatusValues,
  sourceStatusValues,
  articleStatusValues,
  chatRoleValues,
} from './schema'

// DB 定義から enum スキーマを生成(Single Source of Truth)
export const projectStatusSchema = z.enum(projectStatusValues)
export const sourceStatusSchema = z.enum(sourceStatusValues)
export const articleStatusSchema = z.enum(articleStatusValues)
export const chatRoleSchema = z.enum(chatRoleValues)

// Create/Update スキーマ
export const createProjectSchema = z.object({
  name: z.string().min(1),
  goal: z.string().optional(),
  projectPrompt: z.string().optional(),
})

export const updateProjectSchema = z.object({
  name: z.string().min(1).optional(),
  goal: z.string().optional(),
  projectPrompt: z.string().optional(),
  status: projectStatusSchema.optional(),
})

// 型エクスポート
export type ProjectStatus = z.infer<typeof projectStatusSchema>
export type CreateProjectSchema = z.infer<typeof createProjectSchema>
export type UpdateProjectSchema = z.infer<typeof updateProjectSchema>

Step 3: 統一された import

// backend/src/routers/project.ts
import { createProjectSchema, projects, updateProjectSchema } from '@my/model'

export const projectRouter = router({
  create: publicProcedure
    .input(createProjectSchema)  // Zod スキーマ
    .mutation(async ({ ctx, input }) => {
      return ctx.db.insert(projects).values(input)  // Drizzle テーブル
    }),
})

drizzle-zod で Zod スキーマを自動生成する

drizzle-zod はテーブル定義から Zod スキーマを生成でき、API リクエスト/レスポンスの契約をスキーマ駆動に寄せられます。

import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
import { projects } from './schema'

// テーブルから自動生成
export const projectSchema = createSelectSchema(projects)
export const createProjectSchema = createInsertSchema(projects, {
  name: (schema) => schema.min(1),
}).omit({ id: true, userId: true })

運用では「生成結果にフォーム都合の制約を追加したい」「特定 DB で挙動差がある」などが起きるため、Hybrid(生成 + 手動補強) にしておくと破綻しにくいです。

SQLite での既知の不具合

drizzle-zod で SQLite の text()Buffer 型として推論される問題が報告されています(Issue #4705)。

この場合、本記事の enum 共有パターンを使うか、pnpm patch で修正を適用します。

詳細: pnpm の patchedDependencies で依存パッケージにパッチを当てる

テーブル設計の指針

drizzle-zod での自動生成を見据えた設計ガイドライン:

1. enum は明示的に定義

// Good: enum 値を const で定義し、テーブルに渡す
export const statusValues = ['draft', 'published'] as const

status: text('status', { enum: statusValues }).notNull().default('draft')

// Bad: 文字列リテラルを直接書く
status: text('status').notNull().default('draft')

2. nullable は意図的に表現

// nullable: text('col') - .notNull() なし
content: text('content'),  // → z.string().nullable()

// non-nullable: .notNull() 明示
title: text('title').notNull(),  // → z.string()

3. default は .$defaultFn() か .default() で明示

// SQLite の SQL default
createdAt: integer('created_at', { mode: 'timestamp' })
  .default(sql`(unixepoch())`),

// JavaScript default(アプリ側で生成)
id: text('id').primaryKey().$defaultFn(() => nanoid()),

モノレポ移行手順(db/shared → model 統合)

1. ディレクトリリネーム

mv db model

2. package.json 更新

{
  "name": "@my/model",
  "dependencies": {
    "drizzle-orm": "^0.38.0",
    "drizzle-zod": "^0.8.0",
    "zod": "^3.25.1"
  }
}

3. workspace 設定更新

# pnpm-workspace.yaml
packages:
  - 'frontend'
  - 'backend'
  - 'model'      # db → model
  - 'realtime'
  # shared は削除

4. import パス一括置換

# エディタの検索置換で
@my/db     → @my/model
@my/shared → @my/model

5. CI/CD 更新

# Dockerfile
RUN pnpm --filter @my/model build
RUN pnpm --filter @my/backend build

まとめ

Before After
db + shared(2 パッケージ) model(1 パッケージ)
enum 二重定義 DB 定義から取得
型の乖離リスク Single Source of Truth

ポイント:

  • DB スキーマを型とバリデーションの源泉に
  • drizzle-zod で自動生成 or enum 値の手動共有
  • enum 値は as const で定義してテーブルと Zod 両方で使用

参考リンク

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?