Modeling Aggregates

Modeling aggregates with entities, command handlers and domain events.

Aggregates are central to DDD and ZenWave SDK modeling. They represent clusters of entities and values that works as transactional boundary and are treated as a single unit:

  1. Treated as a Single Unit.

  2. Root Entity: Each aggregate has a root entity, also known as the aggregate root. All external references to the aggregate should only point to the aggregate root. This ensures the integrity of the aggregate.

  3. Consistency Boundaries: The aggregate defines the transactional units of consistency boundaries.

  4. Reference by Identity: Aggregates should be referenced by their identifiers (IDs) rather than direct object references. This promotes loose coupling between aggregates and enhances the system's scalability and maintainability.

  5. Invariants: The aggregate root is responsible for maintaining all invariants within the aggregate. An invariant is a business rule that must always be consistent.

  6. Persistence: Persistence of an aggregate should be done as a whole. It's the responsibility of the aggregate root to handle this.

  7. Size: Ideally, an aggregate should be as small as possible, based on the business rules. A smaller aggregate is easier to maintain and understand.

ZenWave SDK supports these three different patterns to model aggregates:

Lightweight Aggregates

💡 If your application logic is simple, keep your implementation simple.

Lightweight Aggregates are simple data containers that do not contain any business logic. They are used to group related data together. They are treated as a single unit and have a root entity but related business logic is implemented in services or other classes.

Most microservice applications look like:

"Receive some command/request, validate, process, persist the data and emit an integration event for downstream services."

If your application logic is simple, keep your implementation simple.

This would be our first, go-to recommended approach. For most microservice applications this pattern is good enough to guarantee maintainability and scalability. And it, does not overcomplicate your codebase with unnecessary complexity. If you are in doubt, start with this pattern.

Sometimes you will find this referred as the Anemic Domain Model but it's OK for most microservice applications.

It applies Keep-It-Simple (KISS) principle and Separation of Concerns, clearly separating where data is defined (your entities) and the business process you apply to it (your services). Services are still the only entry point for your aggregates functionality, and it works just fine for most microservice applications.

Use the @aggregate annotation to mark your aggregate root entity, and use service for defining command/methods:

@aggregate
entity Customer {
// fields, nested objects and relationships...
}
service CustomerService for (Customer) {
/* the following service method names and signatures will genereate CRUD operations */
createCustomer(Customer) Customer withEvents CustomerEvent
updateCustomer(id, Customer) Customer? withEvents CustomerEvent
deleteCustomer(id) withEvents CustomerEvent
getCustomer(id) Customer?
@paginated
listCustomers() Customer[]
/* this is will generate (almost) empty implementation placeholders for service and tests */
updateCustomerAddress(id, Address) Customer? withEvents CustomerEvent
}

Rich Domain Aggregates

💡 Start with the Lightweight Aggregates pattern and refactor to Rich Domain Aggregates when you need it.

Sometimes referred to as Rich Domain Model or Rich Domain Objects. These are objects that contain both data and behavior. They are not just data containers but also contain business logic.

We recommend following this approach when:

  • Business logic is complex and the aggregate needs to maintain its own consistency and invariants.
  • Your service methods spans multiple aggregates.
  • You are modeling aggregates that need to encapsulate state transitions.
  • You are working on a core subdomain.

With this approach, your service methods will look simpler, in expense of more complex, and bigger code base for your aggregates. And a stepper learning curve for your developing team.

If you don't know if you need it, you probably don't.

Modeling Rich Domain Aggregates with ZenWave ZDL

This is how you would model this with ZenWave SDK:

/**
* Delivery Aggregate
*/
aggregate DeliveryAggregate (Delivery) {
createDelivery(DeliveryInput) withEvents DeliveryStatusUpdated
onOrderStatusUpdated(OrderStatusUpdated) withEvents DeliveryStatusUpdated
updateDeliveryStatus(DeliveryStatusInput) withEvents DeliveryStatusUpdated
}
// @aggregate
entity Delivery {
// fields, nested objects and relationships...
}
@rest("/delivery")
service DeliveryService for (DeliveryAggregate) {
@asyncapi({api: OrdersAsyncAPI, channel: "OrderCreatedChannel"})
createDelivery(DeliveryInput) Delivery withEvents DeliveryStatusUpdated
@asyncapi({api: OrdersAsyncAPI, channel: "OrderUpdatesChannel"})
onOrderStatusUpdated(id, OrderStatusUpdated) Delivery withEvents DeliveryStatusUpdated
@put("/{orderId}/status")
updateDeliveryStatus(id, DeliveryStatusInput) Delivery withEvents DeliveryStatusUpdated
@get @paginated
listDeliveries() Delivery[]
}

Rich Domain Aggregates in Java

This is how a service for a rich aggregate looks like:

public class DeliveryServiceImpl implements DeliveryService {
@Transactional
public Delivery onOrderStatusUpdated(String id, OrderStatusUpdated input) {
var deliveryAggregate = deliveryRepository.findDeliveryAggregateById(id).orElseThrow();
deliveryAggregate.onOrderStatusUpdated(input); // the aggregate business logic
persistAggregate(deliveryAggregate);
emitEvents(deliveryAggregate.getEvents());
return deliveryAggregate.getRootEntity();
}
}

And the aggregate itself:

/**
* Rich Domain Aggregate for Delivery.
*/
public class DeliveryAggregate {
private final Delivery rootEntity;
private final List<Object> events = new ArrayList<>();
// getters, setters, constructor, etc.
/**
* Performs business logic related to 'onOrderStatusUpdated' and emits 'DeliveryStatusUpdatedEvent' domain event.
*/
public void onOrderStatusUpdated(OrderStatusUpdated input) {
// TODO: your business logic would be here
mapper.update(rootEntity, input);
events.add(mapper.asDeliveryStatusUpdated(rootEntity));
}
@org.mapstruct.Mapper
interface Mapper {
Delivery update(@MappingTarget Delivery entity, OrderStatusUpdated input);
DeliveryStatusUpdatedEvent asDeliveryStatusUpdated(Delivery entity);
}
}
/**
* Delivery JPA Entity, used as rootEntity.
*/
@Entity(name = "delivery")
@lombok.Getter
@lombok.Setter(AccessLevel.PACKAGE) // disallow direct access to setters from service layer
public class Delivery implements Serializable {
// ...
}
/**
* Delivery Updated Domain Event.
*/
public class DeliveryStatusUpdatedEvent implements Serializable {
// ...
}

Event Sourced Aggregates

💡 This is and extension of the Rich Domain Aggregates, where aggregate commands are event handlers that reconstruct the aggregate state.

With ZenWave SDK you can model aggregates that are event sourced, whose state is created as a sequence of events. This is common when implementing the CQRS pattern.

This section is not about how to store the state of an aggregate as a sequence of events, but how aggregates state is rebuilt from incoming events.

This approach is just an extension of the Rich Domain Aggregates pattern, where aggregate commands are event handlers that reconstruct the aggregate state.

/**
* Delivery Aggregate
*/
aggregate DeliveryAggregate (Delivery) {
onOrderCreatedEvent(OrderCreatedEvent) withEvents DeliveryStatusUpdated
onOrderStatusUpdated(OrderStatusUpdatedEvent) withEvents DeliveryStatusUpdated
onOrderCancelled(OrderCancelledEvent) withEvents DeliveryStatusUpdated
}
service DeliveryService for (DeliveryAggregate) {
@asyncapi({api: OrdersAsyncAPI, channel: "OrderCreatedChannel"})
onOrderCreatedEvent(OrderCreatedEvent) withEvents DeliveryStatusUpdated
@asyncapi({api: OrdersAsyncAPI, channel: "OrderUpdatedChannel"})
onOrderStatusUpdated(OrderStatusUpdatedEvent) withEvents DeliveryStatusUpdated
@asyncapi({api: OrdersAsyncAPI, channel: "OrderDeletedChannel"})
onOrderCancelled(OrderCancelledEvent) withEvents DeliveryStatusUpdated
}