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