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.
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
DataSourcePropertiesmoved in Spring Boot 4: it's noworg.springframework.boot.jdbc.autoconfigure.DataSourcePropertiesin artifactspring-boot-jdbc, notorg.springframework.boot.autoconfigure.jdbc.DataSourceProperties. Same family of split as@DataJpaTestand@WebMvcTestin thehexagonalmodule's notes.- This module, like
order-fulfillmentandoutbox, does not inheritspring-boot-starter-parent, so it does not get-parameterspassed tojavacfor free. Any@PathVariable/@RequestParamrelying on its own parameter name (no explicitname = "...") fails at request time, not at compile time -- seeoutput/03-missing-parameters-flag-failure.txt. Worth flagging on the way past:order-fulfillment'sOrderControllerhas 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'sPublishedEventsExtension, never a real HTTP call toGET /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.
AsyncProfileStalenessWindowTestoriginally 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 inoutput/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.