Files
asmhatre b4a623b889 Add cqrs module: CQRS in Spring Boot Without a Framework
Separate write/read DataSources, a JPA command side, and two interchangeable
projection listeners (sync and @Async) demonstrating the real latency-versus-
freshness trade-off CQRS forces. Includes a reflection-based proof that the
query side has no dependency on the write side, and a real failure/fix
transcript for the -parameters compiler flag this standalone reactor doesn't
inherit from spring-boot-starter-parent.
2026-10-03 20:59:55 +00:00

5.8 KiB

cqrs

Companion project for the article CQRS in Spring Boot Without a Framework: Separate Read Models and Projections on ankurm.com.

There is deliberately no docs/ folder: the deeper material lives in collapsible "going deeper" sections inside the article itself, next to the paragraph each one extends.

"Without a framework" means exactly that: no Axon, no Spring Modulith event publication registry (that's the companion outbox module, a different post), no event store. Just plain Spring -- ApplicationEventPublisher, @TransactionalEventListener, @Async, two DataSource beans, and JdbcTemplate.

Versions

Spring Boot 4.1.1
Spring Framework 7.0.9
JDK 25 (Temurin 25.0.4.1+1)
Maven 3.9
H2 2.4.240
Hibernate ORM 7.4.5.Final

The shape

Two databases, one Spring Boot application:

Database Reached through Who writes to it
Write side jdbc:h2:mem:cqrs-write Spring Data JPA (OrderRepository) OrderCommandService only
Read side jdbc:h2:mem:cqrs-read a single JdbcTemplate bean (readJdbcTemplate) the projection listener only

OrderCommandService changes the write side and publishes an event in the same transaction. A projection listener reacts to that event after the transaction commits and updates the read side. OrderQueryController reads only from the read side -- see QuerySideArchitectureTest, which checks that by reflection rather than by convention.

The two profiles

The same projection write (OrderSummaryWriter, with a configurable simulated delay to stand in for whatever a real read-model write costs) is called two different ways:

Profile Listener Command path Read model
(default) SyncOrderSummaryProjection pays the projection's write cost consistent the instant the command returns
async-projection AsyncOrderSummaryProjection does not pay it consistent a short, measurable while later
mvn -pl cqrs test -Dtest=SyncProfileLatencyTest              # default profile
mvn -pl cqrs test -Dtest=AsyncProfileStalenessWindowTest     # -Dspring.profiles.active=async-projection, set in the test

Quickstart

export JAVA_HOME=/path/to/jdk-25
mvn -pl cqrs -am spring-boot:run                       # default (sync) profile
mvn -pl cqrs -am spring-boot:run -Dspring-boot.run.profiles=async-projection

curl -X POST localhost:8080/orders \
  -H 'Content-Type: application/json' \
  -d '{"customerName":"Priya","lines":[{"sku":"USB-C-HUB","quantity":1,"unitPriceCents":4999}]}'

curl localhost:8080/order-summaries

Captured output

File What it shows
output/00-sync-profile-latency.txt the default profile: command latency includes the projection write, and the read model is already correct the instant the command returns
output/01-async-profile-staleness-window.txt the async-projection profile: the same command returns in milliseconds, and a query that lands inside the staleness window gets a real 404 for an order that was just placed
output/02-query-side-architecture-proof.txt reflection over the compiled OrderQueryController class proving its only dependency is the read-side JdbcTemplate
output/03-missing-parameters-flag-failure.txt the real 500 and IllegalArgumentException that come back from GET /order-summaries/{orderId} if this module is built without <parameters>true</parameters> -- captured by actually removing it and running the test, not reconstructed from memory

A note for anyone copying this shape

  • DataSourceProperties moved in Spring Boot 4: it's now org.springframework.boot.jdbc.autoconfigure.DataSourceProperties in artifact spring-boot-jdbc, not org.springframework.boot.autoconfigure.jdbc.DataSourceProperties. Same family of split as @DataJpaTest and @WebMvcTest in the hexagonal module's notes.
  • This module, like order-fulfillment and outbox, does not inherit spring-boot-starter-parent, so it does not get -parameters passed to javac for free. Any @PathVariable/@RequestParam relying on its own parameter name (no explicit name = "...") fails at request time, not at compile time -- see output/03-missing-parameters-flag-failure.txt. Worth flagging on the way past: order-fulfillment's OrderController has the exact same unqualified @PathVariable, in the exact same kind of module, and has never hit this -- its own test suite exercises that controller only through Spring Modulith's PublishedEventsExtension, never a real HTTP call to GET /orders/{id}. The bug is latent there; this module doesn't touch that one, this just flags it.
  • The first HTTP request against a freshly started test context is not a fair baseline. AsyncProfileStalenessWindowTest originally measured that first request directly and the ~370ms of Hikari/Hibernate first-use cost looked like it came from the projection. A throwaway warm-up request before the measured one fixed it -- see the comment at the top of that test and the note in output/01-async-profile-staleness-window.txt.

No docs/ chapter directory in this repository -- the intermediate and reference-depth material that would normally live there is in accordion sections inside the WordPress post itself.