Next.js 15 + Drizzle でマーケットプレイスを立てる時のスキーマ設計
ClearNets が運営するスキ活マーケット (teen-earn) は Next.js 15 App Router + Neon Postgres + Drizzle ORM の構成で本番稼働しています。本記事では、マーケットプレイス型プロダクトのスキーマ設計で必ず遭遇する 8 つの論点を、実際の DDL と一緒に整理します。
1. なぜ Drizzle を選んだか
Next.js 15 App Router + serverless (Vercel) 環境では、DB クライアントの制約が厳しいです。 Prisma は Cold Start が重く、Kysely は型が強いが Drizzle の方が SQL に近く読みやすい。 Neon は WebSocket ドライバと HTTP ドライバの 2 種があり、短命なサーバレスリクエストなら HTTP、対話 tx が必要なら Pool (WS) を 使い分けます。
2. 押さえておくべきテーブル (最小 15)
マーケットプレイスの MVP でも最低 15 テーブル前後が必要です。スキ活マーケットの 主要テーブル群:
- users / seller_profiles: ユーザーと出品者プロフィール を 1:1 で分割。出品しないユーザーは seller_profiles 行を持たない。
- parental_consents: 未成年出品者の保護者同意。同意日時 + IP + UA + 同意対象文書のバージョンを保存 (民法 5 条の反証用)。
- categories: 商品カテゴリの階層構造 (自己参照 FK)。
- products / product_assets: 商品と紐付くファイル (本体・ プレビュー・サムネ)。本体は非公開バケット、プレビューは公開バケット。
- orders / order_items / payments: 注文と明細と決済。commission_bps は明細にスナップショット (料率変更に影響されない)。
- download_grants: 購入者に発行されるダウンロード権。orderItemId + assetId の複合 unique で冪等。
- reviews: verified purchase 制約 (order_items 存在確認)。
- reports / content_flags: 通報とテイクダウン管理。
- payouts: 出金掃引記録。Transfer 実行前後の状態と idempotencyKey を保存。
- audit_logs: 状態変更の監査。actor_type (user/system/webhook) と metadata (jsonb) を持つ。
- notifications: リアルタイム通知 (in-app + email)。type + payload (jsonb) の可変長で柔軟性。
- webhook_events: Stripe Webhook の冪等 lock。event.id で unique。
3. 未成年対応の schema 上の要点
中高生向けサービスの場合、schema にいくつかの追加設計が必要です。
- users.birthdate と派生カラム users.is_minor /users.age_band (middle/high/adult) を持つ。live 計算だけだと SQL 集計 で不便なので、signup 時 + 日次 cron で再計算して保存。
- seller_profiles.guardian_is_account_holder: 保護者名義の Connect アカウント であることのフラグ。true の場合のみ未成年 seller の出金を許可。
- 公開ゲート関数:
canPublish(userId)がparental_consents.status='verified'ANDseller_profiles.kyc_status='verified'を返す時のみ product.status → 'published' へ遷移可能。
4. Migration 運用 (drizzle-kit)
Drizzle は db:push (schema 直接反映) と db:generate + db:migrate (SQL migration 生成 + 適用) の 2 モードがあります。
- 開発中:
db:pushで高速反復 - 本番:
db:generateで SQL を生成し、レビュー後にdb:migrateで適用 - drizzle 側のバグ回避: 循環参照 FK は
AnyPgColumn型と arrow function を組み合わせた lazy evaluation で回避
5. Neon lazy init パターン (build 時 crash 回避)
Next.js の build 時 (page-data 収集フェーズ) は、cron routes などが import chain で 実行されて DB client が初期化される場合があります。DATABASE_URL 未設定の マシンで build すると「No database connection string was provided」で crash します。 対策として Neon クライアントを Proxy 経由の lazy init にします。
// src/db/index.ts
let _sql: NeonQueryFunction | undefined;
function getSql() {
if (_sql) return _sql;
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[db] DATABASE_URL is not set");
_sql = neon(url);
return _sql;
}
const sqlProxy = new Proxy(...) // 呼び出し時まで getSql() を触らない
export const db = drizzle(sqlProxy, { schema });これで env 一切なしで npm ci → npm test → next build が通ります。 CI/CD 環境や別端末での初回セットアップが楽になります。
6. 冪等性のためのスキーマ工夫
Stripe Webhook や cron の再走 (重複配信・retry) に耐える設計:
- download_grants に (order_item_id, asset_id) UNIQUE 制約:
ON CONFLICT DO NOTHING RETURNING idで「新規挿入されたか」を判定できる。 RETURNING が空 = 既存 = 通知/email をスキップ (副作用の重複防止)。 - webhook_events に event.id UNIQUE: 冪等 lock。SELECT-then-INSERT の 競合窓を防ぐため
INSERT ... ON CONFLICT DO NOTHING RETURNINGパターンで atomic に判定。 - Stripe API 呼び出しの idempotencyKey: DB 更新失敗で API 再呼び出しが 発生しても Stripe 側で dedup される。
transfer.createには必須。
7. Full-text search (tsvector)
商品検索は Postgres 標準の tsvector + GIN index で十分機能します。Meilisearch/Algolia は 高機能ですが小規模マーケットでは overkill。
// schema.ts (Drizzle)
export const products = pgTable("products", {
...
searchVector: tsvector("search_vector"), // title + description
}, (t) => [
index("idx_products_search").using("gin", t.searchVector),
]);
// クエリ (parametrized)
.where(sql`${products.searchVector} @@ plainto_tsquery(${query})`)raw SQL に見えますが Drizzle 経由なので parameterized で SQL injection 耐性あり。plainto_tsquery は日本語入力を token 化してくれます。
8. モデレーション (通報→テイクダウン) の schema
UGC (User Generated Content) を扱うマーケットでは、通報→審査→削除→出金保留 の フローが必須。schema 側の要点:
- reports: 匿名可 (reporter_user_id nullable)。product_id + reason_key + evidence (jsonb)。
- content_flags: 自動検知 (checksum 重複等) と通報経由の両方の flag を 統一管理。status ('pending'/'confirmed'/'dismissed')。
- takedown 実行の副作用: (a) products.status = 'taken_down', (b) 関連する download_grants の revoked_at をセット, (c) 関連する payouts を on_hold にする。これら 3 つを 1 関数 (takedownProduct) で atomic に実行。
9. audit_logs は最初から入れる
状態遷移が起きた時 (product.published / order.paid / payout.executed / product.taken_down) に audit_logs に 1 行残します。actor_type ('user'/'system'/'webhook'/'admin') と metadata (jsonb) で「なぜ・誰が・何を」の再構成ができます。障害調査・返金争議・弁護士対応 の全てで役立ちます。
10. まとめ
マーケットプレイス型プロダクトのスキーマは、最初の設計を 「未成年対応 + 冪等性 + 監査」 の 3 軸で押さえておくと、後から big refactor をせずに済みます。 Drizzle + Neon の組み合わせは Next.js 15 App Router との相性が良く、Vercel serverless でも支障なく本番稼働します。スキ活マーケットは現在 30+ テーブル・10+ migration で 運用しており、追加機能 (投げ銭・教員向け販売) も schema 追加のみで実装できました。