About AggregatesThis 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.
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.
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
When a batch of events is saved to Serialized the aggregate version is incremented by one.
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();
Each aggregate is uniquely identified by a single identifier. This identifier is a UUID string, such
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());
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
orderId as part of the event data.
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
To continue to load more events, you should use the
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.
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.