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>
3.3 KiB
ADR-MONGO-002 — BSON representation is a pinned manifest
- Status: Accepted
- Date: 2026-08-13
- Design source: design §10, decision D-06
Context
How a Java value is represented in BSON is a data contract, but nothing in the default toolchain
treats it as one. Spring Data and the MongoDB driver both have defaults, and those defaults have
changed across versions. A BigDecimal can land as a Double, a String or a Decimal128; a UUID
can land as Binary subtype 3 or subtype 4; an Instant can land as a Date or a String.
The consequences are asymmetric. A representation change is invisible in a value-equality test —
12.30 looks like 12.30 whether it is a double or a Decimal128 — but once a collection holds
production data, changing it is a full migration. And the UUID case is worse than a migration: legacy
Java representation byte-swaps two halves of the UUID, so a document written under one representation
and read under the other yields a different, valid-looking UUID. Nothing errors. You get the wrong
record.
Decision
MongoTypeRepresentationManifest pins the representation for every type the platform maps, and
MongoMappingConfiguration builds the Spring Data converters from it. Nothing relies on a library
default.
| Java | BSON | Rationale |
|---|---|---|
UUID |
Binary subtype 4 (STANDARD) |
Subtype 3 byte-swaps; cross-representation reads are silently wrong. |
BigDecimal |
Decimal128 |
A double cannot represent 12.30; money compared as a double is eventually wrong by a cent. |
BigInteger |
Decimal128, or declared String when out of range |
34 significant digits; out of range fails on write instead of rounding. |
Instant / OffsetDateTime / ZonedDateTime |
UTC Date |
One instant, one representation. |
LocalDate |
declared per field | A calendar day is not an instant. |
LocalDateTime |
refused | No offset: the stored value depends on the writing JVM's default zone. |
enum |
String name |
Ordinals renumber when someone inserts a constant. |
Type metadata follows MongoTypeMetadataPolicy — NONE, ALIAS or CLASS_NAME. A
@LongLivedMongoDocument type may not use CLASS_NAME: writing a FQCN into a million documents makes
a package rename a data migration.
The manifest is enforced by a golden gate. MongoBsonSnapshot canonicalises a stored document,
preserving BSON types and keeping missing distinct from null, and
MongoBsonSnapshotAssert.hasTypeSignature(...) fails on any representation change. The registry
pins UuidCodec(STANDARD) explicitly rather than inheriting a default, since inheriting the default
is the exact drift the gate exists to catch.
Consequences
Positive. A library upgrade cannot move a representation without failing a test. Money is exact. UUIDs read back as themselves. Class moves stay refactors.
Negative. Every representation-affecting change requires updating a snapshot and writing a migration. A new mapped type needs a manifest entry before it can be used. This is the intended friction: the alternative is discovering the change in production.
Rejected alternative — "snapshot the JSON." JSON destroys exactly the distinctions the gate
protects: Decimal128 and String both render as text, Binary UUID and ObjectId both render as
hex, and missing and null both disappear.