Files
clean-architecture-backend-…/docs/adr/ADR-MONGO-002-bson-representation.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

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 MongoTypeMetadataPolicyNONE, 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.