Use Cases (Mis à jour: 01/06/2026)

Concevoir un schéma Firestore avec Claude Code: guide GCP/Firebase pour SaaS

Concevez collections, règles, index et modèle SaaS Firestore avec Claude Code sans pièges de sécurité.

Concevoir un schéma Firestore avec Claude Code: guide GCP/Firebase pour SaaS

Un bon schéma Firestore commence par les lectures

Je suis Masa, l’opérateur de claudecode-lab.com.

Ma première erreur avec Firestore a été classique: j’ai commencé par nommer des collections. users, projects, events, subscriptions semblaient propres. Puis les vrais écrans SaaS sont arrivés: tableau de bord, liste des membres, journal d’activité, état d’abonnement, rappels de fin d’essai, interface admin. Le modèle paraissait rangé, mais les requêtes ne correspondaient pas au produit.

Firestore n’est pas une base relationnelle où l’on corrige tout plus tard avec des JOIN. La documentation officielle du modèle de données explique que les données sont stockées dans des documents, eux-mêmes organisés dans des collections. Un document peut avoir des champs, des objets imbriqués et des sous-collections. Une image simple: la collection est une étagère, le document est un dossier, la sous-collection est un petit dossier attaché au dossier principal.

Le bon ordre est donc: écrans, requêtes, schéma, règles, index. Claude Code est utile comme relecteur local. Je ne lui demande pas seulement de générer du code; je lui demande de trouver les incohérences entre firestore.rules, firestore.indexes.json, les types TypeScript et les fonctions de requête.


Collections, documents et sous-collections pour un SaaS

Pour un petit SaaS B2B, je démarre souvent avec ces chemins:

users/{uid}
projects/{projectId}
projects/{projectId}/members/{uid}
projects/{projectId}/events/{eventId}
subscriptions/{uid}
billingCustomers/{uid}
CheminRôleLecture typique
users/{uid}Profil utilisateurProfil courant
projects/{projectId}Workspace ou projet clientDétail du projet
projects/{projectId}/members/{uid}Rôle dans le projetListe et contrôle d’accès
projects/{projectId}/events/{eventId}Activité, audit, notificationsDerniers événements
subscriptions/{uid}Plan et statut de paiementActivation de fonctionnalités
billingCustomers/{uid}Identifiants Stripe ou facturationJobs serveur uniquement

Une sous-collection n’est pas une décoration. Elle doit correspondre à un accès fréquent. Si l’écran principal affiche les 50 derniers événements d’un projet, projects/{projectId}/events est naturel. Si vous avez aussi besoin d’une vue transversale, une collection group query peut être ajoutée, mais elle impose des règles et des index spécifiques.

Prompt que j’utilise avec Claude Code:

claude -p "
Relis le design Firestore d'un SaaS B2B.
Avant de proposer les collections, liste les requêtes par écran.

Écrans:
- Projets de l'utilisateur connecté
- Détail d'un projet
- 50 derniers événements d'un projet
- Liste admin des abonnements
- Utilisateurs dont l'essai se termine bientôt

Pour chaque écran, indique where/orderBy/limit,
les index composites nécessaires et la condition Security Rules.
"

Ce prompt force Claude Code à raisonner depuis le produit réel.


Exemple de schéma SaaS utilisable

Voici un modèle côté serveur pour Firebase Admin SDK ou Cloud Functions. Même si certaines écritures viennent du client, ces interfaces donnent un contrat clair.

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

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

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";
  trialEndsAt: Timestamp | null;
  updatedAt: Timestamp;
}

Les champs displayName et email dans ProjectMemberDoc sont une dénormalisation volontaire. La dénormalisation consiste à recopier une petite information pour éviter des lectures supplémentaires. Dans Firestore, afficher 50 membres puis lire 50 documents users/{uid} peut coûter cher. Recopier le nom et l’e-mail rend la liste plus rapide; il faut simplement synchroniser ces champs quand le profil change.

Exemple 1: pour le tableau de bord initial, je crée souvent une collection de références par utilisateur.

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

Ce n’est pas le modèle le plus académique, mais il transforme l’écran d’accueil en une lecture simple et prévisible.


Les Security Rules ne sont pas des filtres

C’est le piège le plus dangereux. La documentation officielle sur les requêtes sécurisées précise que les règles ne filtrent pas les résultats. Une requête est acceptée ou refusée entièrement. Si elle peut retourner un document non autorisé, elle échoue.

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;
    }
  }
}

Cette requête est mauvaise, car la règle exige une limite:

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

await getDocs(collection(db, "projects", projectId, "events"));

La requête doit prouver qu’elle respecte la règle:

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

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

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

Exemple 2: si la règle autorise seulement visibility == "public", la requête doit aussi contenir where("visibility", "==", "public"). Firestore ne renvoie pas seulement les documents visibles; il refuse les requêtes qui ne garantissent pas la sécurité.


Index composites et collection group query

Firestore crée des index simples, mais les combinaisons de filtres et de tri demandent souvent des index composites. La page officielle de gestion des index explique qu’une erreur peut fournir un lien pour créer l’index manquant. En équipe, je préfère versionner firestore.indexes.json.

{
  "indexes": [
    {
      "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": []
}

Une collection group query lit toutes les sous-collections qui portent le même ID. Exemple avec les événements de projet:

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() }));
}

Les règles doivent suivre. Le guide officiel de structure des règles rappelle que match pointe vers des documents, pas vers des collections. Pour une collection group query, utilisez rules_version = '2' et un wildcard récursif.

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);
    }
  }
}

Testez toujours avec l’Emulator. Si vous créez plus tard un autre events pour la facturation ou les e-mails, la même collection group query peut le toucher. Des noms précis comme projectEvents ou billingEvents rendent les règles plus lisibles.


Demander une revue locale à Claude Code

Avant de coder trop loin, je place docs/firestore-schema.md, firestore.rules, firestore.indexes.json et les fonctions de requête dans le dépôt.

claude -p "
Relis localement ce design Firestore.
Fichiers:
- docs/firestore-schema.md
- firestore.rules
- firestore.indexes.json
- src/lib/firestore/queries.ts

Vérifie:
1. Chaque requête d'écran correspond-elle au schéma?
2. Les Security Rules sont-elles utilisées par erreur comme filtres?
3. Les requêtes list ont-elles where/orderBy/limit nécessaires?
4. Des index composites manquent-ils ou sont-ils inutiles?
5. Une collection group query est-elle trop large?
6. Le client peut-il modifier le statut d'abonnement?
7. Quel écran lit trop de documents?

Retourne les problèmes, la raison et le code corrigé.
"

Exemple 3: les abonnements. Le client ne doit pas écrire dans subscriptions/{uid}. Un webhook Stripe ou une Cloud Function doit en être propriétaire.

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /subscriptions/{uid} {
      allow get: if request.auth != null && request.auth.uid == uid;
      allow list: if false;
      allow create, update, delete: if false;
    }
  }
}

Vérifiez aussi côté serveur. Cacher un bouton dans l’interface n’est pas une autorisation.

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;
}

Mes erreurs récurrentes: IDs séquentiels, règles relues séparément des requêtes, statut de paiement mélangé au profil utilisateur, et nom générique events utilisé pour plusieurs concepts. En testant ce flux sur un gestionnaire de contacts, un outil éditorial et une démo SaaS, la table des requêtes au départ a réduit les réécritures.

Pour continuer côté GCP, lisez Claude Code x GCP Cloud Functions et Claude Code x GCP Cloud Run. Si vos limites d’API restent floues, concevoir une REST API avec Claude Code complète bien ce guide. ClaudeCodeLab prépare aussi des PDF gratuits, supports et sessions de revue; avec schema, rules et liste de queries, la consultation devient immédiatement concrète.

#claude-code #gcp #firestore #database #typescript #query-design
Gratuit

PDF gratuit: cheatsheet Claude Code

Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.

Nous protégeons vos données et n'envoyons pas de spam.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.