intermediate~2h

Building the Aggregate — the Write Model

Build the single most important class in an event-sourced system: the aggregate, which is the one place business rules get enforced and the only thing allowed to decide that a command is valid.

Learning objectives

  • Explain what an aggregate is and what makes it the one authority for a piece of business state
  • Mark an aggregate's identifying field with @AggregateIdentifier
  • Write a @CommandHandler that validates a command and applies an event
  • Explain what AggregateLifecycle.apply() actually does
  • Avoid the mistake of mutating aggregate fields directly inside a command handler

◆ Story

Imagine a bank with four departments — Customer Service, Accounts, Cards, Loans — and, inside Customer Service, exactly one clerk whose entire job is deciding whether a request is actually allowed. "You can't set an empty name." "You can't create two customers with the same email." Every single request touching a customer record, no matter who submits it or from which department, has to pass through this one clerk first. Nobody else in the building is permitted to directly change a customer record without this clerk's sign-off.

That clerk is the aggregate. It's the one place in the system responsible for deciding whether a command is valid, and it's the only thing allowed to say "yes, this can happen" by producing an event. Every business rule your system ever enforces around a customer, an account, a loan, or a card lives inside the matching aggregate — nowhere else gets to make that call.

This concentration of authority is deliberate, and it's what makes the rest of an event-sourced system trustworthy. If validation logic were scattered across controllers, service classes, and database triggers, you'd have no single place to look to answer "under what conditions can this actually happen?" With the aggregate pattern, that answer is always in exactly one place.

Two questions need answering before an aggregate class is useful: how does Axon know a given class is an aggregate at all, and how does it know which specific instance a given command belongs to, when a system might have millions of customers?

The first question is answered by a single annotation, @Aggregate, placed on the class. It tells Axon "this class handles commands and rebuilds its own state from events" — nothing more is required for Axon to start treating it that way and wiring it into the command-routing machinery.

The second question is where an aggregate's identifying field comes in. Every real-world entity this aggregate represents — one customer, in this example — needs exactly one aggregate instance responsible for it, and Axon needs a reliable way to find or create that specific instance when a command arrives. That's the job of @AggregateIdentifier.

An empty aggregate shell with just these two pieces in place doesn't yet do anything useful — it has no way to actually get created or changed. The rest of this topic builds that capability: a constructor that handles a creation command, validates it, and — if valid — applies the event that represents the decision having been made.

The starting shape of an aggregate is small: a class annotated @Aggregate, a field annotated @AggregateIdentifier that uniquely identifies this instance, the rest of the fields that make up this aggregate's current state, and a no-argument constructor that Axon requires internally.

@AggregateIdentifier marks the field that uniquely identifies this specific instance — one CustomerAggregate per real customer, identified by customerId. This is exactly what gives meaning to @TargetAggregateIdentifier on a command: when a command arrives carrying a specific customerId, Axon uses that value to find, or in a creation command's case start, the exact matching aggregate instance the command is meant for. Without this pairing, Axon would have no reliable way to route a command to the right instance among potentially millions of customers.

The no-argument constructor is a small but easy-to-forget requirement — Axon needs a way to instantiate a completely empty aggregate before replaying any events into it, and it does that through this constructor. Everything meaningful about the instance's actual state gets filled in afterward, by replaying events, not by this constructor doing any real work itself.

💻 Code example

@Aggregate public class CustomerAggregate { @AggregateIdentifier private String customerId; private String name; private String email; public CustomerAggregate() {} // Axon requires a no-args constructor }

The real work happens in a @CommandHandler method. For a creation command, that method is conventionally a constructor — the aggregate doesn't exist yet, so handling CreateCustomerCommand successfully is what brings it into existence. Inside, the handler does exactly two things, in order: check whether the command should be allowed, and if it should, apply the event that represents that decision.

Validation is a plain guard clause — if the email is missing or blank, throw. Throwing here is precisely how a command gets rejected: nothing is applied, no event is produced, and the caller receives the exception. If validation passes, the handler calls AggregateLifecycle.apply() with a new event carrying the same information the command carried.

AggregateLifecycle.apply() does two things at once, and understanding both matters. First, it publishes the event outward, so the rest of the system — the query side, other services listening for it — can react. Second, and easy to miss, it also immediately routes that same event back to this aggregate's own event sourcing handler method, updating this instance's in-memory fields right away. That second part is exactly how the aggregate keeps its own state correct for the very next command that might arrive in the same interaction, without needing to reload anything from storage first.

▲ Common mistake

Directly setting a field — this.name = command.getName() — inside a @CommandHandler method, instead of applying an event and letting an event sourcing handler set it, breaks the entire event sourcing model. That change would exist only in this one in-memory instance and would vanish completely the next time this aggregate gets rebuilt from its event history, because replaying events is the only way state ever gets set. Business-rule checks belong in the command handler; every actual state change belongs in an event sourcing handler, reacting to an event that has already been applied.

💻 Code example

@Aggregate public class CustomerAggregate { @AggregateIdentifier private String customerId; private String name; private String email; public CustomerAggregate() {} @CommandHandler public CustomerAggregate(CreateCustomerCommand command) { if (command.getEmail() == null || command.getEmail().isBlank()) { throw new IllegalArgumentException("email is required"); // REJECTING the command } AggregateLifecycle.apply(new CustomerCreatedEvent( command.getCustomerId(), command.getName(), command.getEmail())); } }

The direct-field-mutation mistake described above is the single most common bug in a first attempt at writing an aggregate, precisely because it looks correct and even compiles and passes a quick manual test — the field really does get set, in memory, right then. It's only when the aggregate is rebuilt from its event history later (which, as later material covers, happens on essentially every command) that the missing event sourcing handler call becomes obvious: the field silently reverts to whatever the actual event history says it should be.

A second common mistake is putting business-rule validation that depends on another aggregate's state inside a single aggregate's command handler. An aggregate can only see its own fields — the ones rebuilt from its own event history — so a rule like "this email must be unique across all customers" cannot be enforced purely inside CustomerAggregate's command handler; that kind of cross-aggregate uniqueness check needs a different mechanism entirely, since one aggregate instance has no visibility into any other instance's state.

A third mistake is forgetting the no-argument constructor Axon requires internally, or accidentally making it private. Without a constructor Axon can actually call, it has no way to instantiate an empty aggregate to replay events into, and you'll typically see a reflection-related error at startup or on first command, rather than a helpful compile-time warning.

▲ Edge case

A command handler that both validates and applies more than one event in sequence is valid — nothing requires exactly one apply() call per command handler. What matters is that every actual state change happens through an applied event, however many events that takes, and never through a direct field assignment sitting alongside those calls.

The full command lifecycle this topic builds toward looks the same in any real event-sourced system, regardless of domain: a command arrives through a command gateway, gets routed to the correct aggregate instance by its identifier, the aggregate validates it and applies zero or more events if it's accepted, and those events get durably stored — after which they both update the aggregate's own in-memory state and get published outward to anything else in the system that's listening, like a read-side projection.

In a bank-style system, this pattern repeats across every domain concept that has real business rules attached to it: a LoanAggregate enforces things like "a loan cannot be approved above a customer's pre-approved limit"; an AccountAggregate enforces "a withdrawal cannot exceed the current balance"; a CardAggregate enforces "a card cannot be activated twice." Each of these is a completely separate aggregate class, each responsible for exactly one kind of entity, each the sole authority for deciding whether a command touching that entity is allowed.

This is also why aggregates tend to stay deliberately small and focused. A CustomerAggregate shouldn't also try to enforce loan-approval rules — that responsibility belongs on a LoanAggregate instead, kept as its own class with its own identifier and its own event history, even though both aggregates might get created or modified as part of the same real-world business workflow, like a customer applying for a loan.

  • Q: Where do business-rule checks belong — the command handler or the event sourcing handler? A: The command handler — it's the only place allowed to reject a command. Event sourcing handlers just apply an already-decided change and never validate anything.

  • Q: What two things does AggregateLifecycle.apply() actually do? A: It publishes the event outward to the rest of the system, and it immediately routes that same event back to this aggregate's own event sourcing handler to update its in-memory state right away.

  • Q: What does @AggregateIdentifier mark, and how does it relate to @TargetAggregateIdentifier on a command? A: @AggregateIdentifier marks the field that uniquely identifies one specific aggregate instance. When a command carrying a matching value in its @TargetAggregateIdentifier field arrives, Axon uses that value to find or create the exact aggregate instance the command is meant for.

  • Q: Why can't a CustomerAggregate enforce a rule like "this email must be unique across all customers"? A: An aggregate only has visibility into its own fields, rebuilt from its own event history — it has no way to see any other aggregate instance's state, so cross-aggregate uniqueness checks need a different mechanism.

  • Q: What's the risk of setting a field directly inside a @CommandHandler method instead of applying an event? A: The change only exists in that one in-memory instance and will silently disappear the next time the aggregate is rebuilt from its event history, since replaying events is the only mechanism that actually sets state.

Want a visual for this concept?

Generate a diagram tailored to “Building the Aggregate — the Write Model” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Event Sourcing Handlers & Rebuilding State← Back to all Event-Driven Microservices chapters