Skip to main content

CQRS in Spring Boot Without a Framework: Separate Read Models and Projections

A real CQRS split built with no framework at all: a JPA write model, a second H2 database reached only through its own JdbcTemplate, and two interchangeable projection listeners (synchronous and @Async) that make the latency-versus-freshness trade-off measurable instead of theoretical, including a genuine 404 for an order that was just placed successfully.

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:

Command and query never share a path OrderCommandController POST /orders OrderCommandService @Transactional write DB cqrs-write (JPA) projection listener @TransactionalEventListener OrderPlaced read DB cqrs-read (JdbcTemplate) OrderQueryController GET /order-summaries The query controller’s only dependency is the read DB’s JdbcTemplate — there is no arrow from it back to the write side anywhere in this picture, and that absence is enforced, not assumed (see below).

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.

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:

Same event, same write, paid for at a different moment sync profile POST /orders — 670 ms (includes the read-model write) 200 async profile 14ms 200 (order placed) staleness window — read model not updated yet GET → 404 GET → 200, consistent Both profiles write the identical row with the identical 300ms simulated cost — the only thing that moves is which side of the HTTP response that cost sits on, and whether a query that lands too early sees a 404 for it.

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

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.