The usual way a Spring Boot CRUD app grows is: one @Entity, one
JpaRepository, one @Service that both changes the data and answers
every question about it. That works fine until the question side and the change side start
wanting different things from the same table — the list screen wants a flattened,
joined, pre-counted shape; a report wants yesterday’s totals without locking today’s writes;
a dashboard wants to poll something that isn’t the primary database at all. CQRS
(Command Query Responsibility Segregation) is the name for refusing to force both jobs onto one
model, and this post builds the smallest version of it that still tells the truth: two real
databases, a plain Spring event connecting them, and a trade-off you can watch on a stopwatch
instead of just read about.
“Without a framework” in the title is deliberate. No Axon, no event store, not even the
Spring Modulith event publication registry this series’ outbox post
builds around — just ApplicationEventPublisher,
@TransactionalEventListener, @Async, and two
DataSource beans. If you’ve never touched Spring events before, that
sentence will make complete sense by the end of this page.
Versions used in this post. Spring Boot 4.1.1 · Spring Framework 7.0.9 · Hibernate ORM 7.4.5.Final · H2 2.4.240 · JDK 25 LTS. Every number and every line of output below came out of a real mvn test run against this post’s companion repository — including the 404 three paragraphs from now, which is the whole point of this post and not a typo.
One model, two jobs, and they want different things
Picture an order system with the usual single Order JPA entity. The command
side — placing an order, shipping it, cancelling it — wants that entity exactly as
it is: normalized, one row per order, one row per line item, easy to validate and save. The
query side — “show me this customer’s orders”, “what’s the total for order #4471” —
wants something else entirely: a flat, already-summed, already-joined shape with no foreign
keys to chase. Force one model to do both and you end up with one of two things: a write model
cluttered with computed, display-only fields nobody writes to, or a query layer doing an N+1
join across entities every time someone opens a list page.
CQRS’s answer sounds extreme the first time you hear it and turns out to be simple in practice: stop insisting it’s one model. The command side keeps the normalized shape it actually needs for validation and consistency. The query side gets its own, denormalized, disposable shape, built for reading and nothing else. Something has to keep the second one updated when the first one changes — that something is an event, and the rest of this post is what happens when you build that connection for real instead of waving at it.
If “split into two services” is where your mind went next, pull back: this is one Spring Boot application, one JVM, two databases. The split that matters here is the model, not the deployment — see the closing section for when it’s worth going further than this.
The smallest correct shape: two stores, one event, one listener
Everything in this post is one diagram, built in code. A command changes the write-side database and publishes an event in the same transaction. After that transaction commits, a listener reacts to the event and updates the read-side database. A query never touches the write side at all — it can’t, because it’s never given anything that would let it:
Two separate H2 databases, not two schemas in one — jdbc:h2:mem:cqrs-write
and jdbc:h2:mem:cqrs-read, two independent DataSource beans, two
independent connection pools. That’s a stronger separation than most real CQRS setups need (a
lot of production systems use the same physical database with a read replica, or even just a
separate schema), but for a companion repository meant to be run and inspected, physically
separate databases make the boundary impossible to blur by accident while you’re reading the
code.
The write side: a command, and an event published in the same transaction
The command service does exactly two things per method, in one transaction: change the
orders/order_lines tables, and publish the event that says so.
@Transactional
public String placeOrder(String customerName, List<OrderLine> lines) {
String orderId = UUID.randomUUID().toString();
Order order = new Order(orderId, customerName, lines);
orderRepository.save(order);
events.publishEvent(new OrderPlaced(orderId, customerName, lines, order.totalCents()));
return orderId;
}
cqrs/src/main/java/com/ankurm/cqrsdemo/command/OrderCommandService.java
events here is a plain ApplicationEventPublisher — no
registry, no outbox table. OrderPlaced carries everything the read side will need
(customer name, every line, the total), so the projection never has to call back into the
write side to fill in a blank:
public record OrderPlaced(String orderId, String customerName, List<OrderLine> lines, long totalCents) {
}
cqrs/src/main/java/com/ankurm/cqrsdemo/command/OrderPlaced.java
Whether this event gets delivered at all if the process crashes between commit and delivery is exactly the gap the transactional outbox post in this series exists to close, with a registry that tracks every publication until it’s confirmed handled. This module deliberately doesn’t reach for that — the two posts are about different problems (this one is about the shape of the split itself) and are meant to be combined, not duplicated. If you’re building this for real, read both.
Two things worth knowing before they bite: if a listener method doesn’t wrap a risky write
in its own error handling, an exception there can silently lose the read-side update with no
error surfaced anywhere near the command that triggered it; and @Transactional
only defers event delivery to AFTER_COMMIT because the listener is annotated
@TransactionalEventListener — a plain @EventListener would fire
during the write transaction, before there’s anything committed to be consistent with.
Going deeper: AFTER_COMMIT is one of four phases, and the other three answer different questions
@TransactionalEventListener takes a phase argument with four options, and reaching for the wrong one produces a listener that runs at a technically valid but practically useless moment:
- AFTER_COMMIT (the default, and what this module uses) — the transaction succeeded; whatever the event describes is durably true. This is the only phase that makes sense for updating a read model, because it is the only one that guarantees the write side and the event agree.
- BEFORE_COMMIT — runs inside the still-open transaction, so an exception here can still roll it back. Useful for a last validation step that needs to see the about-to-be-committed state; wrong for a read-model update, because the write might yet be rolled back after this phase runs.
- AFTER_ROLLBACK and AFTER_COMPLETION — the former only on failure, the latter on either outcome. Both exist for cleanup and compensating actions, not for propagating a success that may not have happened.
Picking AFTER_COMMIT for a projection isn’t a default you can take for granted — it’s the one phase whose guarantee actually matches what a read model needs.
The smallest thing that works: a listener, a second database, a real round trip
The projection listener does the opposite job — it never touches
OrderRepository or an EntityManager, only the read side’s own
JdbcTemplate:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void on(OrderPlaced event) {
writer.applyOrderPlaced(event);
}
cqrs/src/main/java/com/ankurm/cqrsdemo/read/SyncOrderSummaryProjection.java
Run the test that exercises this over real HTTP — place an order, then immediately ask the read side for it — and both halves check out:
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]
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Full transcript: cqrs/output/00-sync-profile-latency.txt
670ms for one HTTP request is slow, and deliberately so — the projection has a configurable simulated write delay (300ms, standing in for whatever a real read-model write costs: a second database round trip, a search index update, a cache invalidation) so the cost of this architecture shows up on a stopwatch instead of needing to be taken on faith. Right now, in this profile, that cost is paid by the person who just placed the order, before they get a response. The next section is about what happens when you decide that’s not an acceptable trade.
Going deeper: two DataSource beans without disabling Boot’s autoconfiguration
Giving the read side its own DataSource without breaking JPA’s autoconfiguration (which expects to find exactly one DataSource bean, or one obviously primary one) takes two @ConfigurationProperties-bound beans, one marked @Primary:
@Bean
@Primary
@ConfigurationProperties("spring.datasource.write")
public DataSourceProperties writeDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
@Primary
public DataSource writeDataSource(@Qualifier("writeDataSourceProperties") DataSourceProperties properties) {
return properties.initializeDataSourceBuilder().build();
}cqrs/src/main/java/com/ankurm/cqrsdemo/config/DataSourceConfig.java
@Primary on the write side is what lets Spring Data JPA’s autoconfiguration find it with zero further configuration — no manual EntityManagerFactory bean needed. The read DataSource is reachable only by its explicit @Qualifier("readDataSource"), used exactly once, in the JdbcTemplate bean the query side depends on.
One more Boot 4 relocation worth recording here: DataSourceProperties is now org.springframework.boot.jdbc.autoconfigure.DataSourceProperties in a dedicated artifact, spring-boot-jdbc — not org.springframework.boot.autoconfigure.jdbc.DataSourceProperties, which is where it lived through Boot 3 and where most existing blog posts (and some IDE autocomplete caches) still point. Confirmed by downloading spring-boot-autoconfigure-4.1.1.jar and finding it almost entirely empty — nearly everything that used to live there moved into small, dedicated artifacts like this one, the same pattern this series has already hit with @DataJpaTest and @WebMvcTest in the hexagonal architecture post.
What “the query side can’t reach the write side” actually means
It’s easy to write a comment that says a class has no dependency on something. It’s more
convincing to have a test that would fail the moment someone adds one. OrderQueryController
has a single constructor and a single field, and a test inspects both through plain reflection
— no Spring context, because this is a claim about the compiled class, not about runtime
wiring:
Constructor<?>[] constructors = OrderQueryController.class.getDeclaredConstructors();
Class<?>[] paramTypes = constructors[0].getParameterTypes();
assertThat(paramTypes).containsExactly(JdbcTemplate.class);
cqrs/src/test/java/com/ankurm/cqrsdemo/QuerySideArchitectureTest.java
field readJdbcTemplate : org.springframework.jdbc.core.JdbcTemplate
OrderQueryController constructor parameter types: [class org.springframework.jdbc.core.JdbcTemplate]
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
Full transcript: cqrs/output/02-query-side-architecture-proof.txt
This is the same move as the hexagonal architecture post’s
mvn dependency:tree proof that a module’s core has no Spring dependency, applied
to a different boundary: not “which framework is on the classpath” but “which other part of
this application can this class reach.” It’s also a smaller, less structural version
of what the Spring Modulith boundary-enforcement post in
this series does with ApplicationModules.verify() — that tool checks every
module boundary in an entire codebase against a convention; this is one hand-written reflection
test checking one specific, important boundary. Reach for Modulith’s heavier guarantee once you
have more than a couple of these to keep honest; a focused test like this one is enough for a
single seam.
- Spring Framework reference — transaction-bound events, for exactly what
AFTER_COMMIT,BEFORE_COMMIT, and the other phases guarantee
The trade-off CQRS doesn’t let you avoid: latency versus freshness
Swap one annotation and the exact same write moves to another thread instead of running inline:
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void on(OrderPlaced event) {
writer.applyOrderPlaced(event);
}
cqrs/src/main/java/com/ankurm/cqrsdemo/read/AsyncOrderSummaryProjection.java
Both versions ship in this one repository, switched with a Spring profile
(async-projection), so you can run the exact same command against both and watch
what actually changes:
And the real transcript behind the right-hand half of that picture — a 404 for an order that genuinely, successfully exists:
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]
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Full transcript: cqrs/output/01-async-profile-staleness-window.txt
Neither profile is “more correct.” They offer the identical consistency guarantee — the read model always catches up — paid for at a different point. Sync makes the person who just placed the order wait for the whole system to agree with itself. Async lets them go immediately, at the cost of a real window, a few dozen milliseconds wide in this repository but potentially much wider under real load, in which the thing they just did looks like it hasn’t happened yet to anyone who asks. Which one is right depends entirely on what “asks” means in your system — the same person’s next page load, a different person’s dashboard, or a billing job that runs overnight all tolerate that window completely differently.
What breaks, and what the symptom actually looks like
Two real failures came out of building this, and both are worth knowing before you hit them yourself rather than after.
The first: this module, like every standalone companion project in this repository, doesn’t
inherit spring-boot-starter-parent — which is what normally passes
-parameters to javac for free. Without it, @PathVariable String
orderId compiles cleanly and fails at request time:
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.
Full transcript, captured by actually removing the fix and re-running the test: cqrs/output/03-missing-parameters-flag-failure.txt
The fix is a maven-compiler-plugin block with <parameters>true</parameters>
in this module’s own pom.xml. Worth flagging on the way past, not fixed here: the
earlier order-fulfillment module in this same repository has
the exact same unqualified @PathVariable, in the exact same kind of standalone
reactor — it has simply never been caught, because that module’s own tests exercise its
behaviour through Spring Modulith’s event-testing support, never through a real HTTP call to
the endpoint carrying the bug.
The second is a testing lesson, not a framework one. The first version of the async-profile test measured the very first HTTP request against a freshly started test context directly, and that request’s own warm-up cost — Hikari pool startup, Hibernate’s first use — came to roughly 370ms, comfortably past the 300ms this test exists to prove the async path doesn’t pay. A throwaway warm-up request before the measured one fixed it:
The first request against a fresh Spring context is never a fair benchmark. If a “should be fast” assertion fails by a suspiciously specific amount on the very first request your test makes, measure a second request before concluding the code is slow.
Should every read path get its own store? No — this is the most expensive answer to “the query is slow” and it should be the last one you reach for, not the first. A well-chosen index, a database view, or just a dedicated read-only query method on the existing repository solves the large majority of cases that look like they need CQRS. Reach for an actual second store and a projection, as built in this post, once a read pattern is genuinely incompatible with the write model’s shape — heavy denormalization across several aggregates, a reporting load that would contend with transactional writes, or a read side that wants a completely different technology (a search index, a cache, a graph store) than the write side needs. If you can’t answer “what happens to a reader who queries during the staleness window” for your actual use case, that is the sign you are not ready to turn this on yet.
Further reading
- cqrs companion repository — versions, the two-profile quickstart, and the output/ index
- Transactional Outbox with the Spring Modulith Event Publication Registry — closes the crash-between-commit-and-delivery gap this post’s plain events leave open
- Spring Modulith 2.1: Enforcing Module Boundaries Inside a Spring Boot Monolith — the heavier, whole-codebase version of the boundary proof this post does by hand for one seam
- Modular Monolith vs Microservices in 2026: A Java Decision Framework — for when “split the model” isn’t enough and the question becomes splitting the service
- Hexagonal Architecture (Ports and Adapters) in Spring Boot 4 — another boundary proved by a real command instead of a comment, from a different angle
- Martin Fowler — CQRS, including his own warnings about reaching for it too early
- Spring Framework reference — transaction-bound events
No Comments yet!