Files
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

4.5 KiB

BSON Mapping Guide

Design §10 and decision D-06. The representation of a value in BSON is a data contract, not an implementation detail: once a collection holds a million documents, changing how a BigDecimal is stored is a migration with downtime, not a code change. MongoTypeRepresentationManifest pins the representation so a library upgrade or a different default cannot move it.

1. The manifest

MongoTypeRepresentationManifest.standard() fixes:

Java type BSON Representation type
UUID Binary subtype 4 MongoUuidRepresentation.STANDARD
BigDecimal Decimal128 MongoDecimalRepresentation.DECIMAL_128
BigInteger Decimal128 (or String when out of range, declared) MongoBigIntegerRepresentation
Instant / OffsetDateTime / ZonedDateTime UTC Date MongoTemporalRepresentation.UTC_DATE
LocalDate String (ISO-8601) or UTC Date, declared per field MongoTemporalRepresentation
enum String name MongoEnumRepresentation.NAME

MongoMappingConfiguration and MongoCustomConversionsFactory build the Spring Data converters from the manifest, so there is one place to read and one place to change.

2. UUID

UuidRepresentation.STANDARD (subtype 4), always. The driver's legacy Java representation (subtype 3) byte-swaps two halves of the UUID, so a document written by one representation and read by the other yields a different — and valid-looking — UUID. Nothing errors; you just get the wrong row. The golden snapshot kit pins the codec explicitly for this reason (MongoBsonSnapshot.defaultRegistry()).

3. Decimal

BigDecimalDecimal128, never Double. 12.30 stored as a double is 12.299999999999999, and a monetary comparison written against it will one day be wrong by a cent for a customer who notices. BigDecimalToDecimal128Converter / Decimal128ToBigDecimalConverter are registered from the manifest.

Decimal128 has 34 significant digits; a BigDecimal beyond that range fails on write rather than rounding silently.

4. Time

Store instants, not local times. LocalDateTimeMappingGuard refuses LocalDateTime fields on a mapped document: a LocalDateTime has no offset, so the value that goes in depends on the JVM default zone of whichever instance wrote it, and the two instances in a rolling deploy can disagree. Use Instant when the moment matters and LocalDate when the calendar day matters.

5. Type metadata

MongoTypeMetadataPolicy decides what goes in _class:

Policy Stored Use when
NONE nothing The collection holds exactly one type and never will hold a subtype.
ALIAS a registered short alias A polymorphic hierarchy in a long-lived collection.
CLASS_NAME the FQCN Short-lived or internal collections only.

PolicyAwareMongoTypeMapper enforces it, and MongoTypeMetadataRegistry holds alias → class. A @LongLivedMongoDocument type with CLASS_NAME is refused: writing com.example.OrderV2 into a million documents means that renaming the package is a data migration.

6. Missing versus null

The golden kit keeps these apart deliberately. MongoBsonSnapshotAssert.hasNoField(...) and hasExplicitNull(...) are different assertions, because in MongoDB they are different documents: {"a": null} matches {a: null} and {a: {$exists: true}}, while {} matches only the first. A mapper change that starts writing explicit nulls silently changes what your queries return.

7. Golden representation tests

Every collection with a fixed representation should have a snapshot test:

MongoBsonSnapshot snapshot = MongoBsonSnapshot.of(storedDocument);
MongoBsonSnapshotAssert.assertThat(snapshot)
    .hasBsonType("amount", "DECIMAL128")
    .hasBsonType("externalId", "BINARY")
    .hasNoJavaClassName("dev.caskeleton")
    .hasTypeSignature("_id:OBJECT_ID,amount:DECIMAL128,createdAt:DATE_TIME,externalId:BINARY");

hasTypeSignature is the regression gate: it fails on any representation change, including ones a value-equality assertion would pass. When it fails, the question is whether the change was intended and has a migration — not whether to update the string.

8. Round trips

MongoRoundTripContract asserts that write → read returns an equal domain object and that write → read → write produces an identical BSON document. The second half is what catches an asymmetric converter: a value that reads back equal but re-serialises differently makes every subsequent save() a spurious update, and turns change streams into a noise generator.