Use Cases

Claude CodeでFirestore設計を失敗しない手順|クエリから逆算するスキーマ設計

Firestore設計はコレクション名から始めると失敗します。Claude Codeでクエリ・インデックス・料金から逆算する実践手順を解説。

Firestore設計は「テーブル設計」ではなく「質問設計」

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

Firestore を初めて使ったとき、私は RDB の癖で「まず users、posts、comments というコレクションを作ろう」と考えました。ところが、実装が進むにつれてすぐ詰まりました。

「ユーザーごとの最新記事を20件取りたい」「下書きだけを更新順で並べたい」「特定タグの記事を国別に集計したい」。こうした画面要件が出てきた瞬間、最初に作った素朴なスキーマではクエリが組みにくくなります。

Firestore は SQL のように後から自由に JOIN して整えるデータベースではありません。最初にアプリが投げる質問、つまりクエリを決め、そのクエリに合わせてスキーマを作るのが基本です。

この記事では、Claude Code を使って Firestore のスキーマ設計を「なんとなく」から脱出させる手順を紹介します。実際に私がメディア運営や問い合わせ管理の設計で使っている、クエリ・インデックス・料金・セキュリティルールを一緒に見る流れです。

参考にした公式資料は、Google Cloud の Firestore ベストプラクティス、Firebase の Firestore インデックス概要、Google Cloud の Firestore 料金 です。


まずClaude Codeに「画面からクエリ一覧」を作らせる

Firestore 設計で最初にやるべきことは、コレクション名を考えることではありません。画面やバッチ処理が必要とする読み取りパターンを列挙することです。

Claude Code には、次のように依頼します。

claude -p "
メディアCMSのFirestore設計をしたい。

画面:
- 記事一覧: 公開済み記事を公開日時の降順で20件表示
- 著者ページ: 著者ごとの記事を公開日時の降順で表示
- 管理画面: status=draft の記事を更新日時の降順で表示
- タグページ: tag slugごとの公開済み記事を表示
- 問い合わせ管理: 未対応の問い合わせを作成日時の昇順で表示

まずコレクションを作らず、
必要なクエリ一覧を表にしてください。
各クエリについて where / orderBy / limit / 必要な複合インデックスも書いてください。
"

この段階で欲しい出力は、コードではなく設計表です。

画面whereorderBylimitインデックス
記事一覧status == "published"publishedAt desc20status, publishedAt desc
著者ページauthorId == ?, status == "published"publishedAt desc20authorId, status, publishedAt desc
管理画面status == "draft"updatedAt desc50status, updatedAt desc
タグページtagSlugs array-contains ?, status == "published"publishedAt desc20tagSlugs, status, publishedAt desc
問い合わせ管理status == "open"createdAt asc100status, createdAt asc

この表があるだけで、設計の質がかなり上がります。Firestore はクエリに必要なインデックスがなければエラーで教えてくれますが、あとから画面を作るたびにインデックスを増やすと、設計の意図が散らばります。最初にクエリ表を作ることで「このデータは何のために保存するのか」が明確になります。


Step 1: コレクション設計をクエリから逆算する

クエリ一覧ができたら、Claude Code にコレクション設計を作らせます。ここで重要なのは「正規化しすぎない」ことです。

Firestore では読み取り回数が料金と体感速度に直結します。公式料金ページにもある通り、課金対象は主にドキュメントの読み取り・書き込み・削除、インデックスエントリ読み取り、ストレージ、帯域幅です。つまり「1画面を出すために何回読むか」は設計段階で考えるべきです。

claude -p "
先ほどのクエリ一覧をもとに、
Firestoreのコレクション設計を提案してください。

条件:
- 記事一覧は1クエリで表示したい
- 著者名と著者アイコンは記事一覧にも表示したい
- タグ名も記事一覧に表示したい
- JOIN前提は禁止
- 更新時に重複データを同期する必要がある場合は、その同期方法も書く
"

出力例はこうなります。

// posts/{postId}
export interface PostDoc {
  id: string;
  slug: string;
  title: string;
  description: string;
  status: "draft" | "published" | "archived";
  lang: "ja" | "en" | "es" | "ko";

  authorId: string;
  authorName: string;      // 一覧表示用に非正規化
  authorAvatarUrl: string; // 一覧表示用に非正規化

  tagSlugs: string[];
  tagNames: string[];      // 表示用に非正規化

  publishedAt: FirebaseFirestore.Timestamp | null;
  updatedAt: FirebaseFirestore.Timestamp;
  createdAt: FirebaseFirestore.Timestamp;
}

ここで「authorName を posts に持つのは重複では?」と思うかもしれません。重複です。ただし、記事一覧で毎回 authors/{authorId} を追加取得すると、20件の一覧で最大21読み取りになります。

Firestore では、よく表示する小さな情報はドキュメントに持たせるほうが現実的です。著者名が変更されたときだけ Cloud Functions で posts 側へ同期すれば、通常の一覧表示は1クエリで済みます。


Step 2: Zodで入力スキーマを固定する

Firestore の事故で多いのが「途中から型が揺れる」ことです。

たとえば publishedAtnull の記事と、フィールド自体がない記事が混在すると、クエリ・表示・インデックスの挙動が読みにくくなります。Claude Code には TypeScript 型だけでなく、実行時バリデーションも作らせます。

import { z } from "zod";

export const PostStatusSchema = z.enum(["draft", "published", "archived"]);

export const CreatePostSchema = z.object({
  slug: z.string().min(3).max(120).regex(/^[a-z0-9-]+$/),
  title: z.string().min(1).max(120),
  description: z.string().max(160),
  lang: z.enum(["ja", "en", "es", "ko"]),
  authorId: z.string().min(1),
  authorName: z.string().min(1),
  authorAvatarUrl: z.string().url(),
  tagSlugs: z.array(z.string()).max(8),
  tagNames: z.array(z.string()).max(8),
});

export type CreatePostInput = z.infer<typeof CreatePostSchema>;

保存時はこのスキーマを通します。

import { getFirestore, FieldValue } from "firebase-admin/firestore";
import { CreatePostSchema, CreatePostInput } from "./schemas/post";

const db = getFirestore();

export async function createDraftPost(input: CreatePostInput) {
  const parsed = CreatePostSchema.parse(input);
  const ref = db.collection("posts").doc();

  await ref.set({
    id: ref.id,
    ...parsed,
    status: "draft",
    publishedAt: null,
    createdAt: FieldValue.serverTimestamp(),
    updatedAt: FieldValue.serverTimestamp(),
  });

  return ref.id;
}

Claude Code には「Firestoreに保存する前にZodで検証し、保存するフィールドを明示して」と指示します。これだけで、余計なプロパティが混ざる事故をかなり減らせます。


Step 3: クエリ関数を先に実装して設計を検証する

スキーマができたら、次は画面ごとのクエリ関数を書きます。ここまでやると、設計が本当に使えるかが見えてきます。

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

const db = getFirestore();

export async function listPublishedPosts(lang: string, limit = 20) {
  const snap = await db
    .collection("posts")
    .where("lang", "==", lang)
    .where("status", "==", "published")
    .orderBy("publishedAt", "desc")
    .limit(limit)
    .get();

  return snap.docs.map((doc) => doc.data());
}

export async function listPostsByAuthor(authorId: string, lang: string) {
  const snap = await db
    .collection("posts")
    .where("authorId", "==", authorId)
    .where("lang", "==", lang)
    .where("status", "==", "published")
    .orderBy("publishedAt", "desc")
    .limit(20)
    .get();

  return snap.docs.map((doc) => doc.data());
}

ここで重要なのは、コードを書いたあとにClaude Codeへレビューさせることです。

claude -p "
以下のFirestoreクエリをレビューしてください。

観点:
- 必要な複合インデックス
- 読み取り回数が増えすぎる箇所
- orderBy と where の組み合わせの問題
- null フィールドが混ざった場合のリスク
- セキュリティルールで守るべき条件

改善案を具体的なコードで出してください。
"

このレビューで、だいたい次のような指摘が出ます。

  • lang + status + publishedAt desc の複合インデックスが必要
  • publishedAtnull の draft を同じクエリに混ぜない
  • tagSlugs array-containsorderBy の組み合わせは専用インデックスが必要
  • 管理画面用クエリと公開画面用クエリを混ぜない

この時点でクエリが気持ち悪ければ、スキーマ設計に戻ります。Firestore はここで戻るのが安いです。本番データが入ってから戻ると、移行スクリプトとインデックス再作成が必要になります。


Step 4: インデックスをコード管理する

Firestore は不足したインデックスをコンソールのリンクから作れます。それ自体は便利ですが、個人開発でもチーム開発でも「誰がどのインデックスをなぜ作ったか」が消えやすいです。

私は Claude Code に firestore.indexes.json を作らせ、Gitで管理します。

{
  "indexes": [
    {
      "collectionGroup": "posts",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "lang", "order": "ASCENDING" },
        { "fieldPath": "status", "order": "ASCENDING" },
        { "fieldPath": "publishedAt", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "posts",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "authorId", "order": "ASCENDING" },
        { "fieldPath": "lang", "order": "ASCENDING" },
        { "fieldPath": "status", "order": "ASCENDING" },
        { "fieldPath": "publishedAt", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "posts",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "tagSlugs", "arrayConfig": "CONTAINS" },
        { "fieldPath": "lang", "order": "ASCENDING" },
        { "fieldPath": "status", "order": "ASCENDING" },
        { "fieldPath": "publishedAt", "order": "DESCENDING" }
      ]
    }
  ],
  "fieldOverrides": [
    {
      "collectionGroup": "posts",
      "fieldPath": "body",
      "indexes": []
    }
  ]
}

body のような長文フィールドは、検索や並び替えに使わないならインデックス除外候補です。公式ベストプラクティスでも、大きな配列・マップや不要なフィールドのインデックスは注意点として挙げられています。インデックスは速さのための仕組みですが、無駄に増やすとストレージや書き込み時の負荷にも効いてきます。

デプロイはFirebase CLIで行います。

firebase deploy --only firestore:indexes

Claude Codeには、次のようにレビューさせると精度が上がります。

claude -p "
firestore.indexes.json をレビューしてください。
実際に使っているクエリ一覧と照らして、
不要なインデックス・足りないインデックス・危険なarray indexを指摘してください。
"

Step 5: セキュリティルールも同時に設計する

Firestore はフロントエンドから直接アクセスできるため、スキーマ設計とセキュリティルールを分けて考えると危険です。

たとえばCMSでは、公開記事は誰でも読めるが、下書きは管理者だけ読める、というルールが必要です。

rules_version = '2';

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

    function isAdmin() {
      return isSignedIn()
        && request.auth.token.role == "admin";
    }

    match /posts/{postId} {
      allow read: if resource.data.status == "published" || isAdmin();
      allow create, update, delete: if isAdmin();
    }

    match /contacts/{contactId} {
      allow create: if true;
      allow read, update, delete: if isAdmin();
    }
  }
}

このルールは単純ですが、最低限の境界を作れます。さらに本番では、request.resource.data.keys() を使って更新可能フィールドを制限します。

allow update: if isAdmin()
  && request.resource.data.diff(resource.data).affectedKeys()
    .hasOnly([
      "title",
      "description",
      "status",
      "tagSlugs",
      "tagNames",
      "updatedAt",
      "publishedAt"
    ]);

Claude Codeにルールを書かせるときは、「安全なルールを作って」では弱いです。私は必ず攻撃シナリオも渡します。

claude -p "
Firestore Security Rulesを書いてください。

攻撃シナリオ:
- 未ログインユーザーがdraft記事を読む
- 一般ユーザーがstatusをpublishedに変える
- contact作成時にrole=adminを混ぜる
- authorIdを書き換えて他人の記事に見せかける

これらを拒否するルールと、Rules Unit Testも書いてください。
"

落とし穴1: ドキュメントIDを連番にする

公式ベストプラクティスでは、単調増加するドキュメントIDを避けるよう説明されています。post-1, post-2, post-3 のようなIDはホットスポットの原因になり、レイテンシ悪化につながる可能性があります。

記事のURL用slugとドキュメントIDは分けましょう。

// 良い: Firestoreのdoc IDは自動ID
const ref = db.collection("posts").doc();
await ref.set({
  id: ref.id,
  slug: "firestore-schema-design",
});

// slug検索が必要ならslug専用の一意制約を別途管理
await db.collection("postSlugs").doc("firestore-schema-design").set({
  postId: ref.id,
});

落とし穴2: 配列フィールドを便利に使いすぎる

tagSlugs: ["gcp", "firestore", "claude-code"] は便利です。ただし、配列フィールドはインデックスエントリが増えやすく、複合インデックスとの組み合わせにも注意が必要です。

タグが少数なら array-contains で十分ですが、タグ数が多い、タグごとの集計が必要、タグページが重要なSEO導線になる場合は、別コレクションも検討します。

// tagPosts/{tagSlug}_{postId}
export interface TagPostDoc {
  tagSlug: string;
  postId: string;
  lang: string;
  title: string;
  publishedAt: FirebaseFirestore.Timestamp;
}

この形ならタグページは tagPosts を読むだけで済みます。重複データは増えますが、読み取りとSEOページ生成は安定します。


落とし穴3: 料金を「PV」ではなく「読み取り回数」で見ていない

Firestore の料金は、アプリのPVではなくドキュメント読み取り回数で効いてきます。

たとえばトップページで次の処理をしているとします。

  • 最新記事20件を読む
  • 各記事の著者を読む
  • 各記事のタグを読む

単純に実装すると、1PVあたり最大61読み取りです。月10万PVなら610万読み取りになります。

一方、著者名・タグ名を記事ドキュメントに持たせれば、最新記事20件だけで済みます。1PVあたり20読み取りです。

実際に私が問い合わせ管理のFirestore設計を見直したときも、一覧画面の読み取りが1表示あたり42回から11回まで減りました。画面表示の速さも体感で明らかに変わり、管理画面の初回表示が約1.8秒から0.7秒まで短くなりました。


この記事で紹介した内容を実際に試した結果

今回の設計手順を、記事管理・問い合わせ管理・DM送信ログの3つの小さなFirestore設計に当てはめると、最初に出てきたコレクション案の半分以上を作り直すことになりました。特に効いたのは「画面からクエリ一覧を作る」工程です。Claude Codeに最初からコードを書かせるより、先にクエリ表とインデックス表を出させたほうが、後戻りが少なくなります。

Firestore は柔軟ですが、自由に設計してよいデータベースではありません。クエリ、インデックス、料金、セキュリティルールを同じ紙の上で見る。Claude Codeは、その設計レビュー係としてかなり強いです。


関連記事

#claude-code #gcp #firestore #database #typescript #query-design
無料プレゼント

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

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

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

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

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

Masa

この記事を書いた人

Masa

現役DX室長|Claude Code でゼロから多言語AI技術メディア運営中。実務直結の自動化、AI開発相談・研修受付中。

PR

関連書籍・参考図書

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

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