Working with aggregates in Java

This page will show you how to use the most fundamental parts of storing events in aggregates to build event-sourced applications using Java.

Aggregate client

To start working with events you should get familiar with the aggregate client. This makes it easier to store events using the features that the Serialized API provides. To use the aggregate client you need to implement two different classes: the state class and the aggregate root class. Both these classes are plain Java classes without any dependencies required.

Aggregate root class

The aggregate root class contains a number of methods that handles your application commands. Each command handler method returns a List of events that are the results of the logic inside the aggregate root. To be able to perform its domain logic, the aggregate root also needs a current state (which will be based on previously stored events).

Below is visualization of the role of the aggregate root class, where a command and the current state is handled and the resulting execution of the command in the aggregate root is a list of domain events.

These events will be saved atomically by the client during save/update. You can choose how to organize the command handler methods, but it's recommended to keep them in a single class for each aggregate type.

Below is an example of an aggregate root class Order that handles orders for an e-commerce system.

public class Order {

  private final OrderStatus status;
  private final String orderId;

  public Order(OrderState state) {
    this.status = state.status();
    this.orderId = state.orderId();
  }

  public List<Event<?> placeOrder(UUID orderId, long amount) {
    if (orderId.toString().equals(this.orderId)) {
      return emptyList();
    } else {
      return singletonList(orderPlaced(orderId.toString(), amount));
    }
  }

}

State class

The state is a mutable class that materializes the current state from the events for a given aggregate type. In this case it will load the order status from all previous order events.

When the aggregate client reads an aggregate from the event store it will call all handler methods in the state class instance matching the event type. Next, add a handler method for the event that our aggregate produces and register this handler method with the aggregate client.

Below is an example of a state class showing the state of an order. It currently only records the status of the order but extending this into a more complete example would basically involve adding more fields and handler methods.

public class OrderState {

  private OrderStatus status;
  private String orderId;

  public OrderState handleOrderPlaced(Event<OrderPlaced> event) {
    // Called when the history of previous events is loaded 
    this.status = OrderStatus.PLACED;
    this.orderId = event.data().orderId;
    return this;
  }

  public OrderStatus status() {
    return status;
  }

  public String orderId() {
    return orderId;
  }
}

Configuring the aggregate client

To use the Order and OrderState classes and to start working with the order aggregate you need to configure the aggregate client.

AggregateClient<OrderState> orderClient = 
  AggregateClient.aggregateClient("order", OrderState.class, serializedConfig)
    .registerHandler(OrderPlaced.class, OrderState::handleOrderPlaced)
    .build();

You can now create the aggregate client with the handler method from the state class registered.

Creating an aggregate

Creating a new aggregate means saving the first events for an aggregate id. There is no explicit creation of aggregates in Serialized, so the first events that are saved for a new aggregate id will create the aggregate implicitly. To create an aggregate you use the save() method in the Aggregate client and provide an aggregate id and the event(s) that should be stored as part of the aggregate creation.

public void createOrder(String orderId) {
  orderClient.save(saveRequest()
                .withAggregateId(request.accountId)
                .withEvents(accountEvents)
                .withExpectedVersion(0L)
                .build());  
}

To create a new aggregate you want to make sure that there are no previous events stored on the aggregate. Use the withExpectedVersion(0) option in the builder of the request to guarantee that this is the first event stored for the aggregate.

You can skip any initialization of a current state since there is no state to load (because the aggregate does not yet have any events stored).

Loading and updating an aggregate

Changes in an event-sourced system means adding events to the event history. To support updating an aggregate you will load (or materialize) the current state from the history of events for that aggregate. To help with this task of loading + materializing a current state + storing more events, the Aggregate client provides an update() method.

int eventCount = orderClient.update(orderId, state -> {
      Order order = new Order(state);
      return order.cancel();
    });

The update() method takes the aggregate id an update function, which is the hook where the aggregate root (in this case Order) is called to execute its domain logic and generate new events if the command is successful. For an illustration of how this works, take a look at the logic for the cancel() method below, which has 3 scenarios.

  • Order is already cancelled and the command will succeed but no new events will be added since the behavior should be idempotent.
  • Order is SHIPPED and the command fails with an exception.
  • Order is in any other status and the command is successful and an OrderCancelled event is stored.

class Order {

    private final UUID orderId;
    private final OrderStatus status;

    public Order(OrderState state) {
      this.orderId = state.getOrderId();
      this.status = state.getStatus();
    }
    
    public List<Event<?>> cancel() {
      if (OrderStatus.CANCELLED.equals(this.status)) {
        return Collections.emptyList();
      } else if (OrderStatus.SHIPPED.equals(this.status)) {
        throw new OrderCancellationFailed("Cannot cancel order since it already is shipped");
      } else {
        return List.of(OrderCancelled.orderCancelled(orderId));
      }
    }

    ...   

}

A complete example

To see a complete example of how to use the aggregate client to build a complete application and to integrate it with different frameworks, checkout our Java code samples on Github.