Building the Query Side: Projections
Build the read side of a CQRS system: a projection that keeps a simple, fast, query-optimized table in sync with events, so reads never need to replay an aggregate.
Learning objectives
- Explain what a projection is and how it differs from an aggregate
- Define a plain read-model entity for the query side
- Write @EventHandler methods that keep a read model in sync with events
- Write a @QueryHandler method and call it through the QueryGateway
- Explain concretely what eventual consistency means in terms of this specific system
◆ Story
Imagine a librarian who, instead of re-reading an author's entire biography every single time someone asks "what books has this author written," keeps one small, always-current summary card per author. Every time that author publishes a new book, the librarian updates that one card. When someone later asks the question, the librarian just hands over the card — instant, no re-reading required, no digging back through years of publishing history.
A projection is exactly that librarian. It listens for events — CustomerCreatedEvent, CustomerUpdatedEvent, and so on — and keeps a simple, ready-to-read record updated in response, so that answering a query never requires replaying anything. This stands in deliberate contrast to how the write side works: an aggregate rebuilds its state from scratch by replaying history on every command, while a projection maintains one always-current row that a query can read directly, with no replay involved at all.
This split is the practical payoff of CQRS: writes go through a path built for correctness and auditability (the aggregate, replaying events), and reads go through a completely separate path built purely for speed (the projection, reading one row). Neither path needs to compromise to accommodate the other.
The write side built around an aggregate stores no current state directly — only events, replayed on demand to compute state. That's exactly right for handling commands, where correctness and auditability matter more than raw read speed, but it's a genuinely bad fit for answering a query like "show me this customer's details" or "list every customer with an overdue loan."
Answering either of those directly against the aggregate would mean replaying that aggregate's full event history on every single read, and for a query that needs to scan across many customers at once, replaying every one of their individual event histories just to render a list. That's slow, and it also couples your read patterns tightly to the shape of your event history, which makes evolving how you display or search data far harder than it needs to be.
The fix is a genuinely separate read model: an ordinary, simple database table shaped exactly for how the data needs to be read, kept in sync by listening for the same events the aggregate already produces. Answering a query then becomes an ordinary, fast database lookup against that table — no event replay, no aggregate loading, nothing more sophisticated than a normal SELECT. This section builds that table and the component, a projection, responsible for keeping it current.
A read model is a plain JPA entity — nothing about it needs to know anything about aggregates, event sourcing, or Axon at all. It's shaped purely for how the data needs to be read back, which often means it's simpler than the aggregate that produces the events feeding it, since it doesn't need to carry any fields that exist only for internal business-rule checks.
This table is genuinely separate from anything on the write side. It has no direct relationship to the event store at all — no foreign key back to it, no shared schema. It's an ordinary, disposable table that could be deleted entirely and perfectly rebuilt by replaying every past event through the same projection that normally keeps it updated live. It exists purely to make reads fast, and nothing else in the system should ever treat it as the "real" source of truth — the event store still holds that role, permanently.
That disposability is a genuine feature, not a limitation. It means a read model's shape can evolve — adding a new indexed column, splitting one table into two for a new query pattern — by rebuilding it from history, rather than needing a careful, risky migration of "live" data the way a traditional CRUD table would.
💻 Code example
@Entity public class CustomerReadModel { @Id private String customerId; private String name; private String email; public String getCustomerId() { return customerId; } public void setCustomerId(String customerId) { this.customerId = customerId; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } }
A projection is really just a normal Spring component with two jobs, both handled by the same class. It reacts to events by updating rows in the read model table, using @EventHandler methods — notice this is a different annotation from an aggregate's @EventSourcingHandler, because a projection isn't rebuilding anything from a replay; it's just reacting to events as they arrive. And it answers queries by reading straight from that same table, using @QueryHandler methods, with no event replay or aggregate loading involved at all.
On the API side, a controller never talks to CustomerAggregate directly for a read. It goes through the QueryGateway, dispatching a FindCustomerQuery, which Axon routes to the matching @QueryHandler — here, straight to the projection's own fast database lookup. This is CQRS's core promise, now genuinely working end to end: writes and reads are fully decoupled, with reads staying fast and simple regardless of how much validation or event-replaying complexity lives on the write side.
💻 Code example
@Component public class CustomerProjection { private final CustomerReadModelRepository repository; public CustomerProjection(CustomerReadModelRepository repository) { this.repository = repository; } @EventHandler public void on(CustomerCreatedEvent event) { CustomerReadModel readModel = new CustomerReadModel(); readModel.setCustomerId(event.getCustomerId()); readModel.setName(event.getName()); readModel.setEmail(event.getEmail()); repository.save(readModel); } @EventHandler public void on(CustomerUpdatedEvent event) { CustomerReadModel readModel = repository.findById(event.getCustomerId()).orElseThrow(); readModel.setName(event.getName()); readModel.setEmail(event.getEmail()); repository.save(readModel); } @EventHandler public void on(CustomerDeletedEvent event) { repository.deleteById(event.getCustomerId()); } @QueryHandler public CustomerReadModel handle(FindCustomerQuery query) { return repository.findById(query.getCustomerId()) .orElseThrow(() -> new CustomerNotFoundException(query.getCustomerId())); } } @RestController public class CustomerQueryController { private final QueryGateway queryGateway; public CustomerQueryController(QueryGateway queryGateway) { this.queryGateway = queryGateway; } @GetMapping("/customers/{id}") public CompletableFuture<CustomerReadModel> getCustomer(@PathVariable String id) { return queryGateway.query(new FindCustomerQuery(id), CustomerReadModel.class); } }
There is a small, genuinely real gap in time between an aggregate applying an event and a projection's @EventHandler actually reacting to it and updating the read model — for most systems, milliseconds, but never truly zero. If an API immediately tries to read a customer right after creating them, there's a real, if small, chance the projection hasn't finished updating yet, and the read returns stale or missing data. This is precisely what "eventual consistency" concretely means here — not a vague warning in a textbook, but this exact, specific window between a write being accepted and a read reflecting it.
The practical mistake to avoid is designing an API (or setting a team's expectations) as if a write is instantly visible to every subsequent read. A common pattern to handle this well is having the endpoint that issues the original command return enough information for the client to poll or wait briefly before re-reading, rather than assuming an immediate follow-up read will always see the change.
A second common mistake is letting a projection's @EventHandler method silently swallow exceptions. If updating the read model fails partway — say, the customer row doesn't exist yet when an update event arrives out of expected order — that failure should be visible, not hidden, since a silently-failing projection quietly drifts out of sync with the event store it's supposed to mirror, and that drift can go unnoticed for a long time if nothing surfaces it.
▲ Edge case
Since a read model is fully rebuildable from the event store, a projection observed to be out of sync doesn't need heroic manual data-fixing — replaying the relevant events back through the same @EventHandler methods against a freshly emptied table restores it exactly. That rebuildability is the safety net that makes projections a low-risk place to experiment with new query shapes.
Multiple projections can listen to the exact same event stream at once, each maintaining a completely different read model shaped for a different use case, without ever touching the write side or each other. A bank-style system might maintain one projection producing a simple per-customer summary table for a customer-facing app, and a completely separate projection consuming the same AccountUpdatedEvent stream to maintain an aggregated table of daily transaction volumes for an internal reporting dashboard — two very different shapes, same events, no coordination needed between the two projections.
This is also where search and reporting infrastructure typically plugs in. A projection's @EventHandler methods aren't limited to writing into a relational table — the same events could just as easily update a search index or a document store optimized for a specific query pattern, since the projection's only real job is "react to this event, update whatever storage backs this particular read model."
Because a read model is fully disposable and rebuildable, teams commonly use this to safely evolve read patterns over time: add a new projection listening to the same historical events to support a new feature, backfill it by replaying the full event history once, and it's immediately caught up — with zero changes required on the write side that originally produced those events.
-
Q: Does answering a query ever involve replaying events from the aggregate? A: No — a query is answered directly from the projection's own simple, pre-built read model table, with no event replay or aggregate loading involved.
-
Q: What specifically causes the small delay behind "eventual consistency" in this kind of system? A: The real, small gap in time between an aggregate applying an event and the projection's @EventHandler actually reacting to it and updating the read model.
-
Q: Why is a projection's read model table considered disposable? A: Because it has no direct relationship to the event store and holds no unique data of its own — it can be deleted entirely and perfectly rebuilt by replaying every past event back through the same projection.
-
Q: What annotation does a projection use to react to events, and how is it different from an aggregate's annotation for the same purpose? A: @EventHandler, as opposed to an aggregate's @EventSourcingHandler — the distinction reflects that a projection is just reacting to events as they arrive, not participating in a full state-rebuilding replay.
-
Q: Can more than one projection listen to the same event stream? A: Yes — multiple projections can each maintain a completely different, independently-shaped read model from the same underlying events, without needing to coordinate with each other or with the write side.
Want a visual for this concept?
Generate a diagram tailored to “Building the Query Side: Projections” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →