Skip to main content
Version: 0.15

Service with aggregate

Concept

note

This page describes command services that work with aggregates. If you are not using aggregates, check the Functional services page.

The command service itself performs the following operations when handling one command:

  1. Extract the aggregate id from the command, if necessary.
  2. Instantiate all the necessary value objects. This could effectively reject the command if value objects cannot be constructed. The command service could also load some other aggregates, or any other information, which is needed to execute the command but won't change state.
  3. If the command expects to operate on an existing aggregate instance, this instance gets loaded from the event store.
  4. Execute an operation on the loaded (or new) aggregate, using values from the command, and the constructed value objects.
  5. The aggregate either performs the operation and changes its state by producing new events, or rejects the operation.
  6. If the operation was successful, the service persists new events to the store. Otherwise, it returns a failure to the edge.
Handling failures

The last point above translates to: the command service does not throw exceptions. It returns an instance of Result<TState>.Error instead. It is your responsibility to handle the error.

Implementation

Eventuous provides a base class for you to build command services. It is a generic abstract class, which is typed to the aggregate type. You should create your own implementation of a command service for each aggregate type. As command execution is transactional, it can only operate on a single aggregate instance, and, logically, only one aggregate type.

note

Add Eventuous.Application NuGet package to your project.

The base class for aggregate-based command services is CommandService<TAggregate, TState, TId>.

Handling commands

The base class has one function that must be used in the service class constructor to define how the service will handle commands. The function is called On<TCommand> where TCommand is the command type. You can add as many command handlers as you need. The On function composes a command handler builder that allows to chain further details to describe how the command needs to be processed.

After calling On, add two more calls to the builder:

  • InState(ExpectedState) to specify what is the expected aggregate state is. For example, if the BookRoom command expects that no booking exists with a given identity, you'd specify InState(ExpectedState.New). There are three possible states: New, Existing, and Any.
  • GetId(Func<TCommand, TId>) to explain the service how to use one or more properties of TCommand type to compose an identity object for loading and storing the aggregate state.

After that, use one of the Act functions to specify the business logic of the command handler. There are two available functions for it: Act and ActAsync.

Here is an example of a command service form our test project:

BookingService.cs
public class BookingsCommandService 
: CommandService<Booking, BookingState, BookingId> {
public BookingsCommandService(
IEventStore store,
Services.IsRoomAvailable isRoomAvailable
) : base(store) {
On<BookRoom>()
.InState(ExpectedState.New)
.GetId(cmd => new BookingId(cmd.BookingId))
.ActAsync(
(booking, cmd, _) => {
var period = new StayPeriod(
LocalDate.FromDateTime(cmd.CheckInDate),
LocalDate.FromDateTime(cmd.CheckOutDate)
),
booking.BookRoom(
cmd.GuestId,
new RoomId(cmd.RoomId),
stayPeriod,
new Money(cmd.BookingPrice, cmd.Currency),
new Money(cmd.PrepaidAmount, cmd.Currency),
DateTimeOffset.Now,
isRoomAvailable
);
}
);

On<RecordPayment>()
.InState(ExpectedState.Existing)
.GetId(cmd => new BookingId(cmd.BookingId))
.Act((booking, cmd) =>
booking.RecordPayment(
new Money(cmd.PaidAmount, cmd.Currency),
cmd.PaymentId,
cmd.PaidBy,
DateTimeOffset.Now
));
}
}
Stream name

Check the stream name documentation if you need to use custom stream names.

Result

The command service will return an instance of Result<TState>. It can be inspected using the following members:

SignatureDescription
bool TryGet(out Result<TState>.Ok ok)Returns true if the result is successful and also returns Result<TState>.Ok as the out variable.
bool TryGetError(out Result<TState>.Error error)Returns true if there was an error. The error then gets assigned to an instance of Error that contains more details about what went wrong.
bool SuccessReturns true if the result is successful.
Exception? Exception { get; }Returns an exception instance if there was an error, or null if there was no exception.
void ThrowIfError()Throws the recorded exception if there was an error, does nothing otherwise.
T Match<T>(Func<Ok, T> matchOk, Func<Error, T> matchError)Can be used for pattern matching success and error if the output has the same type. Eventuous uses this function for producing IResult and ActionResult in HTTP API extensions.
void Match<T>(Action<Ok> matchOk, Action<Error> matchError)Allows to execute code branches based on the result success.
Task MatchAsync<T>(Func<Ok, Task> matchOk, Func<Error, Task> matchError)Allows to execute async code branches based on the result success.

When using TryGet, you get the Ok instance back, which contains the following properties:

PropertyDescription
TState StateNew state instance
Change[] ChangesList of new events. Change struct contains both the event payload and its type.
ulong StreamPositionPosition of the last event in the stream that can be used for tracking, for example, read model updates.

The Match function also provides Ok for the matchOk function to use.

When using TryGetError, you get the Error instance back, which contains the following properties:

PropertyDescription
string ErrorMessageThe error message, which can be custom or taken from the exception.
Exception? ExceptionException details if available.

Bootstrap

If you registered an implementation of IEventStore in the DI container, you can also register the command service:

Program.cs
builder.Services.AddCommandService<BookingCommandService, BookingState>();

The AddCommandService extension will register the BookingService, and also as ICommandService<BookingState>, as a singleton. Remember that all the DI extensions are part of the Eventuous.Extensions.DependencyInjection NuGet package.

When you also use AddControllers, you get the command service injected to your controllers.

You can simplify your application and avoid creating HTTP endpoints explicitly (as controllers or minimal API endpoints) if you use the command API feature.

Application HTTP API

The most common use case is to connect the command service to an HTTP API using controllers or minimal API mappings.

Read the Command API feature documentation for more details.