Files
clean-architecture-backend-…/docs/mongodb/advanced/multi-tenancy.md
T
DongHyeonkaandClaude Opus 5 d57d2f62a0 feat(mongodb): implement the MongoDB document persistence platform
Implements the mongodb-superpowers-package design: Stable Tasks 1-50 and
Advanced Tasks 1-15.

The design assumes 19 Stable + 12 Advanced Gradle projects under
modules/mongodb*. This repository's fail-closed registry declares exactly 19
leaf identities, so those modules become package boundaries inside the
registered leaf :adapter:outbound:persistence-mongo, with the design's module
dependency table enforced by ten ArchUnit rules. The mapping and every
deviation are recorded in docs/mongodb/repository-adaptation.md.

Contract highlights, all enforced by tests rather than convention:

- Transaction body retry and commit retry are separate loops. A new session per
  body attempt; commit-only retry on an unknown commit. The body is never
  replayed after a commit ambiguity, so a failover cannot become a duplicate.
- MongoExecutionOutcome keeps both ambiguous outcomes distinct from success and
  failure, and MongoFailureContext records only the design-permitted fields.
- Failure classification reads server error labels before numeric codes.
- BSON representations come from a pinned manifest, never a library default,
  and a golden type-signature gate fails on any drift.
- Index and validator changes go through the manifest and the admin plane;
  metadata ownership gates every drop.
- Every Advanced capability refuses construction unless its flag is enabled.

Verified against real servers, not only unit tests. Running the lanes for the
first time exposed four defects that a green `check` had hidden:

- Four release lanes passed while executing zero tests; the gate now counts
  executed tests per lane and fails on zero.
- The "single replica set" fixture was a standalone, because Testcontainers 2.x
  needs withReplicaSet(); its test only asserted a connection string.
- The three-node fixture was three independent clusters, so no election could
  occur, and awaitNewPrimary() compared against the post-stop primary.
- The migration lease checked modifiedCount, so a same-millisecond refresh read
  as a lost lease.

scripts/verify-mongodb-platform.sh now reports:
  9 lanes, 0 skipped, 0 failed, every evidence category produced.

scripts/verify-mongodb-advanced.sh reports NOT PROMOTABLE: actual-topology
evidence (real sharded cluster, real KMS, real target deployment) is
unobtainable here, so it is named rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:41:00 +09:00

5.0 KiB

Advanced — Multi-tenancy

Capabilities: MongoCapability.SHARED_COLLECTION_TENANCY (Advanced), MongoCapability.DATABASE_PER_TENANT (Experimental) Properties: ca-skeleton.persistence-mongo.advanced.shared-collection-tenancy.enabled, ca-skeleton.persistence-mongo.advanced.database-per-tenant.enabled

1. Shared collection

Every tenant's documents live in one collection, discriminated by a tenant field.

MongoTenantContext carries the tenant. MongoTenantPredicateInjector adds the tenant predicate to every query, every atomic filter and the first aggregation stage. TenantScopedMongoOperations is the entry point, so a caller cannot construct an unscoped operation by forgetting.

Three details are load-bearing:

  • Injection, not convention. A tenant predicate that each query is expected to add itself is a cross-tenant leak waiting for one missed where(...). The injector adds it structurally.
  • First aggregation stage. firstStageMatch(...) places the tenant $match before anything else. A $lookup or $group that runs before the tenant filter has already crossed the boundary, even if a later stage filters the output.
  • An absent tenant is not "all tenants". The injector takes an Optional<MongoTenantContext> so the missing case is a decision the policy makes explicitly, not a predicate that quietly disappears.

MongoTenantManifestValidator.validate(manifest, tenantScopedUniqueIndexes) checks that every unique index that should be per-tenant actually includes the tenant field. A unique index on email alone in a shared collection makes an email globally unique across tenants — tenant B cannot register an address tenant A already used, which is both a bug and an information leak.

requireShardKeyAnalysed(...) requires a shard-key readiness report before a shared-collection tenant model is sharded: tenant id as a shard key prefix concentrates the largest tenant on one shard.

Observability

tenantId and rawTenantId are on MongoObservationConvention's forbidden tag list. Cardinality grows with the customer list, and the tag ships tenant identity into the metrics backend.

2. Database per tenant (Experimental)

MongoTenantDatabaseResolver maps a tenant to its database; MongoTenantClientRegistry holds the clients.

Experimental for a specific reason: it is correct in the small and unbounded in the large. Each tenant database costs connections, file handles and monitoring cardinality. It works beautifully at 20 tenants and falls over at 2,000, and nothing in a functional test distinguishes the two. Promotion requires operational scale evidence.

MongoTenantLifecyclePolicy:

  • requireActivationReady(tenantKey, schemaAndIndexesValidated) — a tenant is not activated until its schema and indexes are validated. Activating first means the first customer request is the migration test.
  • requireDeleteAllowed(...) — deletion requires an explicit retention decision. Dropping a tenant database is irreversible and takes the backup surface with it.

MongoTenantMigrationCoordinator runs a migration across tenant databases with per-tenant results. Partial failure is normal and must be reported per tenant: "migration failed" across 500 databases is not a report anyone can act on.

3. Choosing

Shared collection Database per tenant
Isolation Logical, enforced by injection Physical
Tenant count Unbounded Bounded by connections and file handles
Per-tenant restore Hard Natural
Noisy neighbour Shared resources Isolated
Migration One collection N databases, partial failures
Cross-tenant query Possible (and must be forbidden) Structurally impossible

Shared collection is the default. Database-per-tenant is for a small number of tenants with a contractual isolation or per-tenant-restore requirement.

4. Failure recovery

Symptom Cause Action
Cross-tenant data visible An operation bypassed TenantScopedMongoOperations Treat as a security incident. Find the path, close it, audit access.
Unique constraint fires across tenants Unique index missing the tenant field Rebuild the index with the tenant field as prefix; the validator catches this before it ships.
One shard holds most data Tenant id as shard-key prefix with a dominant tenant Refine the shard key with a high-cardinality suffix.
Connection exhaustion Database-per-tenant beyond the connection budget The scale limit. Consolidate or move to shared collections.
Migration partially applied across tenants Normal MongoTenantMigrationCoordinator reports per tenant; re-run for the failures only.

5. Promotion evidence

Shared-collection tenancy: actual-topology evidence, a security review covering cross-tenant access, a migration path, failure cases (injection proven on query, atomic filter and first aggregation stage), this runbook. Database-per-tenant additionally requires operational scale evidence and stays Experimental until it exists.