ClearNets

Navigation

ホーム

トップ / プロダクト一覧

会社紹介

ClearNets の考え方

ブログ

開発ノート・設計思想

コツコツ。

和モダン習慣トラッカー

Merci

チップ決済 SaaS

Kotodama

AI 占い × キャラ

Paircon

家族見守り

家族おでかけガイド

週末のおでかけ先

コツコツ。を試す

← ブログ·技術·

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 追加のみで実装できました。



← ブログ一覧に戻る