CQRS aggregates aren’t just data containers; they’re the guardians of your business rules, and their design is where the real magic happens.

Let’s see this in action. Imagine an Order aggregate. When a customer places an order, we create an OrderPlaced event.

{
  "type": "OrderPlaced",
  "orderId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
  "customerId": "customer-123",
  "items": [
    {"productId": "prod-abc", "quantity": 2, "price": 15.50},
    {"productId": "prod-def", "quantity": 1, "price": 30.00}
  ],
  "timestamp": "2023-10-27T10:00:00Z"
}

Later, if the customer wants to change the quantity of an item, we’d issue a ChangeItemQuantity command. The Order aggregate would then handle this command, check if the change is valid (e.g., is the order already shipped?), and if so, emit an ItemQuantityChanged event.

{
  "type": "ItemQuantityChanged",
  "orderId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
  "productId": "prod-abc",
  "newQuantity": 3,
  "timestamp": "2023-10-27T10:15:00Z"
}

This process is about building a robust system where business logic is centralized and enforced. The aggregate is the single point of truth for a given entity (like an Order). Commands are intentions to change the state, and events are immutable records of what has happened. When an aggregate processes a command, it:

  1. Loads its current state: Replays past events to reconstruct its current state.
  2. Validates the command against its state: This is where business invariants are checked. For example, can we change an item quantity if the order is already in a "Shipped" state?
  3. Applies changes and produces new events: If valid, it generates one or more new events that describe the state change.
  4. Persists the new events: These events are then saved, and eventually, the read models are updated.

The core problem CQRS aggregates solve is managing complexity in stateful domain logic. In a traditional CRUD system, business rules can become scattered across many services or layers, leading to inconsistencies and bugs. By encapsulating all logic for an aggregate within its boundaries, you create a clear, auditable, and maintainable system. You control the state transitions precisely. The aggregate’s methods (e.g., changeItemQuantity(productId, newQuantity)) are the only ways to modify its state. They are designed to throw exceptions or reject commands if invariants are violated, preventing invalid state from ever being generated.

The aggregate’s constructor and apply methods are crucial. The constructor typically starts with an empty state, and apply methods are responsible for mutating the aggregate’s internal state based on the event being replayed. This separation ensures that state changes are only driven by past events, maintaining a consistent historical record.

Consider an aggregate that manages a user’s bank account. A common invariant is that the balance cannot go below zero if the account is not an overdraft-enabled account. When a Withdraw command arrives, the aggregate’s withdraw(amount) method would first check if (amount > this.balance && !this.isOverdraftEnabled). If this condition is true, it would throw an InsufficientFundsException. Only if the check passes would it then emit a FundsWithdrawn event and update its internal balance. This ensures that no matter how many Withdraw commands are issued, the fundamental business rule of not allowing negative balances (unless explicitly permitted) is never broken.

A subtle but critical aspect of aggregate design is how you handle complex, multi-step business processes. Often, these are modeled as a single aggregate to maintain transactional consistency. However, if an aggregate’s event stream becomes excessively long (hundreds or thousands of events), the time it takes to load and replay the state can become a performance bottleneck. In such cases, snapshotting is employed. A snapshot is a saved state of the aggregate at a particular event version. When loading, the system first loads the latest snapshot and then only replays events that occurred after that snapshot was taken, significantly reducing the replay time.

The next challenge you’ll encounter is how to handle commands that require coordination across multiple aggregates.

Want structured learning?

Take the full Cqrs course →