Chapter 11 · Subcollections, Collection Groups, Hierarchies, Deletion, and Data Lifecycle
Deep Hierarchies vs Flat Collections: Queryability, Security, Ownership, and Operational Complexity
Model AtlasMart hierarchy from ownership, query, security and lifecycle requirements, comparing deep subcollections with flatter root collections.
Learning outcomes
Design Firestore hierarchy from ownership, query and deletion boundaries instead of using nesting as visual organization alone.
Compare root collections and nested subcollections for queryability, Security Rules, lifecycle ownership and operational discovery.
Produce a path inventory that exposes every AtlasMart descendant before destructive work begins.
Recognize that a deep path can clarify ownership while simultaneously increasing rules, deletion and retention complexity.
Use the Emulator Suite, a Firebase demo project, or an isolated test project for destructive, security-sensitive, billing-sensitive, migration, backup/restore, or write-heavy exercises unless the lesson explicitly marks managed verification as required. Treat shown output as expected evidence unless it is explicitly identified as captured output, and re-check current Firebase/Google Cloud edition, mode, quota, pricing, and security documentation before production execution.
AtlasMart continues the same mandatory environment used in
Chapters 01–10: project ID
demo-atlasmart-firestore, Standard edition /
Native mode / (default) database, Firestore
emulator 127.0.0.1:8080, Authentication emulator
127.0.0.1:9099, Emulator UI
127.0.0.1:4000, Firebase CLI
15.30.0, Firebase JavaScript SDK
12.19.0, Firebase Admin Node SDK
14.4.0 with
@google-cloud/firestore 9.1.0, and Node.js 22+.
Mandatory deletion/lifecycle work remains local and isolated.
Managed bulk delete, managed export/import, PITR, production
IAM, Cloud Storage and legal/compliance procedures are
discussed but are not falsely claimed to have run in the
emulator.
The Emulator Suite is appropriate for proving path structure, document-delete versus surviving-subcollection behavior, collection-group query results, Security Rules behavior, recursive-cleanup logic and deterministic post-delete verification. It does not establish production delete throughput, billed read/delete cost, managed bulk-delete progress, export consistency, IAM behavior, backup/PITR recovery, legal-retention compliance or p95/p99 latency. Recursive deletion is multi-operation work rather than one atomic cascade; every production run needs a scoped target, project/database guard, durable audit evidence and post-delete verification.
1. The AtlasMart problem: hierarchy is an operational contract
AtlasMart has tenants, tenant users, orders and order messages. A relational instinct might flatten everything into root collections; a UI instinct might nest every object beneath its owner. Neither shape is automatically correct. In Firestore, a path decides which collection query is convenient, which Security Rules pattern matches, how lifecycle ownership is inferred, whether collection-group queries are required and how destructive cleanup must discover descendants.
A root collection begins directly under the
database, such as orders/o-1001. A
subcollection is a collection beneath a
document, such as
tenants/t-acme/orders/o-1001/messages/m-001. A
collection group is every collection with the
same collection ID, regardless of its parent path. A
lifecycle owner is the application object whose
retention/deletion policy governs a child; Firestore does not
enforce that concept for you.
| Shape | Convenient operation | Security/lifecycle benefit | Operational cost |
|---|---|---|---|
| tenants/{tenant}/orders/{order} | Read one tenant's orders | Path encodes tenant ownership | Cross-tenant analytics needs collection-group or duplication |
| orders/{order} | Global order queries are simple | Fewer path levels | Tenant ownership must live in fields/rules and deletion manifests |
| .../orders/{order}/messages/{message} | Messages naturally owned by order | Narrow parent-scoped reads | Parent deletion does not cascade; recursive cleanup required |
| messages/{message} | Global messages easy to query | Simple top-level index surface | Ownership/lifecycle must be explicit fields; easy to forget copies |
2. Start with journeys, then assign ownership
AtlasMart needs three primary journeys: a seller opens one
tenant dashboard, a customer reads one order thread, and support
searches recent messages for one tenant. The first two favor
hierarchy. The third is still possible because all
messages subcollections form a collection group.
The path therefore preserves ownership without sacrificing the
cross-parent query, provided indexes and rules are designed for
that access pattern.
tenants/t-acme users/u-1001 orders/o-1001 messages/m-001 orders/o-1002 messages/m-002userDirectory/u-1001 # deliberate root-level identity lookup copyactivity/a-001 # deliberate root-level audit/activity copy
The two root-level copies are intentional. Their presence means “delete tenant subtree” is not sufficient for either tenant deletion or data-subject deletion. Chapter 11 therefore treats deletion as a manifest-driven operation, not a single path operation.
3. Make the hierarchy observable before you delete anything
process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080";process.env.GCLOUD_PROJECT = "demo-atlasmart-firestore";import { initializeApp } from "firebase-admin/app";import { getFirestore, FieldValue, Timestamp } from "firebase-admin/firestore";initializeApp({ projectId: "demo-atlasmart-firestore" });const db = getFirestore();
async function seedHierarchy() { const batch = db.batch(); batch.set(db.doc("tenants/t-acme"), { name: "Acme Seller", lifecycleState: "ACTIVE", schemaVersion: 4 }); batch.set(db.doc("tenants/t-acme/users/u-1001"), { tenantId: "t-acme", uid: "u-1001", displayName: "Ava", lifecycleState: "ACTIVE", schemaVersion: 4 }); batch.set(db.doc("tenants/t-acme/orders/o-1001"), { tenantId: "t-acme", customerId: "u-1001", status: "PAID", totalMinor: 12990, lifecycleState: "ACTIVE", schemaVersion: 4 }); batch.set(db.doc("tenants/t-acme/orders/o-1002"), { tenantId: "t-acme", customerId: "u-1002", status: "PAID", totalMinor: 4950, lifecycleState: "ACTIVE", schemaVersion: 4 }); batch.set(db.doc("tenants/t-acme/orders/o-1001/messages/m-001"), { tenantId: "t-acme", orderId: "o-1001", authorId: "u-1001", text: "Please leave at reception", sentAt: Timestamp.fromMillis(1789570800000), schemaVersion: 4 }); batch.set(db.doc("tenants/t-acme/orders/o-1002/messages/m-002"), { tenantId: "t-acme", orderId: "o-1002", authorId: "u-1002", text: "Thanks", sentAt: Timestamp.fromMillis(1789570860000), schemaVersion: 4 }); batch.set(db.doc("userDirectory/u-1001"), { uid: "u-1001", tenantId: "t-acme", lifecycleState: "ACTIVE", schemaVersion: 4 }); batch.set(db.doc("activity/a-001"), { subjectUid: "u-1001", tenantId: "t-acme", type: "ORDER_CREATED", schemaVersion: 4 }); await batch.commit();}
async function inventoryDocument(ref, out = []) { const snap = await ref.get(); out.push({ path: ref.path, exists: snap.exists }); for (const coll of await ref.listCollections()) { const children = await coll.get(); for (const child of children.docs) await inventoryDocument(child.ref, out); } return out;}async function inventoryCollection(ref, out = []) { const snap = await ref.get(); for (const doc of snap.docs) await inventoryDocument(doc.ref, out); return out;}
await seedHierarchy();const paths = await inventoryDocument(db.doc("tenants/t-acme"));for (const p of paths.sort((a,b)=>a.path.localeCompare(b.path))) { console.log(`${p.exists ? "DOC" : "MISSING"} ${p.path}`);}
The expected inventory contains the tenant, two orders, their
messages and the tenant user. It does not contain
userDirectory/u-1001 or
activity/a-001, which is precisely why lifecycle
design must record data outside the hierarchy.
4. Boundary: deep nesting is not recursive ownership enforcement
Firestore supports nested subcollections, but a descendant
document can continue to exist even when its parent document is
absent. A path therefore expresses association, not cascade
semantics. Likewise, Security Rules that protect
/tenants/{tenantId} do not magically apply the way
an inheritance hierarchy might in an object-oriented language;
every applicable path pattern must be correct.
Deeply nesting resources without a collection-group, index, rules and deletion plan creates data that is hard to query and easy to orphan. Repair the design by defining ownership and lifecycle for each collection ID, recording cross-tree copies, and building inventory/verification tooling before production data exists.
5. Standard vs Enterprise, Native vs MongoDB compatibility
The mandatory lab uses Standard Native Core operations. Enterprise Native Core keeps the same broad document/subcollection mental model but differs in indexing and billing, so query-cost assumptions must be re-measured. Enterprise Pipeline is an advanced query execution surface, not a replacement for lifecycle ownership. Firestore with MongoDB compatibility has a different driver/query surface; do not port Native Security Rules or client hierarchy assumptions into it without checking the compatibility documentation. Server/Admin code uses IAM in production and bypasses Native client Security Rules, so recursive cleanup belongs behind a trusted operational boundary.
6. Reproducible AtlasMart lab
{ "name": "atlasmart-firestore-ch11", "private": true, "type": "module", "engines": { "node": ">=22" }, "dependencies": { "firebase": "12.19.0", "firebase-admin": "14.4.0" }, "devDependencies": { "firebase-tools": "15.30.0", "@firebase/rules-unit-testing": "5.0.2" }}
{ "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" }, "emulators": { "firestore": { "port": 8080 }, "auth": { "port": 9099 }, "ui": { "enabled": true, "port": 4000 } }}
rules_version = '2';service cloud.firestore { match /databases/{database}/documents { function signedIn() { return request.auth != null; } function sameTenant(tenantId) { return signedIn() && request.auth.token.tenantId == tenantId; } match /tenants/{tenantId} { allow read: if sameTenant(tenantId); allow write: if false; match /users/{uid} { allow read: if sameTenant(tenantId); allow write: if sameTenant(tenantId) && request.auth.uid == uid; } match /orders/{orderId} { allow read: if sameTenant(tenantId); allow write: if false; match /messages/{messageId} { allow read: if sameTenant(tenantId); allow write: if false; } } } // Required for collection-group queries over every messages collection. match /{path=**}/messages/{messageId} { allow read: if signedIn() && resource.data.tenantId == request.auth.token.tenantId; allow write: if false; } match /deletionAudits/{id} { allow read, write: if false; } match /retentionHolds/{id} { allow read, write: if false; } match /{document=**} { allow read, write: if false; } }}
{ "indexes": [ { "collectionGroup": "messages", "queryScope": "COLLECTION_GROUP", "fields": [ { "fieldPath": "tenantId", "order": "ASCENDING" }, { "fieldPath": "sentAt", "order": "DESCENDING" } ] } ], "fieldOverrides": []}
mkdir atlasmart-firestore-ch11 && cd atlasmart-firestore-ch11npm init -ynpm install firebase@12.19.0 firebase-admin@14.4.0npm install --save-dev firebase-tools@15.30.0 @firebase/rules-unit-testing@5.0.2# Save firebase.json, firestore.rules and firestore.indexes.json from this lesson.npx firebase-tools@15.30.0 emulators:start --project demo-atlasmart-firestore --only firestore,auth
Seed the hierarchy, print the path inventory, then implement a
competing flat model under flatOrders and
flatMessages. For each design, write down the exact
query needed for “all orders for t-acme,” “all messages for
o-1001,” and “all recent messages for t-acme.” Do not benchmark
emulator latency as production evidence; instead count queries,
returned documents, rules surfaces and deletion targets.
| Decision surface | Nested model | Flat model |
|---|---|---|
| Tenant-scoped ownership | Encoded in path plus fields | Field-only |
| One-order messages | Direct subcollection | Root query by orderId |
| All tenant messages | Collection-group query | Root query |
| Parent deletion | Children survive unless recursively deleted | No implicit relationship to children |
| Tenant purge discovery | Tree + deliberate external copies | Multiple root queries/manifest entries |
| Rules complexity | Recursive/group rules plus parent ownership | Field-based root rules |
Production judgment
Prefer hierarchy when ownership and bounded parent-scoped access are strong, but only if every subcollection has a collection-group/index plan and a lifecycle plan. Prefer flatter structures when global querying dominates, but compensate with explicit ownership fields and deletion manifests. The deciding dimensions are query scope, rules proofability, delete discoverability, retention boundaries, future analytics and operator tooling—not aesthetic preference.
Lesson 2 drills into collection-group queries, where one collection ID becomes a cross-parent query surface and a security boundary.
Knowledge check
- Does a nested path guarantee a child is deleted with its parent?
- What does a collection group contain?
- Why record root-level copies such as userDirectory?
- What should be inventoried before destructive cleanup?
- What does the emulator prove here?
Review the answers
1. No. Document deletion does not cascade to subcollection documents.
2. All collections with the same collection ID at any hierarchy level.
3. Because deleting only the tenant tree would otherwise leave duplicated subject/tenant data behind.
4. The target tree, deliberate external copies, collection-group copies, retention holds and expected post-delete exceptions.
5. Path, query, rules and cleanup logic—not production latency, billing, IAM or legal compliance.
Summary and next step
Hierarchy is simultaneously a query structure, security structure and lifecycle structure. AtlasMart now has an explicit ownership map and path inventory. Next we make collection-group queries safe across those parent paths.
Authoritative references
- Cloud Firestore data model — subcollections, hierarchy depth and the non-cascading document-delete warning.
- Collection group queries — querying same-ID subcollections across parents.
- Securely query data — rules are not filters and rules_version 2 collection-group patterns.
- Manage indexes — collection-group/composite index configuration.
- Delete data — collection deletion guidance and server-side recursive deletion.
- Delete collections and subcollections — recursive deletion, orphan discovery and non-atomic behavior.
- Managed bulk delete — production collection-group deletion, billing and operation behavior.
- Export and import data — billing, Cloud Storage, collection-group exports and recovery implications.
- Delete User Data extension — configured paths, recursive mode and auto-discovery considerations.
- Firebase CLI release notes — pinned CLI baseline.
- Admin Node.js release notes — pinned trusted-server baseline.