cratis-arc-query-kotlin · git:20261012.0af903a · 2026-10-12 · sha256 b59f61e8c0bdbc2f
cratis-arc-query-kotlin git:20261012.0af903aA
Immutable. This exact content never changes and is served at /api/v1/blob/b59f61e8c0bdbc2f.
---
name: cratis-arc-query-kotlin
description: Add a query to a Cratis Arc @ReadModel in Kotlin or Java — companion-object or static query methods, GET vs RFC QUERY, and observable queries returning Kotlin Flow, JDK Flow.Publisher, or RxJava 3 Observable over SSE and WebSocket. Use when exposing read-model data from a Kotlin or Java Arc application. Do not use for the .NET Arc query shape, for command definition, or for how events populate the read model (Chronicle projections/reducers).
license: MIT
---
# Add a query to an Arc read model in Kotlin or Java
A `@ReadModel` is a plain Kotlin or Java type whose companion-object (Kotlin) or
static (Java) methods are queries. KSP generates a reflection-free
`QueryPerformer` per method; there is no controller to write.
## Verified product sources
Verified against `Arc.Kotlin` at tag **`v7.7.0`**: JDK 17, Gradle 8.14.4,
Kotlin 2.4.20, KSP 2.3.12, Spring Boot 4.1.1, JUnit Jupiter 6.1.3. `io.cratis:arc` supplies
`io.cratis.arc.artifacts.ReadModel`, `io.cratis.arc.queries.Path`,
`@QueryHttpMethod`, `@QueryTransport`, `ObservableState<T>`. The optional
`arc-rxjava3` artifact adds RxJava 3 `Observable` support at runtime. The
`queries/QueryPage.kt`, `QueryRendererFor.kt` and the observable pipeline
verify the paging and transport contracts below.
## Declare a one-shot query
```kotlin
@ReadModel
@AllowAnonymous
data class TaskView(val id: String, val title: String) {
companion object {
@JvmStatic
@Path("/api/tasks/by-id")
suspend fun byId(id: String, @FromServices repository: TaskRepository): TaskView? =
repository.byId(id)
@JvmStatic
@Path("/api/tasks")
fun all(@FromServices repository: TaskRepository): List<TaskView> = repository.all()
}
}
```
```java
@ReadModel
@AllowAnonymous
public record TaskView(String id, String title) {
@Path("/api/tasks")
public static List<TaskView> all(@FromServices TaskRepository repository) {
return repository.all();
}
}
```
Rules the framework enforces:
- Query methods live on the read model's Kotlin **companion object** or as
Java **static** methods. A custom instance method on a `@ReadModel` fails
compilation.
- Mark dependency parameters `@FromServices`; caller-supplied arguments stay
unannotated. A query may also declare exact non-null `QueryRequest` and
`QueryContext` parameters in any position — KSP resolves service, request,
and context parameters from the execution context while preserving
declaration order.
- Java query methods return a value or `CompletionStage<T>`; generated
performers await it inside Arc's request coroutine context.
- Kotlin caller parameters may declare defaults. GET/QUERY/observable
subscriptions preserve *omission* (leaving the argument out of
`QueryRequest.arguments`) so the generated performer runs the Kotlin default
expression; an explicit JSON/subscription `null` is a *supplied* value, not
omission, and is accepted only by a nullable parameter. Java query
parameters have no default feature, and overloaded query methods are
unsupported in either language.
- Only public, non-generic, non-overloaded methods whose return type contains
the enclosing read-model shape are treated as queries; adding `@Path`,
`@QueryHttpMethod`, or `@QueryTransport` explicitly marks a method as a
query, so an invalid annotated return still fails compilation.
## Choose a route and method
Without `@Path`, Arc derives a route from `cratis.arc.endpoints` settings
(prefix, skipped package segments, kebab case, artifact name). `@Path`
preserves what you write verbatim.
| Transport | When | Shape |
| --- | --- | --- |
| `GET` | scalar arguments | `?id=123` |
| `QUERY` (RFC 9110) | structured arguments, paging, sorting; enabled by `enable-query-http-method` | JSON body `{arguments, paging, sorting}` |
```bash
curl -sS 'http://localhost:8080/api/tasks/by-id?id=123'
curl -sS -X QUERY http://localhost:8080/api/tasks \
-H 'Content-Type: application/json' \
-d '{"arguments":{},"paging":{"page":0,"pageSize":25},"sorting":{"field":"title","direction":"ascending"}}'
```
GET reserves `page`, `pageSize`, `sortBy`, `sortDirection`. Client argument
names match case-insensitively for GET and QUERY. QUERY responses always carry
`Cache-Control: no-store`. Prefer GET for a query with `DateOnly`/`TimeOnly`
parameters — the pinned `@cratis/arc` client's QUERY body serializes those
component objects with native `JSON.stringify` rather than their typed
serializer, which GET does not go through.
`@QueryHttpMethod(GET|QUERY|AUTO)` sets the generated proxy's preference. Put
it on the `@ReadModel` class for a default, and override per method.
## Declare an observable query
Return one of three streaming families from a companion/static method; KSP
generates an `OBSERVABLE` performer automatically — no `@QueryTransport`
needed for these:
- Kotlin `kotlinx.coroutines.flow.Flow<T>` / `Flow<List<T>>`
- JDK `java.util.concurrent.Flow.Publisher<T>` / `Publisher<List<T>>`
- RxJava 3 `Observable<T>` / `ObservableSource<T>` / `Subject<T>` — requires
the optional `arc-rxjava3` artifact at runtime
A one-shot return (`T`, `List<T>`, `io.cratis.arc.queries.QueryPage<T>`) stays
request-response. `QueryPage(items, page, pageSize, totalItems)` carries a
provider-owned page, with zero-based page and `totalItems: Long`.
A matching application `QueryRendererFor<T>` chain owns data and paging:
Arc does not append implicit in-memory sorting/paging after it. Return
`QueryPage<T>` or include `QueryableQueryRenderer()` explicitly in that chain.
`QueryPaging.UNPAGED` and `QuerySorting.UNSORTED` represent no paging/sorting;
`InterceptReadModel<T>` is the read-model interception seam. The default
in-memory sorter reads public properties only; sorting is not authorization.
```kotlin
@ReadModel
@AllowAnonymous
data class TaskView(val id: String, val title: String) {
companion object {
@JvmStatic
@Path("/api/tasks/watch")
fun watch(@FromServices repository: TaskRepository): Flow<List<TaskView>> =
repository.watchAll()
}
}
```
### Give a Java query a current value to answer GET with
A plain `SubmissionPublisher` emits but holds nothing — nobody subscribing
learns the current value until the next change, and a snapshot `GET` against
it comes back `202 Not Ready` forever if nothing has happened yet. Kotlin has
`MutableStateFlow` for this; the JDK has no equivalent, so Arc supplies
`ObservableState<T>`:
```java
@Component
public final class TaskSource {
private final ObservableState<List<TaskView>> tasks = new ObservableState<>(List.of());
public Flow.Publisher<List<TaskView>> observe() {
return tasks;
}
public void publish(List<TaskView> updated) {
tasks.set(updated);
}
}
```
`ObservableState<T>` is a `Flow.Publisher<T>`, so a query method returns it
directly. Every subscriber sees the current value first and then each change;
a subscriber that falls behind sees only the newest value, not a backlog —
the right behavior for state rather than a stream of discrete events. Reach
for it whenever a **Java** observable query should also answer a plain `GET`.
Kotlin code has no reason to — return `MutableStateFlow` as a `Flow` instead.
### Consuming it
The same generated route serves three direct transports:
| Transport | Selected by | Behavior |
| --- | --- | --- |
| HTTP GET/QUERY | ordinary request | `200` with the current value when the source is a `StateFlow` (already holds one); a cold `Flow`/`Publisher` returns `202` (not ready) unless `waitForFirstResult=true`, optionally bounded by `waitForFirstResultTimeout=<seconds>` |
| SSE | `Accept: text/event-stream` | each result as `data: {QueryResult}\n\n` |
| WebSocket | upgrading the query route | `{"type":"Data","data":{QueryResult}}` frames, plus `Ping`/`Pong` |
Multiple subscriptions can multiplex over one physical connection through
`/.cratis/queries/ws`, or `/.cratis/queries/sse` with POST
`/.cratis/queries/sse/subscribe` / `/unsubscribe`. Every subscription still
passes through its own query's authorization — a protected query terminates
`Unauthorized` on that subscription without disturbing others sharing the
connection.
Set `transferMode=delta` to get the full first snapshot followed by change
sets only; `transferMode=full` sends every result as a full snapshot with no
change set. Omitting it keeps the legacy behavior of always sending both. With
`delta`, Arc prefers a stable item identity; without one it falls back to
serialized-JSON identity and reports only additions/removals (a changed field
is a removal plus an addition, never a replacement) — change sets never encode
reordering.
## Configure the WebSocket origin for a separate dev server
Spring answers a cross-origin WebSocket handshake with `403`, and a browser
reports that as a socket that silently never opens rather than as a visible
error. Serving the frontend from a dev server on another port (Vite, webpack
dev server) makes every observable-query WebSocket handshake cross-origin.
Set the allowed origin explicitly:
```properties
cratis.arc.observable-queries.allowed-origins=http://localhost:5173
```
Leave it empty in a real deployment — the WebSocket handshake is not subject
to the same-origin policy `fetch` obeys, so this setting is what stops another
site from opening a socket carrying your visitor's cookies.
## Storage-backed queries
For repository-backed paging, concept converters, tenant-aware storage and
Mongo/JPA observable snapshots, use `cratis-arc-spring-data-kotlin`.
Arc `7.7.0` also aligns Chronicle projection collection names with Arc MongoDB;
read [../cratis-arc-command-kotlin/references/read-model-naming.md](../cratis-arc-command-kotlin/references/read-model-naming.md)
when upgrading or customizing collection names.
## Route near misses
- Defining or changing a `@Command`: `cratis-arc-command-kotlin`.
- Adding a query-argument rejection rule: `cratis-arc-validation-kotlin`.
- In-process query specifications: `cratis-arc-testing-kotlin`.
- Identity or tenant resolution: `cratis-arc-identity-and-tenancy-kotlin`.
- How events populate the read model in the first place (Chronicle projections
and reducers): `cratis-chronicle-client-kotlin`, `cratis-chronicle-projection`,
`cratis-chronicle-reducer`.
- Server-side paging semantics shared with Arc .NET: `cratis-arc-query-paging`.
- The C# Arc query/read-model shape: the vertical-slices and Arc React skills.
## Verify
- Query methods live on the companion object (Kotlin) or as static methods
(Java), are public, non-generic, and not overloaded.
- Every dependency parameter is `@FromServices`; caller arguments are not.
- `@Path`, if declared, matches the route the frontend expects; otherwise the
conventional route (prefix, skipped segments, kebab case) is understood.
- An observable query's return type is one of the three recognized streaming
families, and the RxJava artifact is present when `Observable` is used.
- A `delta`-mode observable query has a stable item identity, not incidental
reliance on serialized-JSON fallback identity.
- `./gradlew build` is clean with zero warnings and zero errors.
- Use `QueryScenario` or bounded `ObservableQueryScenario.collect` from
`cratis-arc-testing-kotlin`; assert emission count, not only success.