Repositories in queries and commands
Consume paging and sorting
Section titled “Consume paging and sorting”Arc carries zero-based paging and one sort field in QueryRequest. A model-bound query can declare the exact non-null Spring Data Commons Pageable and Sort types. Generated performers create both values directly from the captured query request; they do not use request scope, thread-local state, or a request-scoped bean.
@JvmStaticfun all( pageable: Pageable, sort: Sort, @FromServices tasks: TaskViewRepository): Page<TaskView> { check(pageable.sort == sort) return tasks.findAll(pageable)}A page size of zero becomes Pageable.unpaged(sort). A blank sort field becomes Sort.unsorted(). Exact Spring Data Page<TaskView> returns are normalized by the generated performer before Arc renderers run, preserving content, zero-based page number, and the repository’s pre-page totalElements; an unpaged result reports Arc page size zero, matching the compatibility adapters.
Pageable marks paging and sorting capabilities; Sort marks sorting capability. A query using either host adapter must return exact Page<T> so Arc cannot page or sort an already provider-shaped collection again. Client parameters cannot use the reserved names page, pageSize, sortBy, or sortDirection. These explicit generated descriptor flags control TypeScript and OpenAPI paging and sorting surfaces. Host-adapter parameters remain in declaration-order metadata for invocation and validation indexes, but Spring binding, Jakarta model-graph validation, TypeScript, OpenAPI, and endpoint introspection expose only client parameters.
The existing JpaQueryRequestAdapter, JpaQueryPageAdapter, MongoQueryRequestAdapter, and MongoQueryPageAdapter APIs remain available for manually implemented performers and compatibility. They use the same Spring Data Commons semantics as generated performers.
Resolve current state for a command
Section titled “Resolve current state for a command”The JPA and MongoDB integrations contribute storage-neutral CanResolveReadModelForCommand providers. A generated handler can therefore request its current read model directly; Arc uses the command’s generated key and captured tenant context before invoking the handler:
@Commanddata class RenameTask(@CommandKey val id: String, val title: String) { fun handle(current: TaskView) { // current came from the owning persistence provider using id. }}Only exact mapped types carrying @ReadModel are claimed. JPA uses DECLARED ownership because an entity mapping explicitly selects that store. MongoDB uses FALLBACK, so a declaring JPA, Chronicle, or application provider wins. Equal-strength claims fail startup rather than selecting a store by bean order. The supplied command key must already be an instance of the persistence identifier type; contextual providers do not recompute or coerce it. Registry-owned read-model parameters are resolved before ordinary Spring services, so a bean of the model class cannot bypass tenant or key selection. For an owned type, a missing row supplies null to Kotlin T? and Optional.empty() to an ordinary Java Optional<T>. A missing command key or missing required owned row produces one dependencyUnavailable validation result and HTTP 400. Resolver/storage failures and missing ordinary Spring services remain exceptions. Replacing Arc’s ReadModelForCommandResolverRegistry bean is an expert override: the application then owns equivalent arbitration and tenancy guarantees.
The historical JpaCommandReadModelResolver and MongoCommandReadModelResolver APIs remain for fixed-store compatibility. Auto-configuration publishes them only with a verified fixed store and optional tenancy. They recompute a key without CommandContext and must not be used for tenant routing.
Route JPA by tenant
Section titled “Route JPA by tenant”Optional, single-unit applications receive a fixed JpaPersistenceUnitResolver. Tenant-routed applications provide one resolver that reports its exact type union and returns a JpaPersistenceUnit certified for the requested tenantId and tenantNamespace. The unit pairs its EntityManagerFactory with an optional matching JpaTransactionManager; Arc verifies both the certificate and mapped type before lookup. With cratis.arc.tenancy.required=true, mapped and resolver-owned JPA read-model claims must match, and the contextual provider must enforce the exact application resolver.
Route MongoDB by tenant
Section titled “Route MongoDB by tenant”A fixed MongoOperationsResolver serves only commands and observations without a tenant; supplying a tenant fails rather than reading and relabeling the default database. Tenant-routed applications provide one TenantAwareMongoOperationsResolver; each lookup returns TenantMongoOperations, which certifies the exact tenant identifier together with its isolated MongoOperations. Unknown tenants must throw, and Arc rejects mismatched certificates without retrying a default database. Required tenancy validates that command lookup, snapshots, and change streams use adapters for that exact resolver, with no unrelated plain resolver.
Inject tenant-bound MongoDB access
Section titled “Inject tenant-bound MongoDB access”When a tenant-aware resolver is configured, Arc auto-configures a TenantContextMongoAccess bean that handlers
can inject. It resolves the correct tenant’s MongoOperations at the moment each method is called — the tenant
is never captured at construction time. This mirrors the IMongoDatabase and IMongoCollection<T> scoped registrations
in Arc .NET’s ServiceCollectionExtensions.AddCratisMongoDB (Source/DotNET/MongoDB/ServiceCollectionExtensions.cs).
Kotlin coroutine call paths
Section titled “Kotlin coroutine call paths”Call operations() inside a withTenant scope or any coroutine whose context carries a TenantCoroutineContext;
the ambient tenant is read on every call:
@Commanddata class ArchiveTask(@CommandKey val id: String) { suspend fun handle( @FromServices access: TenantContextMongoAccess ) { // operations() reads currentTenant() from the coroutine context — never from construction time. val ops = access.operations() ops.remove(Query.query(Criteria.where("_id").`is`(id)), TaskDocument::class.java) }}For direct native driver access, collection() returns a MongoCollection<T> named by the NamingPolicy:
suspend fun handle( @FromServices access: TenantContextMongoAccess) { val col = access.collection(TaskDocument::class.java) col.deleteOne(Filters.eq("_id", id))}The collection name comes from NamingPolicy.getReadModelName() (kernel-aligned English pluralization), not from
Spring Data’s @Document annotation. For Spring Data-mapped types, prefer operations() and its type-safe CRUD
methods, which respect @Document and the configured object mapping.
Java call paths
Section titled “Java call paths”Java command handlers that run on a blocking thread use TenantContextBridge.withTenant to establish the tenant,
then call operationsForCurrentTenant():
public class ArchiveTaskHandler implements BlockingCommandHandler { private final TenantContextMongoAccess access;
public ArchiveTaskHandler(TenantContextMongoAccess access) { this.access = access; }
@Override public void handle(CommandContext context) { TenantContextBridge.withTenant(TenantId.of(context.getTenantId()), () -> { MongoOperations ops = access.operationsForCurrentTenant(); ops.remove(Query.query(Criteria.where("_id").is(context.getCommandKey())), TaskDocument.class); }); }}Alternatively, pass the tenant explicitly using the overload that bypasses the ambient context:
MongoOperations ops = access.operations(TenantId.of(tenantId));This overload is callable from both Kotlin and Java without any context setup.
No-tenant fallback
Section titled “No-tenant fallback”When no tenant is present in the current context and no fallback tenant is configured, all resolution methods
throw IllegalStateException. Construct TenantContextMongoAccess with an explicit fallbackTenantId when
the application has a default tenant for non-tenanted code paths:
// Application bean override — use the workspace default tenant when no tenant is active.@Beanfun arcTenantContextMongoAccess( resolver: TenantAwareMongoOperationsResolver, namingPolicy: NamingPolicy): TenantContextMongoAccess = TenantContextMongoAccess(resolver, namingPolicy, TenantId.DEFAULT)// Java equivalent.@Beanpublic TenantContextMongoAccess arcTenantContextMongoAccess( TenantAwareMongoOperationsResolver resolver, NamingPolicy namingPolicy) { return new TenantContextMongoAccess(resolver, namingPolicy, TenantId.DEFAULT);}The auto-configured bean uses no fallback (throw on missing tenant). Override it as above when a fallback is needed.
Real-server coverage
Section titled “Real-server coverage”TenantContextMongoAccessTests and JavaTenantContextMongoAccessTests verify per-call tenant resolution and
isolation against the mongo-java-server 1.47.0 MemoryBackend emulator. The mongoReplicaSetTest gate
additionally proves the following against a real pinned MongoDB 8.2 single-node replica set
(mongo:8.2@sha256:e0ce8c35124d4a9f9785532d1f268f39e9728ffa1cb38f46fa482436424c4bd3):
TenantContextMongoAccessroutes both coroutine (withTenant) and Java blocking (TenantContextBridge) calls to genuinely isolated real MongoDB databases for two different tenants.- The
DefaultNamingPolicykernel-aligned correction (Person→People) routes writes to the correct physical collection on a real server; collectionPersonsis never created. - A change-stream cursor opened against a real replica set receives an
INSERTevent. Change streams require replica-set mode and cannot be verified against the MemoryBackend emulator. - Kotlin data class and Java record concept IDs (UUID binary subtype 4, string, int64) persist with correct BSON
types and round-trip on a real server; null
@Field(ALWAYS)fields appear as BSON null; malformed stored scalars fail visibly through the reading converter.
The gate does not cover replica-set failover, TLS, transactions through TenantContextMongoAccess, or
deterministic resume/reconnect under cursor interruption. Those limitations remain.
Enroll commands in transactions
Section titled “Enroll commands in transactions”Imperative Spring transactions are disabled by default because JPA and MongoDB transaction managers bind resources to a thread while Arc command handlers may suspend and resume on another worker. Fixed-store applications can explicitly opt in:
cratis: arc: spring-data: jpa: command-transactions-enabled: true mongodb: command-transactions-enabled: trueOpt-in succeeds only for a verified fixed store with an identity-aligned transaction manager. Dynamic tenant resolvers never receive a fixed transaction scope. Even when enabled, command code must not suspend or execute persistence work on another thread; the scope is imperative, not coroutine-safe. The scopes commit only a successful final CommandResult and roll back failures when completion remains on the opening thread.
There is no distributed transaction across JPA, MongoDB, and Chronicle. Chronicle now completes after the local scopes, so a JPA or MongoDB completion failure prevents the append. Partial outcomes remain possible in the other direction: MongoDB may commit before JPA fails, and both local stores may commit before Chronicle fails or has an indeterminate external outcome. Imperative coroutine affinity and cross-store reconciliation remain explicit limitations.