Skip to content

CQRS in Kotlin and Java with Arc on Spring Boot

CQRS (Command Query Responsibility Segregation) means commands change state and queries read it, each with its own model. On the JVM, Cratis Arc is an open-source (MIT) CQRS framework for Spring Boot, published to Maven Central as io.cratis:arc. A command is an annotated class with a handle method, a query is a static method on a @ReadModel, and Arc generates the endpoints at compile time with KSP, plus typed TypeScript clients. Chronicle event sourcing is an optional layer on top.

This page is for Kotlin and Java developers who want CQRS without writing a controller per operation. If you are still deciding whether CQRS fits, read CQRS explained first.

CapabilityHow it looks in ArcMaturity
Commands@Command class with handle; regular, suspend (Kotlin) or CompletionStage (Java) handlersReleased
Queries and read models@ReadModel with static or @JvmStatic companion query methods; GET and RFC QUERYReleased
Live queriesKotlin Flow, JDK Flow.Publisher or RxJava 3 return types; SSE and WebSocketReleased
ValidationJakarta Bean Validation, plus reusable Arc validatorsReleased
Authorization@Authorize, @Roles and @AllowAnonymous on commands and queriesReleased
PersistenceSpring Data JPA and MongoDB integrationsReleased
Typed frontendTypeScript proxies generated at build timeReleased
Event sourcingChronicle through io.cratis:cratis; handle returns an eventReleased, optional

“Released” means the artifacts are published. Arc on the JVM does not claim full parity with Arc on .NET. The shared capability matrix and the JVM parity reference record each row, the evidence behind it, and what is not planned. One example: Arc on the JVM generates model-bound endpoints only, not controller-based ones.

A library application registers authors and lists them. The command carries its input as properties and its decision in handle. This version stores the author with Spring Data MongoDB and needs no Chronicle:

import io.cratis.arc.artifacts.Command
import io.cratis.arc.artifacts.FromServices
import java.util.UUID
@Command
data class RegisterAuthor(val id: UUID, val name: String) {
fun handle(@FromServices authors: AuthorRepository) {
authors.insert(Author(id, name))
}
}

The query side is a read model with a static method. Arc serves it over HTTP and generates the client types:

import io.cratis.arc.artifacts.FromServices
import io.cratis.arc.artifacts.ReadModel
import org.springframework.data.mongodb.repository.MongoRepository
import java.util.UUID
interface AuthorRepository : MongoRepository<Author, UUID>
@ReadModel
data class Author(val id: UUID, val name: String) {
companion object {
@JvmStatic
fun allAuthors(@FromServices authors: AuthorRepository): List<Author> = authors.findAll()
}
}

These are excerpts; a runnable project also needs the Gradle setup and Spring Boot application class from the Kotlin and Java getting started guides. The build runs KSP before compiling, so a model mistake surfaces as an ARCKSP compiler error rather than at startup. The command never returns the author list and the query never changes anything, so you can test, secure and change each on its own.

Validation, authorization and live queries

Section titled “Validation, authorization and live queries”

Validation and authorization are declared on the model, not inside handle. Authorization annotations go on the command or read-model class or on the operation:

@Authorize(policy = "activeSubscription")
@Roles("member")
@Command
class UpdateProfile {
fun handle(): Unit = Unit
}

An observable query returns a stream. Arc then serves the current value and each change over SSE or WebSocket. In Kotlin, return a Flow:

@ReadModel
@AllowAnonymous
data class TaskView(val id: String, val title: String) {
companion object {
@JvmStatic
fun all(@FromServices repository: TaskRepository): Flow<List<TaskView>> =
repository.observeAll()
}
}

In Java, the same method returns a java.util.concurrent.Flow.Publisher. A source that only emits holds no current value, so a plain GET snapshot answers 202 Not Ready. Kotlin uses MutableStateFlow and Java uses Arc’s ObservableState<T> to hold state. The observable queries guide shows both.

io.cratis:arc has no Chronicle dependency. When you want events as the source of truth, add io.cratis:cratis, the single dependency for an event-sourced Spring Boot application. Then handle returns the fact, and Arc appends it:

import io.cratis.arc.artifacts.Command
import io.cratis.arc.artifacts.CommandKey
import io.cratis.chronicle.events.EventType
@Command
data class RegisterAuthor(@CommandKey val id: String, val name: String) {
fun handle(): AuthorRegistered = AuthorRegistered(name)
}
@EventType
data class AuthorRegistered(val name: String)

An event-returning command needs a stable @CommandKey. Without one, Arc returns a validation error and does not guess the event source. A Chronicle projection or reducer then fills the read model, and the query keeps its shape. The Chronicle integration guide covers the starter, a compatible kernel version, and the runnable samples. For the event store itself, see event sourcing in Kotlin and Java.

Use it when reads and writes have different shapes, load or rules, when a frontend needs typed and live data, or when you may want event sourcing later without changing the query contract.

It is not worth it for a single form over a single table. Spring MVC with Spring Data is simpler there; Coming from Spring MVC compares the two. Arc on the JVM is also a Spring Boot framework: it targets servlet hosting, so if you are not on Spring Boot it does not apply. Arc needs JDK 17 and, for Kotlin, a current Kotlin compiler as stated in the getting started guide.

Is there a CQRS framework for Spring Boot?

Section titled “Is there a CQRS framework for Spring Boot?”

Yes. Arc on the JVM provides commands, queries, validation, authorization and generated TypeScript clients for Spring Boot, in Kotlin and Java. It does not claim full parity with Arc on .NET; the parity reference lists the gaps.

No. CQRS is a design choice. Arc handles commands and queries without an event store, over Spring Data JPA or MongoDB. Add Chronicle only if you want event sourcing.

Can I use CQRS in Java without event sourcing?

Section titled “Can I use CQRS in Java without event sourcing?”

Yes. io.cratis:arc has no Chronicle dependency. The same slice works over JPA or MongoDB, as the example above shows.

Yes. Java uses records or classes with annotations, CompletionStage and Flow.Publisher return types, and blocking bridges. Some contracts need an adapter bean in Java; the capability matrix names them.

Arc generates TypeScript proxies from the same compile-time metadata, for the same @cratis/arc packages used with the other backends. See TypeScript proxies.