Firestoreは「保存する形」ではなく「読む形」で設計する:非正規化とサブコレクションの決め方
FirestoreのコレクションをSQL感覚で作ると必ず詰まる。読み取りクエリ起点のデータモデル、非正規化とサブコレクションの判断、複合インデックスと料金、セキュリティルールの落とし穴を実体験で解説。
「とりあえず users、projects、events ってコレクション作っとけばいいでしょ」
そう思って3週間。SaaSの管理画面で「課金中のユーザー一覧を、トライアル終了が近い順に出す」を実装しようとした瞬間、僕は固まりました。クエリがエラーを返す。インデックスが要る。でもそのインデックスを足すと、別の画面のセキュリティルールが通らなくなる。気づけば、最初に決めたコレクション構造をまるごと作り直していました。
Firestoreで詰まる人のほとんどが、SQLのテーブル設計の頭で入ってきます。僕もそうでした。でもFirestoreは、JOINで後からつなぐデータベースじゃない。先に「どの画面が、どのデータを、どう読むか」を決めないと、後から地獄を見るんです。
この記事では、Firestoreのデータモデル設計を「読む形」から組み立てる手順を、僕の失敗込みでまとめます。コレクションとドキュメント、非正規化、サブコレクション vs ルートコレクション、複合インデックス、読み書き課金、セキュリティルール。全部つながっている話なので、順番に行きます。
この記事の要点
- Firestoreは保存したい形ではなく読まれる形から設計する。最初に画面ごとの読み取りクエリ表を作るのが最短ルート。
- 非正規化(同じデータを複数箇所にコピー)は悪ではなく、読み取り回数=料金を減らすための正攻法。
- サブコレクションは「親に紐づくものを時系列で読む」のに強い。横断検索が要るならコレクショングループクエリかルートコレクションを検討。
- 複合インデックスとセキュリティルールはクエリと一致していないと動かない。ルールはフィルターではなく、クエリ全体を許可/拒否する門番。
- 設計の矛盾出しはClaude Codeが得意。「きれいな構造を作って」より「この画面は本当に読めるか検証して」と頼む。
このあたりは、アクセスパターンから組む点でDynamoDBとよく似ています。RDB/DynamoDB側の発想と比べたい人は DynamoDBはアクセスパターンから設計する も合わせて読むと、NoSQL設計の勘所が立体的になります。
コレクション・ドキュメント・サブコレクションを身近な例で
まず用語を、なるべく日本語の感覚に置き換えます。
Firestoreの最小単位はドキュメントです。ドキュメントはJSONに近いキー・バリューの箱。これが必ずコレクションの中に入ります。そしてドキュメントの中には、さらに小さなコレクション(サブコレクション)を持てます。
たとえるなら、こうです。
- コレクション = 本棚(同じ種類の本を並べる場所)
- ドキュメント = 1冊のファイル(中に項目=フィールドが書いてある)
- サブコレクション = そのファイルに挟んだ小さなクリアフォルダ
場所は users/{uid} のように「コレクション名/ドキュメントID」で表します。users だけ、とか {uid} だけ、という指定は基本しません。階層は必ず「コレクション → ドキュメント → コレクション → ドキュメント」と交互に深くなります。
SaaSなら、最初の候補はこんな形になります。
users/{uid}
projects/{projectId}
projects/{projectId}/members/{uid}
projects/{projectId}/events/{eventId}
subscriptions/{uid}
billingCustomers/{uid}
それぞれの役割と「どう読まれるか」をセットで並べると、設計の意図が見えてきます。
| パス | 役割 | 典型的な読み方 |
|---|---|---|
users/{uid} | 表示名、メール、作成日 | 自分のプロフィール取得 |
projects/{projectId} | ワークスペースや案件 | 所属プロジェクト一覧、詳細 |
projects/{projectId}/members/{uid} | プロジェクトごとの権限 | 参加者確認、role判定 |
projects/{projectId}/events/{eventId} | 操作ログ、通知、監査 | 最近のイベント一覧 |
subscriptions/{uid} | plan、status、trial終了日 | 課金状態による機能制御 |
billingCustomers/{uid} | Stripe等の請求ID | サーバー側の請求処理 |
公式のデータモデルも、この「コレクションの中にドキュメント、ドキュメントの中にフィールドやサブコレクション」という構造を前提に書かれています。ここを押さえると、後の話が一気に通ります。
サブコレクション vs ルートコレクション、どっちを選ぶか
ここが最初の分かれ道です。「イベントを projects/{projectId}/events/{eventId} に入れる(サブコレクション)」のか、「events/{eventId} というルートコレクションにして projectId フィールドで紐づける」のか。
判断軸はひとつだけ。そのデータを、何を起点に読むかです。
サブコレクションが向いているのは、こういう読み方です。
- 「このプロジェクトのイベントを、新しい順に50件」
- 「このユーザーの通知だけ」
- 親が決まっていて、その配下を時系列やフィルタで引く
このとき projects/{projectId}/events を orderBy("createdAt", "desc").limit(50) で読めば、それで終わりです。親パスがそのまま絞り込みになるので速いし安い。
逆にルートコレクションが向くのは、親をまたいで横断検索するケースです。
- 「全プロジェクトを通して、自分が関係する最新イベント」
- 「ステータスが
activeのサブスクを、トライアル終了が近い順に全部」
横断検索をサブコレクションでやろうとすると、各プロジェクトを順番に読む羽目になります。これはコレクショングループクエリ(後述)でも一応できますが、対象の events という名前のサブコレクションを全部巻き込むので、設計を慎重にしないと事故ります。
僕の感覚値はこうです。
| 状況 | 選ぶもの | 理由 |
|---|---|---|
| 親が必ず決まっている時系列ログ | サブコレクション | パスが絞り込みになる、ルールが書きやすい |
| 親をまたいで全件を絞り込みたい | ルートコレクション | 単純なwhere+orderByで済む |
| 親配下が基本だが、たまに横断もしたい | サブコレクション + コレクショングループ | 普段は安く、横断はインデックスで対応 |
| ドキュメントが肥大化しそうな配列 | サブコレクション | 1ドキュメント1MiB上限を避けられる |
最後の「1ドキュメント1MiB」は意外と効きます。コメントやログを親ドキュメントの配列フィールドに溜めると、いつか上限に当たります。増え続けるものはサブコレクションに逃がすのが定石です。
Claude Codeに相談するときも、いきなり「Firestoreスキーマ作って」ではなく、読み方から聞くと精度が上がります。
claude -p "
BtoB SaaSのFirestore設計をレビューしてください。
コレクションを提案する前に、まず画面ごとの読み取りクエリ一覧を作ってください。
画面:
- 自分が所属するプロジェクト一覧
- プロジェクト詳細
- プロジェクト内の最近のイベント50件
- 管理者向けの課金状態一覧
- トライアル中ユーザーへの通知対象抽出
各画面について where / orderBy / limit / 必要な複合インデックス /
セキュリティルールで必要な条件を、1枚の表にしてください。
"
この頼み方だと、Claude Codeは「きれいなデータ構造」ではなく「動く画面」に寄せて考えます。設計の出発点が画面になるので、後の手戻りが激減します。
非正規化は「悪」じゃない:読み取り回数=料金だから
SQLに慣れていると、同じデータを2か所に持つのは罪のように感じます。正規化して、必要なときにJOINで取りに行く。それが正しい、と教わってきました。
Firestoreでは逆です。非正規化(同じ情報を複数箇所にコピーして持つこと)が、むしろ正攻法になります。
理由はシンプルで、Firestoreは読んだドキュメントの数で課金されるからです。公式の料金にある通り、課金はドキュメント単位の読み取り・書き込み・削除回数で決まります。だから、画面を1回出すのに何回読むかが、そのままコストに跳ね返ります。
具体例で見てみましょう。メンバー一覧画面を作るとします。
正規化した場合:projects/{projectId}/members を読んで50人のuidを取得 → 各uidについて users/{uid} を取りに行く。これで 1 + 50 = 51回の読み取りです。
非正規化した場合:メンバードキュメントに表示名とメールをコピーして持たせておく。一覧は projects/{projectId}/members を1クエリ読むだけ。51回が1回になります。
型で書くとこうなります。displayName と email を、あえてメンバー側にも持たせています。
import type { Timestamp } from "firebase-admin/firestore";
export type ProjectRole = "owner" | "admin" | "member" | "viewer";
export interface ProjectMemberDoc {
uid: string;
role: ProjectRole;
// ↓ ここが非正規化。users/{uid} のコピーを持つ
displayName: string;
email: string;
joinedAt: Timestamp;
}
もちろんタダではありません。ユーザーが表示名を変えたら、コピーした側も更新する手間が出ます。でも、表示名の変更は「たまに」起きるイベント。一覧表示は「毎回」起きるイベントです。頻度が高い読み取りを安くして、頻度が低い書き込みに手間を寄せる。この判断ができると、Firestore設計はだいぶ楽になります。
同じ発想で、ログイン直後のホーム画面を速くするなら、ユーザー配下に「所属プロジェクトの軽い索引」を持つ手もあります。
users/{uid}/projectRefs/{projectId}
projectId: string
projectName: string
role: "owner" | "admin" | "member" | "viewer"
lastEventAt: Timestamp | null
ホーム画面は users/{uid}/projectRefs を読むだけで済みます。プロジェクト名が重複しますが、ホームのための読み取りが安くなる。これも立派な非正規化です。
判断に迷ったら、こう自問してください。「このコピー、更新漏れが起きたら困るか?」。困らない(多少古くても画面が成立する)なら、非正規化はだいたい勝ちです。
セキュリティルールはフィルターではない(一番ハマる落とし穴)
ここで一度、コストの話を離れて安全の話をします。Firestore初心者が100%引っかかるのが、セキュリティルールの誤解です。
多くの人がこう思っています。「広めにクエリして、見えちゃいけないものはルールが落としてくれる」。
違います。公式のクエリとルールに明記されている通り、ルールはフィルターではなく、クエリ全体を許可するか拒否するかを判定する門番です。条件を1つでも満たさなければ、クエリ全体が拒否されて何も返りません。「読める分だけ返す」という親切はしてくれない。
たとえば、こんなルールを置いたとします。
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;
}
}
}
意味は「プロジェクトのメンバーなら、そのイベントを最大50件まで読める」。ここで request.query.limit <= 50 を要求している点が肝です。クライアントのクエリにも limit がないと、この条件を満たせず拒否されます。
import { collection, getDocs } from "firebase/firestore";
// NG: limitがないので、ルールの request.query.limit <= 50 を満たせず拒否される
await getDocs(collection(db, "projects", projectId, "events"));
正しくは、ルールが求める条件をクエリ側にも明示します。
import {
collection,
getDocs,
limit,
orderBy,
query,
} from "firebase/firestore";
// このクエリ関数は、ローカルエミュレータで動作確認済み
export async function listProjectEvents(db, projectId) {
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() }));
}
同じことは「公開フラグ」でも起きます。ルールに resource.data.visibility == "public" を置いたら、クライアントのクエリにも where("visibility", "==", "public") が要ります。Firestoreが勝手に公開分だけ選んでくれる、ということは起きません。
この誤解は、地味にセキュリティ事故の入り口でもあります。「ルールが落としてくれる」と思って広いクエリを書く → 動かないので、今度はルールをゆるめる → 見せてはいけないデータが漏れる。この流れを、僕は一度やりかけました。ルールはクエリと一対一で噛み合わせる。これが鉄則です。
複合インデックスとコレクショングループを先に設計する
Firestoreは単一フィールドのインデックスを自動で作ってくれます。ただ、複数条件 + 並び替えを組み合わせると、自分で複合インデックスを用意する必要が出ます。
ありがたいことに、不足しているときはエラーメッセージに作成リンクが出ます。公式のインデックス管理にもある挙動です。とはいえ本番でエラーを見てから慌てるより、設計時に firestore.indexes.json に書いておくほうが安全です。
SaaSでよく要るインデックスはこのあたり。
{
"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": []
}
2つ目の COLLECTION_GROUP が、横断検索のためのインデックスです。コレクショングループクエリは、同じIDのサブコレクションをまたいで一度に読む機能。たとえば全プロジェクト配下の events を横断し、特定 projectId のものだけ引くならこう書きます。
import {
collectionGroup,
getDocs,
limit,
orderBy,
query,
where,
} from "firebase/firestore";
export async function listRecentEventsAcrossProjects(db, projectId) {
const eventsQuery = query(
collectionGroup(db, "events"), // events という名前のサブコレクション全部が対象
where("projectId", "==", projectId),
orderBy("createdAt", "desc"),
limit(50),
);
const snap = await getDocs(eventsQuery);
return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}
注意したいのは、横断の対象が「events という名前のサブコレクション全部」になる点です。将来うっかり別用途の events を作ると、このクエリが巻き込みます。横断用のルールも、ドキュメントパスにマッチさせる形で別途必要です。
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));
}
// {path=**} で、どの階層の events サブコレクションにも効かせる
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);
}
}
}
公式のルールの構造にある通り、match はコレクションではなくドキュメントパスを指します。コレクショングループクエリを許可するには rules version 2 と再帰ワイルドカード {path=**} が要ります。
これは必ずエミュレータでテストしてください。僕は昔、監査ログと通知イベントを両方 events という名前にして、横断ルールの意味が曖昧になりました。用途が違うなら auditEvents、notificationEvents のように名前を分けるのが安全です。
課金状態はクライアントに書かせない:サーバー専用にする
設計の総仕上げとして、課金まわりを見ます。subscriptions/{uid} は、クライアントから直接更新させないのが鉄則です。Stripeのwebhookやサーバー側のコードだけが書ける形にします。
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にレビューを頼むときは、「壊したい観点」を具体的に渡すほど鋭くなります。「安全にして」ではなく「一般ユーザーが自分の status を active に書き換える攻撃を防げているか確認して」と書く。後者のほうが、明らかに精度が上がります。
claude -p "
Firestore設計をレビューしてください。
対象: docs/firestore-schema.md / firestore.rules / firestore.indexes.json / src/lib/firestore/queries.ts
観点:
1. 画面ごとのクエリとスキーマが対応しているか
2. セキュリティルールをフィルターのように誤用していないか
3. 必要な where / orderBy / limit がクエリに入っているか
4. 複合インデックスが不足、または過剰ではないか
5. コレクショングループクエリが広すぎないか
6. 課金状態をクライアントから改ざんできないか
7. 読み取り回数が多すぎる画面がないか
問題点 → 修正案 → 修正後コードの順で出してください。
"
ちなみにドキュメントIDは、連番や日付ではなく自動IDを使うのが無難です。URLにslugが要るなら、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(); // 自動IDを採番
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;
}
よくある質問
Q. サブコレクションとルートコレクション、結局どっちが正解? A. データを「何を起点に読むか」で決めます。親が必ず決まる時系列ログはサブコレクション、親をまたいで全件絞り込むならルートコレクション。両方要るなら、サブコレクション+コレクショングループクエリの併用が現実解です。
Q. 非正規化すると、データの整合性が崩れませんか? A. 崩れる可能性はあります。だから「更新漏れが起きても画面が成立するデータ」だけコピーします。表示名のように多少古くてもいい情報は非正規化、金額や権限のように厳密さが要るものは1か所に集約、と切り分けます。
Q. 複合インデックスは最初から全部用意すべき?
A. 主要画面の分は firestore.indexes.json に先に書くのがおすすめです。足りなければFirestoreがエラーURLで教えてくれますが、本番で初めて気づくと体験が悪い。逆に使わないインデックスは書き込みを遅く・高くするので、過剰も避けます。
Q. セキュリティルールだけ書けば、不正アクセスは防げますか? A. 読み取りはルールで守れますが、課金や権限のような重要な書き込みはサーバー側でも検証してください。フロントの表示制御は飾りで、APIを直接叩かれる前提で設計するのが安全です。Firebase全体のルール設計は Firebase開発でデータ流出を防ぐSecurity Rules設計 に詳しくまとめています。
Q. 料金が読み取り回数で決まるなら、何に一番気をつける?
A. 「1画面あたり何ドキュメント読むか」です。一覧画面で関連データをN+1回読んでいないかを最初に疑ってください。非正規化やページネーション(limit)で、1画面の読み取りを定数に近づけるのが効きます。
実際に試した結果
この設計の流れを、問い合わせ管理・記事管理・小さなSaaSデモの3つに当てはめて試しました。
最初にコレクション構造から決めたときは、後から「この一覧に必要な where が足りない」「ルールが list を許可できない」「管理画面だけ別インデックスが要る」という修正が次々出ました。冒頭の作り直しは、まさにこれです。
逆に、最初に画面ごとのクエリ表をClaude Codeに作らせてからスキーマを起こすと、設計の穴がかなり早い段階で見つかりました。とくに効いたのは、セキュリティルールを同時にレビューさせること。Firestoreはスキーマ・クエリ・ルール・インデックスが別ファイルに散らばって見えますが、実体は1つの設計です。where / orderBy / limit / allow list / 複合インデックスを1枚の表に並べた瞬間、「この画面、本当に読めるんだっけ?」が一目で分かるようになりました。
Claude Codeは、コードを書かせるより設計の矛盾を見つけるレビュー係として使うのが、Firestoreでは断然向いています。
サーバー側でどこまで守るかを詰めたいなら、Claude Code x GCP Cloud Functions実装ガイド と Claude Code x GCP Cloud Run実装ガイド が地続きです。API設計から見直したい場合は Claude CodeでREST APIを設計・実装・テストする が近い内容です。
設計レビューの型は、ClaudeCodeLabの教材一覧でもPDFとして整理しています。「自分のFirestore設計が危なくないか見てほしい」という段階なら、まず教材を眺めてから相談してもらえると、話が具体的なところまで一気に進みます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。