Aggregate
From version 0.14.0 using aggregates is optional. You can define your domain logic using functional services instead.
From version 0.15.0, non-generic Aggregate
abstraction is gone, so you have to use Aggregate<TState>
.
Concept
If you are familiar with the concept, scroll down.
Aggregate
is probably the most important tactical pattern in Domain-Driven Design. It is a building block of the domain model. An Aggregate
is a model on its own, a model of a particular business objects, which can be uniquely identified and by that distinguished from any other object of the same kind.
When handling a command, you need to ensure it only changes the state of a single aggregate. An aggregate boundary is a transaction boundary, so the state transition for the aggregate needs to happen entirely or not at all.
TD;LR Eventuous doesn't have entities other than the Aggregate Root. If you are okay with that, scroll down.
Traditionally, DDD defines three concepts, which are related to aggregate:
Entity
- a representation of a business object, which has an identifierAggregate Root
- an entity, which might aggregate other entities and value objectsAggregate
- theAggregate Root
and all the things inside it
The idea of an aggregate, which holds more than one entity, seems to be derived from the technical concerns of persisting the state. You can imagine an aggregate root type called Booking
(for a hotel room), which holds a collection of ExtraService
entities. Each of those entities represent a single extra service ordered by the guest when they made this booking. It could be a room service late at night, a baby cot, anything else that the guest needs to order in advance. Since those extra services might be also cancelled, we need to have a way to uniquely identify each of them inside the Booking
aggregate, so those are entities.
If we decide to persist the Booking
state in a relational database, the natural choice would be to have one table for Booking
and one table for ExtraService
with one-to-many relationship. Still, when loading the Booking
state, we load the whole aggregate, so we have to read from the Booking
table with inner join on the ExtraService
table.
Those entities might also have behaviour, but to reach out to an entity within an aggregate, you go through the aggregate root (Booking
). For example, to cancel the baby cot service, we'd have code like this:
var booking = bookingRepository.Load(bookingId);
booking.CancelExtraService(extraServiceId);
bookingRepository.Save(booking);
In the Booking
code it would expand to:
void CancelExtraService(ExtraServiceId id) {
extraServices.RemoveAll(x => x.Id == id);
RecalculateTotal();
}
So, we have an entity here, but it doesn't really expose any behaviour. Even if it does, you first call the aggregate root logic, which finds the entity, and then routes the call to the entity.
In Eventuous, we consider it as a burden. If you need to find the entity in the aggregate root logic, why can't you also execute the operation logic right away? If you want to keep the entity logic separated, you can always create a module with a pure function, which takes the entity state and returns an event to the aggregate root.
The relational database persistence concern doesn't exist in Event Sourcing world. Therefore, we decided not to implement concepts like Entity
and AggregateRoot
. Instead, we provide a single abstraction for the logical and physical transaction boundary, which is the Aggregate
.
Implementation
Eventuous provides three abstract classes for the Aggregate
pattern, which are all event-sourced. The reason to have three and not one is that all of them allow you to implement the pattern differently. You can choose the one you prefer.
Aggregate
The Aggregate
abstract class is quite technical and provides very little out of the box. Unconditionally, since version 0.15 Eventuous only supports Aggregate<TState>
type, where TState
is the aggregate state type. Traditionally, we consider the state as part of the aggregate. However, state is the only part of the aggregate that gets mutated. The pattern used by Eventuous is to separate state from the behaviour by splitting them into two distinct objects.
The aggregate state in Eventuous is immutable. When applying an event to it, we get a new state.
The State
abstraction is described on the State page.
Here are the Aggregate
class members:
Member | Kind | What it's for |
---|---|---|
Original | Read-only collection | Events that were loaded from the aggregate stream |
Changes | Read-only collection | Events, which represent new state changes, get added here |
Current | Read-only collection | The collection of the historical and new events |
ClearChanges | Method | Clears the changes collection |
OriginalVersion | Property, int | Original aggregate version at which it was loaded from the store |
Version | Property, int | Current aggregate version after new events were applied |
AddChange | Method | Adds an event to the list of changes |
Load | Method | Given the list of previously stored events, restores the aggregate state. Normally, it's used for synchronous load, when all the stored events come from event store at once. |
Apply | Method | Given a domain event, applies it to the state. Replaces the current state with the new version. Adds the event to the list of changes. Returns a tuple with the previous and the current state versions. |
State | Property | Returns the current aggregate state. |
Current | Property | Returns the collection of events loaded from the stream. If it's a new aggregate, it returns an empty list. |
It also has two helpful methods, which aren't related to Event Sourcing:
EnsureExists
- throws ifVersion
is-1
EnsureDoesntExist
- throws ifVersion
is not-1
The stateful aggregate class implements most of the abstract members of the original Aggregate
. It exposes an API, which allows you to use the stateful aggregate base class directly.
Here's an example of an aggregate:
public class Booking : Aggregate<BookingState> {
public void BookRoom(string roomId, StayPeriod period, Money price, string? guestId = null) {
EnsureDoesntExist();
Apply(new RoomBooked(roomId, period.CheckIn, period.CheckOut, price.Amount, guestId));
}
public void Import(string roomId, StayPeriod period, Money price) {
EnsureDoesntExist();
Apply(new BookingImported(roomId, price.Amount, period.CheckIn, period.CheckOut));
}
public void RecordPayment(string paymentId, Money amount, DateTimeOffset paidAt) {
EnsureExists();
if (HasPaymentRecord(paymentId)) return;
// The Apply function returns both previous and new state
var (previousState, currentState) =
Apply(new BookingPaymentRegistered(paymentId, amount.Amount));
// Using the previous state can be useful for some scenarios
if (previousState.AmountPaid != currentState.AmountPaid) {
var outstandingAmount = currentState.Price - currentState.AmountPaid;
Apply(new BookingOutstandingAmountChanged(outstandingAmount.Amount));
if (outstandingAmount.Amount < 0)
Apply(new BookingOverpaid(-outstandingAmount.Amount));
}
// The next line only produces an event if the booking was not fully paid before
if (!previousState.IsFullyPaid() && currentState.IsFullyPaid())
Apply(new BookingFullyPaid(paidAt));
}
// This function uses the previously loaded events collection to
// check if the payment was already recorded. You can do the same using the state.
public bool HasPaymentRecord(string paymentId)
=> Current.OfType<BookingPaymentRegistered>().Any(x => x.PaymentId == paymentId);
}
Aggregate identity
Eventuous Aggregate
abstraction doesn't have an identity property. It is, however, possible to use identity-aware state types.
The identity type must inherit from the Id
abstract record, which needs a string value for its constructor:
public record BookingId(string Value) : Id(Value);
The abstract record overrides its ToString
to return the string value as-is. It also has an implicit conversion operator, which allows you to use a string value without explicitly instantiating the identity record. However, we still recommend instantiating the identity explicitly to benefit from type safety.
The aggregate identity type is only used by the command service and for calculating the stream name for loading and saving events. When the command service loads an aggregate with identity, it sets the state identity to a value derived from the stream name. For example, when loading events from a stream Order-123
for an aggregate type declared as Order : Aggregate<OrderState<OrderId>>
, the OrderId
value will be set to 123
.
Aggregate factory
Eventuous needs to instantiate your aggregates when it loads them from the store. New instances are also created by the CommandService
when handling a command that operates on a new aggregate. Normally, aggregate classes don't have dependencies, so it is possible to instantiate one by calling its default constructor. However, you might need to have a dependency or two, like a domain service. We advise providing such dependencies when calling the aggregate function from the command service, as an argument. But it's still possible to instruct Eventuous how to construct aggregates that don't have a default parameterless constructor. That's the purpose of the AggregateFactory
and AggregateFactoryRegistry
.
The AggregateFactory
is a simple function:
public delegate T AggregateFactory<out T, TState>() where T : Aggregate<TState>;
The registry allows you to add custom factory for a particular aggregate type. The registry itself is a singleton, accessible by AggregateFactoryRegistry.Instance
. You can register your custom factory by using the CreateAggregateUsing<T>
method of the registry:
AggregateFactoryRegistry.CreateAggregateUsing<Booking, BookingState>(
() => new Booking(availabilityService)
);
By default, when there's no custom factory registered in the registry for a particular aggregate type, Eventuous will create new aggregate instances by using reflections. It will only work when the aggregate class has a parameterless constructor (it's provided by the Aggregate
base class).
It's not a requirement to use the default factory registry singleton. Both CommandService
and AggregateStore
have an optional parameter that allows you to provide the registry as a dependency. When not provided, the default instance will be used. If you use a custom registry, you can add it to the DI container as singleton.
Dependency injection
Install the Eventuous.Extensions.DependencyInjection
package to use the API described below.
The aggregate factory can inject registered dependencies to newly created aggregate instances when constructing them. For this to work, you need to tell Eventuous that the aggregate needs to be constructed using the container. To do so, use the AddAggregate<T, TState>
service collection extension:
builder.Services.AddAggregate<Booking, BookingState>();
builder.Services.AddAggregate<Payment, PaymentState>(
sp => new Payment(sp.GetRequiredService<PaymentProcessor>, otherService)
);
When that's done, you also need to tell the host to use the registered factories:
app.UseAggregateFactory();