Map MongoDB concepts
Map MongoDB concepts explicitly in the application
Section titled “Map MongoDB concepts explicitly in the application”If your documents store concepts, MongoDB needs a mapping between each wrapper and its BSON
scalar. This is an application-owned recipe, not Arc autodiscovery. Register concrete,
stateless @WritingConverter/@ReadingConverter pairs for each concept and scalar in one
authoritative MongoCustomConversions.create configuration. A bare Spring Converter bean
does not register that configuration.
Finish configuring the application’s mapping context and converter before creating templates,
repositories, or tenant certificates. Arc must not mutate an application-owned custom
MongoConverter during lookup.
Register concrete scalar pairs
Section titled “Register concrete scalar pairs”The writing converter unwraps the concept; the reading converter reconstructs it from the scalar.
For a concept such as the TextValue shown in Map JPA concepts, define both
directions explicitly:
@WritingConverterpublic class TextWrite implements Converter<TextValue, String> { @Override public String convert(TextValue source) { return source.value(); }}
@ReadingConverterpublic class TextRead implements Converter<String, TextValue> { @Override public TextValue convert(String source) { return new TextValue(source); }}@WritingConverterclass TextWrite : Converter<TextValue, String> { override fun convert(source: TextValue): String = source.value()}
@ReadingConverterclass TextRead : Converter<String, TextValue> { override fun convert(source: String): TextValue = TextValue(source)}Here Converter means Spring’s org.springframework.core.convert.converter.Converter, not the
JPA annotation. The executable recipes provide equivalent concrete UUID-to-UUID and Long-to-Long
pairs named UuidWrite/UuidRead and LongWrite/LongRead. These are concrete fixture types,
not definitions supplied by Arc or by this excerpt. Do not replace them with an erased
Converter<ConceptAs<?>, ?> or infer constructors reflectively.
Defining the pairs is only the first step. Install all six together so Spring Data uses them when reading and writing:
MongoCustomConversions conversions = MongoCustomConversions.create(adapter -> { adapter.registerConverter(new UuidWrite()); adapter.registerConverter(new UuidRead()); adapter.registerConverter(new TextWrite()); adapter.registerConverter(new TextRead()); adapter.registerConverter(new LongWrite()); adapter.registerConverter(new LongRead());});val conversions = MongoCustomConversions.create { adapter -> adapter.registerConverter(UuidWrite()) adapter.registerConverter(UuidRead()) adapter.registerConverter(TextWrite()) adapter.registerConverter(TextRead()) adapter.registerConverter(LongWrite()) adapter.registerConverter(LongRead())}For a programmatically owned template, apply that same configuration to both the mapping context
and the converter before constructing the template. The order matters: repositories must see the
finished mappings, not a template that will be reconfigured on lookup. The complete
RecipeMongoStore fixture also owns and closes its client:
static TextRows textRows(MongoClient client, String database, MongoCustomConversions conversions) { var mappingContext = new MongoMappingContext(); mappingContext.setSimpleTypeHolder(conversions.getSimpleTypeHolder()); mappingContext.setInitialEntitySet(Set.of(UuidRow.class, TextRow.class, LongRow.class)); mappingContext.afterPropertiesSet();
var factory = new SimpleMongoClientDatabaseFactory(client, database); var converter = new MappingMongoConverter(new DefaultDbRefResolver(factory), mappingContext); converter.setCustomConversions(conversions); converter.afterPropertiesSet();
var template = new MongoTemplate(factory, converter); return new MongoRepositoryFactory(template).getRepository(TextRows.class);}fun textRows(client: MongoClient, database: String, conversions: MongoCustomConversions): TextRows { val mappingContext = MongoMappingContext() mappingContext.setSimpleTypeHolder(conversions.simpleTypeHolder) mappingContext.setInitialEntitySet(setOf(UuidRow::class.java, TextRow::class.java, LongRow::class.java)) mappingContext.afterPropertiesSet()
val factory = SimpleMongoClientDatabaseFactory(client, database) val converter = MappingMongoConverter(DefaultDbRefResolver(factory), mappingContext) converter.customConversions = conversions converter.afterPropertiesSet()
val template = MongoTemplate(factory, converter) return MongoRepositoryFactory(template).getRepository(TextRows::class.java)}Configure the client explicitly with
MongoClientSettings.builder().uuidRepresentation(UuidRepresentation.STANDARD) before building
it. This determines the UUID representation used by the recipe. Its tests read raw BsonDocument
values and assert UUID binary subtype 4, BSON string, and BSON int64 for IDs and ordinary
fields, rather than nested wrappers or numeric strings.
Choose identifier and null mappings deliberately
Section titled “Choose identifier and null mappings deliberately”The recipes use Spring Data @Id for UUID and Long concept identifiers, with the concrete pairs
above. For the String concept identifier, use @MongoId(FieldType.STRING) explicitly: the tested
24-hex value 507f1f77bcf86cd799439011 stays a BSON string instead of becoming an ObjectId.
Do not substitute an unqualified @MongoId for these mappings; its implicit target is not the
same conversion policy. Declare repository IDs as the concrete concept type, for example
MongoRepository<TextRow, TextValue>.
Kotlin documents use constructor-bound concept IDs and mutable ordinary fields. Ordinary Java documents use no-argument construction and field hydration, with immutable concept records as their values; this avoids relying on Java constructor parameter-name compiler metadata. Do not change an identifier after saving a document.
Choose the stored null shape separately from the scalar conversion: Spring bypasses these
converters for null. Apply @Field(write = Field.Write.ALWAYS)
(@field:Field(write = Field.Write.ALWAYS) in Kotlin) when a nullable concept field must be
written as explicit BSON null. Without it, the tested null field is absent. Both read as null;
the tests separately remove an explicitly written field and verify absent-field hydration.
Non-null blank text still fails in the concept constructor. A stored String in the Long field
fails with ConverterNotFoundException rather than becoming a default Long concept. The configured
reading pair accepts Long, not arbitrary malformed storage types.
Unlike a JPA managed entity, a loaded MongoDB document has no dirty tracking that will persist
an in-memory field change for you. Call repository save explicitly or use a mapped
MongoTemplate.updateFirst with a concept-valued query and update. Then read through a fresh
template/converter to verify the stored result.
MongoDB recipe evidence and limits
Section titled “MongoDB recipe evidence and limits”Finish configuring each template before issuing its TenantMongoOperations certificate. For
tenant-routed command lookup, that certificate covers
tenantId, not JPA’s tenant namespace; it does not establish Mongo namespace isolation. Repository
injection alone remains ordinary Spring dependency injection, not tenant routing.
The documented recipe coverage in Integrations/SpringDataMongo comes from
io.cratis.arc.persistence.recipes.mongodb.MongoConceptStorageTests and JavaMongoConceptStorageTests.
They execute configured repositories, concept findById and equality predicates, fresh class
reconstruction, explicit save/update, distinct null/absent BSON shapes, malformed-value failures,
and exact signed Long boundaries including values beyond JavaScript’s exact integer range.
The missing-registration control stores a nested concept document instead of a scalar; adding
the application registration produces the asserted scalar. This demonstrates an application
omission, not an existing Arc product bug.
Each language stores the same UUID, String, and Long concept keys with different values in two
databases. The existing contextual resolver rejects unknown tenants, mismatched certificates,
missing/blank tenants, scalar keys, and a different concept key class. Exact resolver-call and
driver find command counts prove no retry or fallback database read.
This evidence uses Spring Data MongoDB 5.1.1 and Mongo Java driver 5.8.1 against the existing
mongo-java-server 1.47.0 MemoryBackend emulator, not a real MongoDB server, Testcontainers,
or an external database. It does not certify production MongoDB, other providers or BSON types,
change streams, replica sets, transactions, optimistic locking, or Arc .NET parity. No provider
dependency or Arc API is added by the recipes.
./gradlew :Integrations:SpringDataMongo:test --tests '*MongoConceptStorageTests'