# 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 `true` -- 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.