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
+20
View File
@@ -0,0 +1,20 @@
$ mvn -pl cqrs test -Dtest=SyncProfileLatencyTest
(default profile -- SyncOrderSummaryProjection active; its write to order_summary runs
on the same thread that is about to answer the command's HTTP request)
2026-10-04T02:27:21.596+05:30 INFO 17202 --- [cqrs] [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:cqrs-write user=SA
2026-10-04T02:27:22.729+05:30 INFO 17202 --- [cqrs] [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-2 - Added connection conn10: url=jdbc:h2:mem:cqrs-read user=SA
POST /orders (sync profile) took 670 ms, orderId=9ee5b016-7a81-40f9-8d7a-d7d1959c09be
Immediately after POST returned, GET /order-summaries/9ee5b016-7a81-40f9-8d7a-d7d1959c09be -> status=200 OK, body=OrderSummary[orderId=9ee5b016-7a81-40f9-8d7a-d7d1959c09be, customerName=Priya, itemCount=2, totalCents=7597, status=PLACED, updatedAt=2026-10-03T20:57:24.283700Z]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Two real HikariCP pools, one per DataSource bean -- that line alone is a cheap way to
confirm the write and read sides really are two separate connection pools to two separate
H2 databases, not one database with two tables. The 670ms includes the 300ms the projection's
simulated write delay adds on top of real JPA/Hibernate first-request costs (see
output/01-async-profile-staleness-window.txt's warm-up note for what that baseline actually
is) -- and because that write finished before the HTTP response did, the very next GET
against the read model, with no wait at all, already sees itemCount, totalCents and status
correctly populated.
@@ -0,0 +1,27 @@
$ mvn -pl cqrs test -Dtest=AsyncProfileStalenessWindowTest
(async-projection profile -- AsyncOrderSummaryProjection active; @Async hands the same
write off to a separate thread instead of running it inline. A throwaway warm-up POST
runs first and is not measured: the first HTTP request against a freshly started test
context pays Hikari/Hibernate first-use costs that have nothing to do with this
profile's actual behaviour -- measuring that cold first request directly made POST
/orders look like it took ~370ms even in this profile, which would have buried the
exact thing the test exists to show.)
2026-10-04T02:27:31.206+05:30 INFO 17346 --- [cqrs] [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:cqrs-write user=SA
2026-10-04T02:27:32.304+05:30 INFO 17346 --- [cqrs] [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-2 - Added connection conn10: url=jdbc:h2:mem:cqrs-read user=SA
POST /orders (async profile) took 14 ms, orderId=8451a829-7ef6-4d19-b839-2d9ce5885437
Immediately after POST returned (inside the staleness window), GET /order-summaries/8451a829-7ef6-4d19-b839-2d9ce5885437 -> status=404 NOT_FOUND, body=null
After waiting for the projection (outside the staleness window), GET /order-summaries/8451a829-7ef6-4d19-b839-2d9ce5885437 -> status=200 OK, body=OrderSummary[orderId=8451a829-7ef6-4d19-b839-2d9ce5885437, customerName=Dev, itemCount=1, totalCents=8999, status=PLACED, updatedAt=2026-10-03T20:57:33.971972Z]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
This is the trade-off in one transcript, warmed-up and measured honestly. The identical
command, in the identical module, against the identical 300ms simulated write cost, took
14ms here instead of 670ms -- because the projection's write moved to another thread and
the HTTP response stopped waiting for it. The price is the 404 on the very next line: a
client that queries in that window, a few dozen milliseconds wide in this run, gets a
"not found" for an order that was just placed successfully. Only after the test waits
(Awaitility, polling every 50ms, up to 2 seconds) does the same GET return 200 with the
order correctly summarized. Neither profile is "more correct" than the other; they are
the same correctness guarantee, paid for at a different point in the request.
@@ -0,0 +1,17 @@
$ mvn -pl cqrs test -Dtest=QuerySideArchitectureTest
(plain JUnit reflection against the compiled OrderQueryController class -- no Spring
context started, because this is a claim about the class file, not about runtime wiring)
field readJdbcTemplate : org.springframework.jdbc.core.JdbcTemplate
OrderQueryController constructor parameter types: [class org.springframework.jdbc.core.JdbcTemplate]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
The whole proof is in those two lines: OrderQueryController has exactly one declared
field and exactly one constructor parameter, and both are JdbcTemplate -- not
OrderRepository, not an EntityManager, not anything from the com.ankurm.cqrsdemo.command
package. A reviewer does not have to trust the Javadoc comment on the class or remember
to check it by hand on the next change; this test fails the build the moment anyone adds
a write-side dependency to the query side, the same way the hexagonal-architecture post's
`mvn dependency:tree` turns "the core has no Spring dependency" from a promise into
something Maven enforces.
@@ -0,0 +1,21 @@
Captured by temporarily building this module without the <parameters>true</parameters>
maven-compiler-plugin configuration (this module does not inherit spring-boot-starter-parent,
which is what normally adds that flag for free), then running the SyncProfileLatencyTest,
which calls GET /order-summaries/{orderId} over real HTTP. The real, unedited response and
server-side exception:
DEBUG raw body: {"timestamp":"2026-10-03T20:54:06.783Z","status":500,"error":"Internal Server Error","path":"/order-summaries/c6fc4ad0-f98e-4e05-92a8-8717e9bffced"}
2026-10-04T02:24:06.801+05:30 ERROR 16353 --- [cqrs] [o-auto-1-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalArgumentException: Name for argument of type [java.lang.String] not specified, and parameter name information not available via reflection. Ensure that the compiler uses the '-parameters' flag.] with root cause
java.lang.IllegalArgumentException: Name for argument of type [java.lang.String] not specified, and parameter name information not available via reflection. Ensure that the compiler uses the '-parameters' flag.
Re-adding <parameters>true</parameters> to cqrs/pom.xml's maven-compiler-plugin configuration
and re-running the exact same test produces the green run captured in
00-sync-profile-latency.txt, with a 200 and a real body instead of this 500.
Worth noting on the way past: order-fulfillment's OrderController (post #31) has the exact
same unqualified @PathVariable Long id, in the exact same kind of standalone-reactor module
with no inherited parent -- it has just never been caught, because that module's own test
suite exercises its event-publication behaviour through Spring Modulith's
PublishedEventsExtension rather than through a real HTTP call to GET /orders/{id}. The bug is
latent there, not fixed; flagged separately rather than changed here.