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.
This commit is contained in:
2026-10-03 20:59:55 +00:00
parent d815a37f2e
commit b4a623b889
27 changed files with 1086 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
# cqrs
Companion project for the article **[CQRS in Spring Boot Without a Framework: Separate Read Models and Projections](https://ankurm.com/cqrs-spring-boot-without-a-framework-separate-read-models-projections/)** on **[ankurm.com](https://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`](../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`](src/test/java/com/ankurm/cqrsdemo/QuerySideArchitectureTest.java),
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`](src/main/java/com/ankurm/cqrsdemo/read/SyncOrderSummaryProjection.java) | pays the projection's write cost | consistent the instant the command returns |
| `async-projection` | [`AsyncOrderSummaryProjection`](src/main/java/com/ankurm/cqrsdemo/read/AsyncOrderSummaryProjection.java) | does not pay it | consistent a short, measurable while later |
```bash
mvn -pl cqrs test -Dtest=SyncProfileLatencyTest # default profile
mvn -pl cqrs test -Dtest=AsyncProfileStalenessWindowTest # -Dspring.profiles.active=async-projection, set in the test
```
## Quickstart
```bash
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`](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`](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`](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`](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`](../../spring-boot-demo/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.