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

91 lines
5.0 KiB
Markdown

# 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.