Documentation
Welcome to docs|Writing data|About Aggregates

About Aggregates

This page describes the fundamentals of Aggregates in Serialized.

Aggregates are used to represent a single entity or process in your application that is constructed from a number of events. Each aggregate has a unique type, unique identifier and a version number that is incremented each time a change is made to the aggregate.

This page describes the fundamentals of Aggregates in Serialized and their basic properties.

When to use aggregates?

The purpose of aggregates is to provide consistency and logical tracking of your writes. When you want to read or present this data, you should not use aggregates directly. Instead, you should use Projections or Feeds which are designed for this purpose. All events that you write to your aggregates can be made available via a projection or feed. In fact, our SDKs does not have an exposed API for reading aggregates directly, although it is done under the hood during updates.

If you want to trigger a workflow as a consequence of an event, you should use Reactions which are designed for this purpose.

Aggregate version

The aggregate version is a number that is automatically incremented each time a change is made to the aggregate. The version number is used to track the number of changes to the aggregate and to ensure that the aggregate is not modified concurrently by multiple processes when the aggregate is updated (see handling concurrency).

Serialized will automatically increase the version number when an aggregate is updated. The version number is also returned from the API when the aggregate is loaded and provided as input to the expectedVersion parameter to handle concurrency during writes.

Event batches

Each change to an aggregate is represented by a batch of events. An event batch is a list of events that are saved together as one atomic operation.

// Create a new event batchvar eventBatch = List.of(    newEvent(new OrderPaid()).build(),    newEvent(new DeliveryStarted()).build());// Store the event batchvar aggregateId = UUID.randomUUID();var request = saveRequest().withAggregateId(aggregateId).withEvents(eventBatch).build()orderClient.save(request); // see the SDK documentation for how to create the client
// Create a new event batchconst eventBatch = [    new OrderPaid(),     new DeliveryStarted()]// Store the event batchconst aggregateId = uuidv4();const request = {aggregateId, events: eventBatch}orderClient.save(request); // see the SDK documentation for how to create the client

When a batch of events is saved to Serialized the aggregate version is incremented by one.

Aggregate types

Aggregates types are used to group aggregates of the same type together. For example, you might have an Order aggregate type representing all orders in your system. Aggregate types are used to provide a namespace for your aggregates.

It is advised to think of aggregate types as processes rather than entities, and you should try to name your aggregate types accordingly. For example, you might have an OrderPlacement aggregate type that represents the process of a customer placing an order, instead of an Order aggregate type that represents the complete lifecycle of an order.

In our SDKs, aggregate types are represented as a String and are provided as input to the creation of the aggregate client. In the API, aggregate types are represented as a String in the URL path.

var client = aggregateClient("order-registration", OrderRegistrationState.class, config).build();
@Aggregate('order-registration', OrderRegistrationStateBuilder)class OrderRegistration {    ...}const client = serializedInstance.aggregateClient(OrderRegistration);

Identity

Each aggregate is uniquely identified by a single identifier. This identifier is a UUID string, such as af70d14e-f3d9-4dd5-8ad6-589c528aac82 that is unique within the aggregate type.

Serialized supports having multiple aggregates with the same identifier in different aggregate types. For example, you might reuse the same identifier for an OrderPlacement and OrderShipment aggregate. Since they are in different aggregate types, they are considered to be different aggregates.

var orderRegistrationsClient = aggregateClient("order-registration", OrderRegistrationState.class, config).build();var orderPaymentClient = aggregateClient("order-payment", OrderPaymentState.class, config).build();orderRegistrationsClient.save(saveRequest()    .withAggregateId(orderId)    .withEvent(new OrderCreated())    .withExpectedVersion(0).build());// OK, since the aggregate is of a different aggregate type, 'order-payment'orderPaymentClient.save(saveRequest()    .withAggregateId(orderId)    .withEvent(new OrderPaid())    .withExpectedVersion(0).build());// Will throw ConflictException since the aggregate already existsorderRegistrationsClient.save(saveRequest()    .withAggregateId(orderId)    .withEvent(new OrderCreated())    .withExpectedVersion(0).build());
const orderRegistrationsClient = Serialized.create(config).aggregateClient(OrderRegistration);const orderPaymentClient = Serialized.create(config).aggregateClient(OrderPayment);const orderId = uuidv4();await orderRegistrationsClient.save({    aggregateId: orderId,     events: [new OrderCreated()],     expectedVersion: 0});// OK, since the aggregate is of a different aggregate type, 'order-payment'await orderPaymentClient.save({    aggregateId: orderId,     events: [new OrderPaid()],     expectedVersion: 0});// Will throw ConflictException since the aggregate already existsawait orderRegistrationsClient.save({    aggregateId: orderId,     events: [new OrderCreated()],     expectedVersion: 0});

Even though each aggregate has a unique identifier, it is advised to also add the identifier to the data of each event, to make it explicit and easier to use in projection and reactions. From the example above, it would be advised to also add an orderId as part of the event data.

Pagination

In the case you have aggregates with a large number of events, you will need to paginate through the events when loading the aggregate to get the complete history. Serialized provides a hasMore field in the response that indicates if there are more events to load from the aggregate. The default/maximum page size is 1000.

To continue to load more events, you should use the since and limit query parameters in the API.

If you use our SDKs, the pagination is handled automatically for you and the SDK will always load the complete history of the aggregate.

Design considerations

You should look to your application needs and requirements to decide how to define, design and structure your aggregates. For example, you might have a single Order aggregate that contains all the data for a single order, or you might have a separate aggregate for each partial process, such as OrderPlacement, OrderPayment, and OrderShipment.

For application with high concurrency requirements, it is advisable to design your aggregates as small as possible, with fewer reasons to change. Since write operations in Serialized are based around aggregates, the larger the aggregate the more likely it is to result in conflict when the aggregate is updated if the contention is high.

Further reading