Use Cases (更新: 2026/6/1)

Claude CodeでFirestoreスキーマ設計: SaaSを壊さないGCP/Firebase実装ガイド

Claude CodeでFirestoreのcollection、rules、indexを設計する実践手順。SaaSの失敗例つき。

Claude CodeでFirestoreスキーマ設計: SaaSを壊さないGCP/Firebase実装ガイド

Firestore設計は「どのデータを保存するか」より「どう読むか」から始める

claudecode-lab.comを運営しているMasaです。

Firestoreを初めて触ると、まず usersprojectsevents みたいにコレクション名を決めたくなります。SQLのテーブル設計に慣れていると自然な発想です。ただ、SaaSを作る場合はこの順番だとかなりの確率で詰まります。

Firestoreは、公式のデータモデルにもある通り、テーブルではなく「collectionの中にdocumentがあり、documentの中にfieldやsubcollectionを持てる」データベースです。言い換えると、collectionは本棚、documentは1冊のファイル、subcollectionはそのファイルの中にある小さなフォルダです。

この構造は柔軟ですが、あとからJOINで整えるタイプではありません。画面、権限、課金状態、indexを先に決めないと、後から手戻りが増えます。

この記事では、Claude Codeを設計レビュー役にして、GCP/FirebaseのFirestoreスキーマをSaaS向けに組む手順をまとめます。対象は、ユーザー、プロジェクト、イベント、課金状態を持つ一般的なBtoB SaaSです。collection/document/subcollection、Rules、index、TypeScript例まで一気に扱います。

結論はシンプルです。Firestoreは、保存したい形ではなく読まれる形から設計します。Claude Codeには画面、クエリ、Rules、indexを並べて矛盾を見つけてもらいます。


まずcollection、document、subcollectionを平易に整理する

Firestoreの最小単位はdocumentです。documentはJSONに近いkey-valueの箱で、必ずcollectionの中に置きます。collectionだけを作る、documentだけを作る、という考え方ではなく、users/{uid} のように「collection名/document ID」で場所を表します。

SaaSなら、最初の候補はこうなります。

users/{uid}
projects/{projectId}
projects/{projectId}/members/{uid}
projects/{projectId}/events/{eventId}
subscriptions/{uid}
billingCustomers/{uid}

それぞれの役割はこうです。

パス役割典型的な読み方
users/{uid}ユーザーの表示名、メール、作成日自分のプロフィール取得
projects/{projectId}SaaS上のワークスペースや案件所属プロジェクト一覧、詳細
projects/{projectId}/members/{uid}プロジェクトごとの権限参加者確認、role判定
projects/{projectId}/events/{eventId}操作ログ、通知、監査イベント最近のイベント一覧
subscriptions/{uid}plan、status、trial終了日課金状態による機能制御
billingCustomers/{uid}Stripeや請求管理のIDサーバー側の請求処理

ここで大事なのは、subcollectionを使う理由です。projects/{projectId}/events/{eventId} にすると、「あるプロジェクトのイベントを時系列で読む」画面が作りやすくなります。一方で、全プロジェクト横断で「自分が関係する最新イベント」を読みたい場合は、collection group queryを検討します。

Claude Codeには、いきなり「Firestoreスキーマを作って」と頼むより、次のように聞くのが安全です。

claude -p "
BtoB SaaSのFirestore設計をレビューしてください。
まずcollectionを提案する前に、画面ごとの読み取りクエリ一覧を作ってください。

画面:
- 自分が所属するプロジェクト一覧
- プロジェクト詳細
- プロジェクト内の最近のイベント50件
- 管理者向けの課金状態一覧
- trial中ユーザーへの通知対象抽出

各画面について where / orderBy / limit / 必要なComposite index / Security Rulesで必要な条件を表にしてください。
"

この依頼にすると、Claude Codeは「きれいなデータ構造」ではなく「動く画面」に寄せて考えやすくなります。


SaaS向けの基本スキーマ例

まずはサーバー側、つまりFirebase Admin SDKやCloud Functionsから書き込む前提のTypeScript型です。フロントから直接書かせる場合でも、この型を基準にして入力検証を作ると事故が減ります。

import type { Timestamp } from "firebase-admin/firestore";

export type ProjectRole = "owner" | "admin" | "member" | "viewer";
export type SubscriptionStatus =
  | "trialing"
  | "active"
  | "past_due"
  | "canceled";

export interface UserDoc {
  uid: string;
  email: string;
  displayName: string;
  createdAt: Timestamp;
  updatedAt: Timestamp;
}

export interface ProjectDoc {
  id: string;
  name: string;
  ownerUid: string;
  plan: "free" | "starter" | "pro";
  memberCount: number;
  lastEventAt: Timestamp | null;
  createdAt: Timestamp;
  updatedAt: Timestamp;
}

export interface ProjectMemberDoc {
  uid: string;
  role: ProjectRole;
  displayName: string;
  email: string;
  joinedAt: Timestamp;
}

export interface ProjectEventDoc {
  id: string;
  projectId: string;
  actorUid: string;
  actorName: string;
  type: "created" | "updated" | "commented" | "exported";
  message: string;
  createdAt: Timestamp;
}

export interface SubscriptionDoc {
  uid: string;
  status: SubscriptionStatus;
  plan: "free" | "starter" | "pro";
  currentPeriodEnd: Timestamp | null;
  trialEndsAt: Timestamp | null;
  updatedAt: Timestamp;
}

ProjectMemberDocdisplayNameemail を持たせているのは、あえての非正規化です。非正規化とは、同じ情報を複数箇所にコピーして持つことです。SQLの感覚だと気持ち悪いですが、Firestoreでは一覧表示の読み取り回数を減らすためによく使います。

例えばメンバー一覧で毎回 users/{uid} を追加取得すると、50人のメンバー表示で51回読むことになります。projects/{projectId}/members/{uid} に表示用の名前を持たせれば、1クエリで一覧が出ます。名前変更時に同期する手間は増えますが、画面の速さと料金の見通しは良くなります。

実例1つ目は、プロジェクト一覧です。ユーザーごとに所属プロジェクトを引く必要があるなら、users/{uid}/projectRefs/{projectId} を持つ選択肢もあります。

users/{uid}/projectRefs/{projectId}
  projectId: string
  projectName: string
  role: "owner" | "admin" | "member" | "viewer"
  lastEventAt: Timestamp | null

これにより、ログイン直後のホーム画面は users/{uid}/projectRefs を読むだけで済みます。プロジェクト名の重複はありますが、ホーム画面のための読み取りが安くなります。


Security Rulesはフィルターではない

Firestore初心者が一番ハマるのがここです。公式のsecurely query dataでは、Security Rulesはフィルターではなく、クエリはall or nothingで評価されると説明されています。

つまり、次のルールがあるとします。

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /projects/{projectId}/events/{eventId} {
      allow list: if request.auth != null
        && exists(/databases/$(database)/documents/projects/$(projectId)/members/$(request.auth.uid))
        && request.query.limit <= 50;
    }
  }
}

このルールは「プロジェクトメンバーなら、そのプロジェクトのeventsを最大50件まで読める」という意味です。しかし、クライアント側で次のような雑なクエリを書くと失敗します。

import { collection, getDocs } from "firebase/firestore";

// NG: limitがなく、ルールの条件を満たす保証がない
await getDocs(collection(db, "projects", projectId, "events"));

ルール側で request.query.limit <= 50 を求めているなら、クエリにもlimitを付けます。

import {
  collection,
  getDocs,
  limit,
  orderBy,
  query,
} from "firebase/firestore";

export async function listProjectEvents(projectId: string) {
  const eventsRef = collection(db, "projects", projectId, "events");
  const eventsQuery = query(
    eventsRef,
    orderBy("createdAt", "desc"),
    limit(50),
  );

  const snap = await getDocs(eventsQuery);
  return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}

実例2つ目は、公開記事やイベントの一覧です。Rulesで resource.data.visibility == "public" のような条件を置いた場合、クライアントのクエリにも where("visibility", "==", "public") が必要になります。Firestoreが勝手に「読めるものだけ返す」わけではありません。

この誤解はセキュリティ事故にもつながります。Rulesをフィルターだと思っていると、「とりあえず広くクエリして、見えないものはRulesが落としてくれる」と考えてしまうからです。実際にはクエリ全体が拒否されるか、逆にRulesが広すぎれば見せてはいけないデータを見せてしまいます。


Composite indexとcollection group queryを先に設計する

Firestoreは基本的なindexを自動で持ちますが、複数条件と並び替えを組み合わせるとComposite indexが必要になります。公式のindex管理にも、必要なindexがないクエリではエラーから作成リンクが出るとあります。

SaaSでよく使うindexはこのあたりです。

{
  "indexes": [
    {
      "collectionGroup": "projectRefs",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "role", "order": "ASCENDING" },
        { "fieldPath": "lastEventAt", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "events",
      "queryScope": "COLLECTION_GROUP",
      "fields": [
        { "fieldPath": "projectId", "order": "ASCENDING" },
        { "fieldPath": "createdAt", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "subscriptions",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "status", "order": "ASCENDING" },
        { "fieldPath": "trialEndsAt", "order": "ASCENDING" }
      ]
    }
  ],
  "fieldOverrides": []
}

collection group queryは、同じIDのsubcollectionを横断して読む機能です。例えば全プロジェクト配下の events を横断して、特定projectIdのイベントだけ読むならこう書きます。

import {
  collectionGroup,
  getDocs,
  limit,
  orderBy,
  query,
  where,
} from "firebase/firestore";

export async function listRecentEventsAcrossProjects(projectId: string) {
  const eventsQuery = query(
    collectionGroup(db, "events"),
    where("projectId", "==", projectId),
    orderBy("createdAt", "desc"),
    limit(50),
  );

  const snap = await getDocs(eventsQuery);
  return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}

注意点は、collection group query用のSecurity Rulesです。公式のRules構造では、matchはcollectionではなくdocument pathを指す必要があると説明されています。またcollection group queryを許可するには、rules version 2と再帰ワイルドカードを使う設計が必要です。

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    function signedIn() {
      return request.auth != null;
    }

    function isProjectMember(projectId) {
      return signedIn()
        && exists(/databases/$(database)/documents/projects/$(projectId)/members/$(request.auth.uid));
    }

    match /{path=**}/events/{eventId} {
      allow list: if signedIn()
        && request.query.limit <= 50
        && resource.data.projectId is string
        && isProjectMember(resource.data.projectId);

      allow get: if signedIn()
        && resource.data.projectId is string
        && isProjectMember(resource.data.projectId);
    }
  }
}

このルールは必ずEmulatorでテストしてください。collection group queryは便利ですが、events という名前のcollectionすべてに効くため、別用途の events を将来作ると巻き込みます。私はここで一度、監査ログと通知イベントを同じ events 名にしてしまい、Rulesの意味が曖昧になりました。用途が違うなら auditEventsnotificationEvents のように分ける方が安全です。


Claude Codeにローカル設計レビューを頼むプロンプト

Firestore設計では、コードを書かせる前のレビューが効きます。私はリポジトリ内に docs/firestore-schema.mdfirestore.rulesfirestore.indexes.json、クエリ関数を置いた状態で、Claude Codeに次のように依頼します。

claude -p "
Firestore設計をレビューしてください。
対象ファイル:
- docs/firestore-schema.md
- firestore.rules
- firestore.indexes.json
- src/lib/firestore/queries.ts

観点:
1. 画面ごとのクエリとschemaが対応しているか
2. Security Rulesをフィルターのように誤用していないか
3. 必要なwhere/orderBy/limitがクエリに入っているか
4. Composite indexが不足または過剰ではないか
5. collection group queryが広すぎないか
6. 課金状態をクライアントから改ざんできないか
7. 読み取り回数が多すぎる画面がないか

問題点、修正案、修正後コードを順番に出してください。
"

実例3つ目は課金状態です。subscriptions/{uid} はクライアントから直接更新させない方が安全です。Stripe webhookやCloud Functionsなど、サーバー側だけが書く形にします。

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    function signedIn() {
      return request.auth != null;
    }

    match /subscriptions/{uid} {
      allow get: if signedIn() && request.auth.uid == uid;
      allow list: if false;
      allow create, update, delete: if false;
    }
  }
}

そして、機能制限はサーバー側でも確認します。フロントで「Proプランならボタン表示」だけにすると、APIを直接叩かれたときに抜けます。

import { getFirestore } from "firebase-admin/firestore";

const db = getFirestore();

export async function assertActiveSubscription(uid: string) {
  const snap = await db.collection("subscriptions").doc(uid).get();
  const data = snap.data();

  if (!data || !["trialing", "active"].includes(data.status)) {
    throw new Error("Active subscription required");
  }

  return data;
}

Claude Codeには、このような「壊したい観点」を渡すほど良いレビューになります。「安全にして」ではなく、「一般ユーザーが statusactive に変える攻撃を防いで」と書く方が精度が上がります。


よくある失敗例と直し方

失敗例1は、document IDを連番や日付にすることです。Firestoreでは自動IDを使う方が無難です。slugが必要ならdocument IDとは分けます。

import { FieldValue, getFirestore } from "firebase-admin/firestore";

const db = getFirestore();

export async function createProject(name: string, ownerUid: string) {
  const projectRef = db.collection("projects").doc();

  await projectRef.set({
    id: projectRef.id,
    name,
    ownerUid,
    plan: "free",
    memberCount: 1,
    lastEventAt: null,
    createdAt: FieldValue.serverTimestamp(),
    updatedAt: FieldValue.serverTimestamp(),
  });

  await projectRef.collection("members").doc(ownerUid).set({
    uid: ownerUid,
    role: "owner",
    joinedAt: FieldValue.serverTimestamp(),
  });

  return projectRef.id;
}

失敗例2は、Rulesで守っているつもりなのにクエリがRulesと一致していないことです。Rulesが limit <= 50 を要求するなら、全クエリ関数にlimitを入れます。Rulesが status == "published" を期待するなら、クエリにもwhereを入れます。

失敗例3は、請求情報をユーザーdocumentに混ぜることです。users/{uid}.plan = "pro" のように置くと、プロフィール更新と課金状態更新の境界が曖昧になります。subscriptions/{uid} に分け、書き込みはサーバー専用にする方が安全です。

失敗例4は、events という名前を何にでも使うことです。collection group queryは同じcollection IDを横断します。プロジェクト操作ログ、メール送信ログ、課金webhookログを全部 events にすると、indexもRulesも読みにくくなります。


Masaの検証メモ: 最初にクエリ表を作るだけで手戻りが減った

この記事の流れを、問い合わせ管理、記事管理、簡単なSaaSデモの3つに当てはめて試しました。最初からcollectionを決めたときは、あとで「この一覧に必要なwhereが足りない」「Rulesがlistを許可できない」「管理画面だけindexが別に必要」という修正が出ました。

逆に、最初に画面ごとのクエリ表をClaude Codeに作らせた場合は、スキーマ変更がかなり早い段階で見つかりました。特に効いたのは、Security Rulesを同時にレビューさせることです。Firestoreはスキーマ、クエリ、Rules、indexが分離して見えますが、実際には1つの設計です。

Claude Codeを使うなら、コード生成ツールとしてではなく、設計の矛盾を見つけるレビュー係として使うのが向いています。whereorderBylimitallow listComposite index を1枚の表にすると、初心者でも「この画面は本当に読めるのか」を確認しやすくなります。

Firestore以外のGCP連携も進めるなら、Claude Code x GCP Cloud Functions実装ガイドClaude Code x GCP Cloud Run実装ガイド も合わせて読むと、サーバー側でどこまで守るかが整理しやすいです。API設計から見直したい場合は Claude CodeでREST APIを設計・実装・テストする が近い内容です。

ClaudeCodeLabでは、こうした設計レビューの型を無料PDFや教材として整理しています。Firestore、GCP、Claude Codeの導入で「自分の設計が危ないか見てほしい」という段階なら、教材を読んでから相談してもらえると、面談ではかなり具体的なレビューまで進められます。

#claude-code #gcp #firestore #database #typescript #query-design
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。