🐘

Supabaseでスキーマ設計:テーブル分割と正規化の実践

に公開
4

この記事は、ひとりでつくるSaaS - 設計・実装・運用の記録 Advent Calendar 2025 の6日目の記事です。

昨日の記事では「Gitブランチ戦略」について書きました。この記事では、Supabaseでのスキーマ設計について解説します。

🎯 なぜスキーマを分けるのか

PostgreSQLにはスキーマという概念があります。テーブルをグループ化する仕組みで、名前空間のように機能します。

スキーマの一般的な用途

スキーマは主に以下の目的で使われます。

  • マルチテナント: 顧客ごとにスキーマを分けてデータを完全分離(エンタープライズSaaSで多い)
  • アクセス制御: スキーマ単位で権限を設定し、ユーザーごとにアクセス範囲を制限
  • 拡張機能の分離: pgvectorなどの拡張を専用スキーマに配置

多くのプロジェクトではpublicスキーマに全テーブルを置くのが一般的です。

個人プロダクトでの使い方

-- デフォルトはpublicスキーマ
SELECT * FROM public.users;

-- 別スキーマを作成
CREATE SCHEMA app_auth;
SELECT * FROM app_auth.users;

私が個人開発しているMemoreruでは、最初はすべてのテーブルをpublicスキーマに置いていました。しかし、テーブル数が増えてくると問題が出てきました。

publicスキーマの問題:

  • テーブル数が増えると見通しが悪くなる
  • どのテーブルがどの機能に属するか分かりにくい
  • 権限管理が複雑になる

そこで、機能ごとにスキーマを分割することにしました。

app_プレフィックスの意図

スキーマ名にapp_というプレフィックスをつけているのには理由があります。

Supabaseにはauthstoragerealtimeなど、システムが使用するスキーマがあります。自分で作成したスキーマにapp_(applicationの略)をつけることで、「アプリケーション用のスキーマ」であることを示しつつ、Supabaseのシステムスキーマと明確に区別できます。pgAdminでスキーマを見たとき、並び順でapp_が先頭に来るため、自分で作ったスキーマだと一目で分かります。

Supabaseのシステムスキーマ:
├── auth        # Supabase認証
├── storage     # Supabaseストレージ
├── public      # デフォルト

アプリケーション用スキーマ:
├── app_auth    # 自作:認証
├── app_billing # 自作:課金
├── app_content # 自作:コンテンツ

Memoreruでは、Supabaseのシステムスキーマ(auth, storage等)は使用せず、すべて自作のスキーマで管理しています。将来的に別のインフラに移行する際のベンダーロックインを回避できることを考慮した設計です。

📂 スキーマ分割の設計

現在のMemoreruでは、以下のスキーマを使用しています。

スキーマ名 責務 主なテーブル例
app_admin 管理機能 tenants, teams, members
app_ai AI機能 embeddings, search_vectors
app_auth 認証 users, sessions, accounts
app_billing 課金 subscriptions, payment_history
app_content コンテンツ管理 contents, pages, tables
app_social ソーシャル機能 bookmarks, comments, reactions
app_system システム activity_logs, system_logs

分割の基準

スキーマ分割の基準として、以下を意識しています。

1. 機能の凝集度
関連するテーブルは同じスキーマに配置します。例えば、認証関連のテーブル(users, sessions, accounts)はapp_authに集約します。

2. 変更頻度
頻繁に変更が発生するテーブルと、安定しているテーブルを分けます。例えば、コンテンツ系は変更が多く、課金系は安定している傾向があります。

3. 権限の境界
異なる権限レベルが必要なテーブルは別スキーマにします。管理者のみがアクセスするapp_adminと、一般ユーザーがアクセスするapp_contentは分けています。

🔧 Drizzle ORMでのスキーマ定義

Memoreruでは、Drizzle ORMを使用しています。スキーマの定義は以下のように行います。

// database/app_auth/schema.ts
import { pgSchema } from 'drizzle-orm/pg-core';

export const appAuth = pgSchema('app_auth');
// database/app_auth/users.ts
import { appAuth } from './schema';
import { text, timestamp, uuid } from 'drizzle-orm/pg-core';

export const users = appAuth.table('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull(),
  email: text('email').notNull(),
  createdAt: timestamp('created_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow(),
});

アプリケーション側のディレクトリ構成

DBのスキーマ構造に合わせて、アプリケーション側もディレクトリを分けています。

src/database/
├── app_auth/
│   ├── schema.ts      # スキーマ定義
│   ├── users.ts       # テーブル定義
│   ├── sessions.ts
│   └── index.ts       # エクスポート
├── app_billing/
│   ├── schema.ts
│   ├── subscriptions.ts
│   └── index.ts
├── index.ts           # 全エクスポート
└── relations.ts       # リレーション定義

この構成により、どのテーブルがどのスキーマに属するか一目で分かります。

📊 正規化の実践

スキーマ分割と合わせて、正規化も重要です。個人開発でも基本的な正規化は守るべきですが、過度な正規化はパフォーマンスに影響します。

正規化の基本ルール

正規化には第1〜第3正規形がありますが、要点は以下の3つです。

  • 第1正規形: 繰り返しを排除(カンマ区切りではなく中間テーブルで管理)
  • 第2正規形: 部分関数従属を排除(主キーの一部にのみ依存するカラムを分離)
  • 第3正規形: 推移的関数従属を排除(導出可能な値は保存せず計算)

個人プロダクトでの採用例

  • 中間テーブルの活用: コンテンツとタグの多対多関係を中間テーブルで管理
  • 複合主キーの採用: マルチテナント対応のため(tenant_id, id)の複合主キーを採用
  • 非正規化の採用: 読み取り最適化のため、ブックマーク数やコメント数をカウントカラムとして保持

🔐 RLS(Row Level Security)とスキーマ

スキーマ分割はRow Level Security(RLS)の設計とも関連します。RLSはPostgreSQLの機能で、テーブル単位で行レベルのアクセス制御を行います。スキーマごとに権限ポリシーを設計できます。

Memoreruでは現在、RLSではなくアプリケーション側でアクセス制御を行っていますが、将来的にRLSを導入する場合、スキーマ分割されていると権限設計がしやすくなります。

💡 実践Tips

カスタムスキーマへのアクセス設定

Supabaseでカスタムスキーマを使う場合、いくつかの設定が必要です。これを忘れるとハマります。

1. Supabaseダッシュボードでスキーマを公開

Project Settings → Data API → Exposed schemas に、使用するスキーマを追加します。設定場所がわかりづらく、私もよく忘れます。

public, app_admin, app_ai, app_auth, app_billing, app_content, app_social, app_system

詳細は公式ドキュメントを参照してください。

https://supabase.com/docs/guides/database/connecting-to-postgres#data-apis

2. DATABASE_URLでスキーマを指定

DATABASE_URLのschemaパラメータにカスタムスキーマを含める必要があります。

# .env.local
DATABASE_URL=postgresql://user:password@host:5432/postgres?schema=public,app_admin,app_ai,app_auth,app_billing,app_content,app_social,app_system

3. Drizzle ORMのschemaFilterを設定

drizzle.config.tsschemaFilterオプションを指定します。

// drizzle.config.ts
export default {
  schema: [
    './src/database/app_admin/index.ts',
    './src/database/app_auth/index.ts',
    './src/database/app_billing/index.ts',
    // ...
  ],
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  // 複数スキーマ対応
  schemaFilter: ['public', 'app_admin', 'app_ai', 'app_auth', 'app_billing', 'app_content', 'app_social', 'app_system'],
} satisfies Config;

これらを設定しないと「テーブルが見つからない」というエラーになります。

✅ まとめ

Memoreruでのスキーマ設計から得た学びをまとめます。

うまくいっていること:

  • 機能ごとにスキーマを分けて責務を明確化
  • ディレクトリ構成とスキーマ名を対応させて可読性向上
  • 基本的な正規化を守りつつ、必要に応じて非正規化

注意が必要なこと:

  • スキーマが増えすぎると逆に複雑になる
  • Supabaseでカスタムスキーマを使う場合はExposed schemasの設定が必要

個人開発でもスキーマ設計を意識することで、将来の拡張性やメンテナンス性が大きく変わります。

明日は「データベースのID設計:ID方式の選択と主キーの考え方」について解説します。


シリーズの他の記事

  • 12/5: Gitブランチ戦略:個人開発で実践するワークフロー
  • 12/7: データベースのID設計:ID方式の選択と主キーの考え方
GitHubで編集を提案

Discussion

nanaperika5460nanaperika5460

いつも記事で勉強させていただいています。
drizzleを使う上で2つ質問をさせてください!
①日付型は取り込む時Date型とString型のどちらにしていますか?(個人的にDate型にしたいけど、フロント-API間でStringになって扱いづらいためString型なのかなと思っています。)
②DrizzleのpgTableで定義した型情報やzodスキーマはクライアントサイドで使用できない(nodejs専用ライブラリのため)と思いますが、クライアントサイドでは別途定義していますか?(私はフロント-API間でzodスキーマの解析処理をしたいと考えています。そもそも前提としてzod等の型チェックライブラリは使用されていますか?)

くまさん最強伝説くまさん最強伝説

ご質問ありがとうございます!

①日付型について
自分の場合はString型で返しています。toISOString()で文字列化して、フロントエンドで表示時にnew Date()で変換する形です。
②Zodスキーマについて
Zodはサーバーサイドのみで使っています(Honoの@hono/zod-openapi)。drizzle-zodは使っていません。

クライアントサイドでは、ZodスキーマではなくTypeScriptの型定義ファイルを共有しています。サーバー側で入力検証していれば、クライアント側は型チェックだけで十分かなと思っています。
tRPCやOpenAPIからの型生成も検討しましたが、現状はシンプルな構成を優先しています。

nanaperika5460nanaperika5460

回答感謝です!
①自分もそうします!
②これまでなんとなくでDB→サーバー→クライアント間でデータのチェックをしていましたが、全部不要っぽいですね…。(CQRS原則?とかに当たるんでしょうか。今後はフォームからの入力だけzodでチェックしようと思います。)

あと、回答を受けて調べてみたんですが、typescriptには開発時のみ型インポートできるimport typeなるものがあるんですね…。
多分drizzleで定義したスキーマを型に変換してクライアントサイド側でimportする流れでしょうか(これでnodejs専用ライブラリの読み込みエラーを回避できる)

めっちゃ参考になりました!ありがとうございます!

くまさん最強伝説くまさん最強伝説

こちらこそ参考になるご質問でした!
import type、自分もClaude Codeに教えてもらいながら実装しています。型情報だけなのでNode.jsライブラリの読み込みエラーは起きないんですよね。
フォーム入力だけZodでチェックする方針、自分も同じ考えです。

1