Event Sourcing Handlers & Rebuilding State
Learn exactly how @EventSourcingHandler methods apply state changes to an aggregate, and the genuinely surprising truth about how Axon rebuilds an aggregate from scratch on every command.
Learning objectives
- Write an @EventSourcingHandler method that applies a field-level state change
- Explain why an event sourcing handler must never throw for a business-rule reason
- Add update and delete handling to an aggregate, following the command/event pair pattern
- Explain how Axon actually loads an aggregate's current state before handling a new command
◆ Story
Think back to a bank statement kept as a plain diary of deposits and withdrawals, nothing more. If someone asks a diligent clerk "what's my balance right now," the clerk has no shortcut. They genuinely start at the very first entry, and add every single deposit and every withdrawal, in order, until they reach the most recent one. There's no separate "current balance" line anywhere they could glance at instead — the balance only exists as the running result of reading the whole history in order.
That process, done faithfully every single time, is exactly what an aggregate does whenever it needs to handle a new command. Its current state — every field on the class — is entirely a derived, computed thing, produced by replaying its full event history from the beginning, in the order those events originally happened. Nothing about an aggregate's state is ever stored directly as "the current row" anywhere.
This topic is about the method that does that replaying work, field by field: the @EventSourcingHandler. Understanding it well is what makes the rest of an event-sourced system make sense, because every single state change anywhere in the write side ultimately goes through a method shaped like the ones built here.
A command handler's job is to decide whether something is allowed and, if so, apply an event describing it. But applying an event and actually updating the aggregate's fields to reflect it are two different steps — and Axon deliberately keeps them separate, handled by two different kinds of methods.
The reason for that separation is subtle but important. By the time an @EventSourcingHandler method runs, the corresponding event has already been decided and, in most real flows, already durably stored. There is no more "should this be allowed" question left to ask — that question was answered earlier, in the command handler. If an event sourcing handler were to throw an exception, Axon would have no sensible way to recover: the event is already permanently part of the aggregate's history, but the aggregate's own in-memory state can't be made to reflect it.
This is exactly why event sourcing handlers are held to a strict, simple contract: unconditional field assignment, nothing else. No validation, no exceptions, no business logic — just "given this event happened, here is what the fields should now be." All genuine decision-making belongs strictly in the command handler, before any event exists; the event sourcing handler's only job is to make the fields match a decision that's already been made.
An @EventSourcingHandler method is typically a small, void-returning method named on, overloaded once per event type the aggregate needs to react to. Its body does exactly one thing: unconditionally set fields from the event's data.
For CustomerCreatedEvent, that means copying the event's customerId, name, and email straight onto the aggregate's own fields. Nothing in this method checks whether those values are valid — that check already happened, back in the @CommandHandler constructor that decided to apply this event in the first place. By the time this method runs, there is nothing left to decide.
This same method is what runs in two very different situations, and it's worth being clear about both. First, it runs immediately after AggregateLifecycle.apply() is called inside a command handler, updating the live, in-memory instance right away. Second — and this is the part covered in the next section — it also runs during a full replay, when Axon rebuilds a fresh aggregate instance from its complete event history before handling a brand-new command. The method's code is identical in both cases; it simply reacts to "this event happened" and updates fields accordingly, regardless of why it's being invoked right now.
💻 Code example
@Aggregate public class CustomerAggregate { @AggregateIdentifier private String customerId; private String name; private String email; // ... no-args constructor and @CommandHandler constructor from earlier ... @EventSourcingHandler public void on(CustomerCreatedEvent event) { // this method NEVER validates or rejects anything — the decision already happened this.customerId = event.getCustomerId(); this.name = event.getName(); this.email = event.getEmail(); } }
The same command-handler-plus-event-sourcing-handler pair repeats for every operation an aggregate supports, not just creation. An update follows the identical pattern: validate in the command handler, apply an event if valid, then unconditionally reflect that event's fields in the matching event sourcing handler.
Delete is a small but useful variation. Rather than nulling out fields by hand, Axon provides AggregateLifecycle.markDeleted(), called from inside the event sourcing handler reacting to the deletion event. This tells Axon the aggregate instance is now finished — any command arriving afterward for the same identifier is treated as targeting a deleted aggregate, rather than silently succeeding against a half-cleared instance.
Notice the pattern holding steady across all three operations: every command handler's only real job is deciding whether something is allowed and, if so, applying the matching event; every event sourcing handler's only job is unconditionally reflecting that already-decided change onto the instance's own fields. Once this pattern is familiar, adding a new operation to an aggregate is almost mechanical — write the command, write the event, write the pair of handlers, and the shape barely changes from one operation to the next.
💻 Code example
@CommandHandler public void handle(UpdateCustomerCommand command) { if (command.getName() == null || command.getName().isBlank()) { throw new IllegalArgumentException("name cannot be empty"); } AggregateLifecycle.apply(new CustomerUpdatedEvent(customerId, command.getName(), command.getEmail())); } @EventSourcingHandler public void on(CustomerUpdatedEvent event) { this.name = event.getName(); this.email = event.getEmail(); } @CommandHandler public void handle(DeleteCustomerCommand command) { AggregateLifecycle.apply(new CustomerDeletedEvent(customerId)); } @EventSourcingHandler public void on(CustomerDeletedEvent event) { AggregateLifecycle.markDeleted(); // tells Axon this aggregate instance is now finished }
◆ Under the hood
When a new command arrives for an existing customer, there is no "row" being fetched from any table. Axon finds every event ever stored for that specific customerId, creates a brand-new, empty CustomerAggregate instance using its no-argument constructor, and replays every one of those events through the @EventSourcingHandler methods above, in the exact order they originally happened — rebuilding the instance's fields from nothing, every single time, before the new command handler even runs. There is no persisted "current row" anywhere on the write side; the current state only exists as the result of that replay.
The most common mistake at this stage is putting a business-rule check inside an @EventSourcingHandler method — for instance, throwing if a field looks wrong. Since this method also runs during a full replay of history, throwing here doesn't just reject one bad request; it can make the aggregate unable to load at all, because replay would hit the same exception on every future load too. Any check that could reasonably fail belongs strictly in the command handler, never here.
A second common worry, and a completely reasonable one, is that replaying potentially thousands of events on every single command sounds wildly inefficient. It's worth knowing Axon does cache aggregates in memory to avoid replaying on every command in rapid succession — but understanding the "replay from scratch" model as the underlying truth is essential groundwork, since it's what any further optimization is built on top of, not a replacement for it.
▲ Common mistake
Assuming an aggregate's fields hold their values "in the database" the same way a normal entity would is a mental model that causes real confusion later. There is no separate current-state table for the write side at all in a purely event-sourced aggregate — only the event store, and the replay process that derives state from it fresh each time.
This replay-based loading model is exactly what gives event-sourced systems their strongest practical benefit: a complete, trustworthy audit trail, for free, as a side effect of how state is computed rather than as a bolted-on logging feature. Because current state is always derived by replaying history, that history necessarily exists, in full, for as long as the event store retains it.
In a bank-style system, this matters concretely. If a regulator or an internal audit needs to know exactly how a loan's status reached its current value — every approval, every adjustment, every correction — that answer isn't reconstructed from scattered log lines and best-effort timestamps; it's the aggregate's actual event history, in order, which is the same history Axon replays to compute the aggregate's live state today.
It also enables a capability that's hard to retrofit onto a traditional CRUD system after the fact: replaying an aggregate's history up to some earlier point in time to see exactly what its state was then, which is genuinely useful for debugging a production issue ("what did this account look like right before the disputed withdrawal?") without needing separate historical snapshots maintained by hand.
-
Q: Should an @EventSourcingHandler method ever reject or fail? A: No — it's applying something that has already, definitively happened. All decision-making belongs in the command handler instead, before any event is applied.
-
Q: How does Axon get an aggregate's current state when a new command arrives? A: It fetches every event ever stored for that aggregate's identifier and replays them all, in order, through the event sourcing handlers, rebuilding a fresh instance from nothing before the new command handler runs.
-
Q: What does AggregateLifecycle.markDeleted() do, and where is it called from? A: It tells Axon this aggregate instance is now finished. It's called from inside the event sourcing handler that reacts to a deletion event, not from the command handler.
-
Q: Why is replaying an aggregate's full event history on every command not as inefficient as it sounds? A: Axon caches aggregates in memory to avoid replaying on every command in rapid succession, though understanding the underlying "replay from scratch" model first is essential before that caching makes sense as an optimization rather than a contradiction.
-
Q: What's the risk of putting a validation check inside an @EventSourcingHandler method? A: Since that method also runs during a full replay of an aggregate's history, a thrown exception there doesn't just reject one request — it can make the aggregate unable to load at all in the future, since replay would hit the same exception every time.
Want a visual for this concept?
Generate a diagram tailored to “Event Sourcing Handlers & Rebuilding State” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →