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