Skip to content

Repositories in queries and commands

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.

public static Page<TaskView> all(
Pageable pageable,
Sort sort,
@FromServices TaskViewRepository tasks
) {
if (!pageable.getSort().equals(sort)) {
throw new IllegalStateException("Paging and sorting must agree.");
}
return tasks.findAll(pageable);
}

View Java snippet source on GitHub

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.

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:

@Command
public record RenameTask(@CommandKey String id, String title) {
public void handle(TaskView current) {
// current came from the owning persistence provider using id.
}
}

View Java snippet source on GitHub

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.

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.

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.

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

operations() and collection() read the ambient tenant on every call, so call them inside a withTenant scope or any coroutine whose context carries a TenantCoroutineContext. Java reaches the same tenant through operationsForCurrentTenant() and collectionForCurrentTenant() inside TenantContextBridge.withTenant. Arc’s command pipeline does not establish this scope for you: a handler that calls them with no scope fails with IllegalStateException unless a fallback tenant is configured. TenantContextBridge.withTenant blocks its calling thread, so it is for plain Java threads; inside a Java command handler, which Arc already runs in a coroutine, use the explicit-tenant overload below instead.

static void archiveTask(TenantId tenantId, String id, TenantContextMongoAccess access) {
TenantContextBridge.withTenant(tenantId, () -> {
// operationsForCurrentTenant() reads the tenant the bridge established for this call.
MongoOperations ops = access.operationsForCurrentTenant();
ops.remove(Query.query(Criteria.where("_id").is(id)), TaskDocument.class);
});
}

View Java snippet source on GitHub

For direct native driver access, the collection accessors return a MongoCollection<T> named by the NamingPolicy:

static void deleteTask(TenantId tenantId, String id, TenantContextMongoAccess access) {
TenantContextBridge.withTenant(tenantId, () -> {
MongoCollection<TaskDocument> col = access.collectionForCurrentTenant(TaskDocument.class);
col.deleteOne(Filters.eq("_id", id));
});
}

View Java snippet source on GitHub

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.

When the tenant is already in hand, pass it explicitly using the overload that bypasses the ambient context:

MongoOperations ops = access.operations(TenantId.of(tenantId));

View Java snippet source on GitHub

This overload is callable from both Kotlin and Java without any context setup.

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:

@Bean
public TenantContextMongoAccess arcTenantContextMongoAccess(
TenantAwareMongoOperationsResolver resolver,
NamingPolicy namingPolicy
) {
return new TenantContextMongoAccess(resolver, namingPolicy, TenantId.DEFAULT);
}

View Java snippet source on GitHub

The auto-configured bean uses no fallback (throw on missing tenant). Override it as above when a fallback is needed.

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):

  • TenantContextMongoAccess routes both coroutine (withTenant) and Java blocking (TenantContextBridge) calls to genuinely isolated real MongoDB databases for two different tenants.
  • The DefaultNamingPolicy kernel-aligned correction (Person → People) routes writes to the correct physical collection on a real server; collection Persons is never created.
  • A change-stream cursor opened against a real replica set receives an INSERT event. 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.

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: true

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