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:
+102
@@ -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.
|
||||
Reference in New Issue
Block a user