1 - Application service
Application service and unit of work
👉
The Application Service base class is optional, it just makes your life a bit easier.
Concept
The command service itself performs the following operations when handling one command:
- Extract the aggregate id from the command, if necessary.
- 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.
- If the command expects to operate on an existing aggregate instance, this instance gets loaded from the Aggregate Store.
- Execute an operation on the loaded (or new) aggregate, using values from the command, and the constructed value objects.
- The aggregate either performs the operation and changes its state by producing new events, or rejects the operation.
- If the operation was successful, the service persists new events to the store. Otherwise, it returns a failure to the edge.
⁉️ Command-handling errors
The last point above translates to: the application service
does not throw exceptions. It
returns an instance of
ErrorResult
instead. It is your responsibility to handle the error.
Application service base class
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.
Registering command handlers
We have three methods, which you call in your class constructor to register the command handlers:
Function |
What’s it for |
OnNew |
Registers the handler, which expects no instance aggregate to exist (create, register, initialise, etc). It will get a new aggregate instance. The operation will fail when it will try storing the aggregate state due to version mismatch. |
OnExisting |
Registers the handler, which expect an aggregate instance to exist. You need to provide a function to extract the aggregate id from the command. The handler will get the aggregate instance loaded from the store, and will throw if there’s no aggregate to load. |
OnAny |
Used for handlers, which can operate both on new and existing aggregate instances. The command service will try to load the aggregate, but won’t throw if the load fails, and will pass a new instance instead. |
Here is an example of a command service form our test project:
public class BookingService
: ApplicationService<Booking, BookingState, BookingId> {
public BookingService(IAggregateStore store) : base(store) {
OnNew<Commands.BookRoom>(
(booking, cmd)
=> booking.BookRoom(
new BookingId(cmd.BookingId),
cmd.RoomId,
new StayPeriod(cmd.CheckIn, cmd.CheckOut),
cmd.Price,
cmd.BookedBy,
cmd.BookedAt
)
);
OnAny<Commands.ImportBooking>(
cmd => new BookingId(cmd.BookingId),
(booking, cmd)
=> booking.Import(
new BookingId(cmd.BookingId),
cmd.RoomId,
new StayPeriod(cmd.CheckIn, cmd.CheckOut)
)
);
}
}
You pass the command handler as a function to one of those methods. The function can be inline, like in the example, or it could be a method in the command service class.
In addition, OnAny
and OnExisting
need a function, which extracts the aggregate id from the command, as both of those methods will try loading the aggregate instance from the store.
👉 Stream name
Check the
stream name documentation if you need to use custom stream names.
Async command handlers
If you need to get outside your process boundary when handling a command, you most probably would need to execute an asynchronous call to something like an external HTTP API or a database. For those cases you need to use async overloads:
OnNewAsync
OnExistingAsync
OnAnyAsync
These overloads are identical to sync functions, but the command handler function needs to return Task
, so it can be awaited.
Result
The command service will return an instance of Result
.
It could be an OkResult
, which contains the new aggregate state and the list of new events. You use the data in the result to pass it over to the caller, if needed.
If the operation was not successful, the command service will return an instance of ErrorResult
that contains the error message and the exception details.
Bootstrap
If you registered the EsdbEventStore
and the AggregateStore
in your Startup
as described on the Aggregate store page, you can also register the application service:
services.AddApplicationService<BookingCommandService, Booking>();
The AddApplicationService
extension will register the BookingService
, and also as IApplicationService<Booking>
, as a singleton. Remember that all the DI extensions are part of the Eventuous.AspNetCore
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 application service to an HTTP API.
Read the Command API feature documentation for more details.
2 - Command API
Auto-generated HTTP API for command handling
Controller base
When using an application service from an HTTP controller, you’d usually inject the service as a dependency, and call it’s Handle
method using the request body:
[Route("/booking")]
public class CommandApi : ConteollerBase {
IApiService<Booking> _service;
public CommandApi(IApplicationService<Booking> service) => _service = service;
[HttpPost]
[Route("book")]
public async Task<ActionResult<Result>> BookRoom(
[FromBody] BookRoom cmd,
CancellationToken cancellationToken
) {
var result = await _service.Handle(cmd, cancellationToken);
result Ok(result);
}
}
The issue here is there’s no way to know if the command was successful or not. As the application service won’t throw an exception if the command fails, we can’t return an error via the HTTP response, unless we parse the result and return a meaningful HTTP response.
Eventuous allows you to simplify the command handling in the API controller by providing a CommandHttpApiBase<TAggregate>
abstract class, which implements the ControllerBase
and contains the Handle
method. The class takes IApplicationService<TAggregate>
as a dependency. The Handle
method will call the application service, and also convert the handling result to ActionResult<Result>
. Here are the rules for exception handling:
Result exception |
HTTP response |
OptimisticConcurrencyException |
Conflict |
AggregateNotFoundException |
NotFound |
Any other exception |
BadRequest |
Here is an example of a command API controller:
[Route("/booking")]
public class CommandApi : CommandHttpApiBase<Booking> {
public CommandApi(IApplicationService<Booking> service) : base(service) { }
[HttpPost]
[Route("book")]
public Task<ActionResult<Result>> BookRoom(
[FromBody] BookRoom cmd,
CancellationToken cancellationToken
) => Handle(cmd, cancellationToken);
}
We recommend using the CommandHttpApiBase
class when you want to handle commands using the HTTP API.
Generated command API
Eventuous can use your application service to generate a command API. Such an API will accept JSON models matching the application service command contracts, and pass those commands as-is to the application service. This feature removes the need to create API endpoints manually using controllers or .NET minimal API.
👉
In fact, the auto-HTTP endpoint feature uses the .NET minimal API feature, so it is only available for .NET 6 and higher.
To use generated APIs, you need to add Eventuous.AspNetCore.Web
package.
All the auto-generated API endpoints will use the POST
HTTP method.
Annotating commands
For Eventuous to understand what commands need to be exposed as API endpoints and on what routes, those commands need to be annotated by the HttpCommand
attribute:
[HttpCommand(Route = "payment", Aggregate = typeof(Booking))]
public record ProcessPayment(string BookingId, float PaidAmount);
You can skip the Route
property, in that case Eventuous will use the command class name. For the example above the generated route would be processPayment
. We recommend specifying the route explicitly as you might refactor the command class and give it a different name, and it will break your API if the route is auto-generated.
If your application has a single application service working with a single aggregate type, you don’t need to specify the aggregate type, and then use a different command registration method (described below).
Another way to specify the aggregate type for a group of commands is to annotate the parent class (command container):
[AggregateCommands(typeof(Booking))]
public static class BookingCommands {
[HttpCommand(Route = "payment")]
public record ProcessPayment(string BookingId, float PaidAmount);
}
In such case, Eventuous will treat all the commands defined inside the BookingCommands
static class as commands operating on the Booking
aggregate.
Also, you don’t need to specify the aggregate type in the command annotation if you use the MapAggregateCommands
registration (see below).
Finally, you don’t need to annotate the command at all if you use the explicit command registration with the route parameter.
Registering commands
There are several extensions for IEndpointRouteBuilder
that allow you to register HTTP endpoints for one or more commands.
Single command
The simplest way to register a single command is to make it explicitly in the bootstrap code:
var builder = WebApplication.CreateBuilder();
// Register the app service
builder.Services.AddApplicationService<BookingService, Booking>();
var app = builder.Build();
// Map the command to an API endpoint
app.MapCommand<ProcessPayment, Booking>("payment");
app.Run();
record ProcessPayment(string BookingId, float PaidAmount);
If you annotate the command with the HttpCommand
attribute, and specify the route, you can avoid providing the route when registering the command:
app.MapCommand<BookingCommand, Booking>();
...
[HttpCommand(Route = "payment")]
public record ProcessPayment(string BookingId, float PaidAmount);
Multiple commands for an aggregate
You can also register multiple commands for the same aggregate type, without a need to provide the aggregate type in the command annotation. To do that, use the extension that will create an ApplicationServiceRouteBuilder
, then register commands using that builder:
app
.MapAggregateCommands<Booking>()
.MapCommand<ProcessPayment>()
.MapCommand<ApplyDiscount>("discount");
...
// route specified in the annotation
[HttpCommand(Route = "payment")]
public record ProcessPayment(string BookingId, float PaidAmount);
// No annotation needed
public record ApplyDiscount(string BookingId, float Discount);
Discover commands
There are two extensions that are able to scan your application for annotated commands, and register them automatically.
First, the MapDiscoveredCommand<TAggregate>
, which assumes your application only serves commands for a single aggregate type:
app.MapDiscoveredCommands<Booking>();
...
[HttpCommand(Route = "payment")]
record ProcessPayment(string BookingId, float PaidAmount);
For it to work, all the commands must be annotated and have the route defined in the annotation.
The second extension will discover all the annotated commands, which need to have an association with the aggregate type by using the Aggregate
argument of the attribute, or by using the AggregateCommands
attribute on the container class (described above):
app.MapDiscoveredCommands();
...
[HttpCommand(Route = "bookings/payment", Aggregate = typeof(Booking))]
record ProcessPayment(string BookingId, float PaidAmount);
[AggregateCommands(typeof(Payment))]
class V1.PaymentCommands {
[HttpCommand(Route = "payments/register")]
public record RegisterPayment(string PaymentId, string Provider, float Amount);
[HttpCommand(Route = "payments/refund")]
public record RefundPayment(string PaymentId);
}
Both extensions will scan the current assembly by default, but you can also provide a list of assemblies to scan as an argument:
app.MapDiscoveredCommands(typeof(V1.PaymentCommands).Assembly);
Using HttpContext data
Commands processed by the application service might include properties that aren’t provided by the API client, but are available in the HttpContext
object. For example, you can think about the user that is making the request. The details about the user, and the user claims, are available in HttpContext
.
You can instruct Eventuous to enrich the command before it gets sent to the application service, using the HttpContext
data. In that case, you also might want to hide the command property from being exposed to the client in the OpenAPI spec.
To hide a property from being exposed to the client, use the JsonIgnore
attribute:
[HttpCommand(Route = "book")]
public record BookRoom(string RoomId, string BookingId, [property: JsonIgnore] string UserId);
Then, you can use the HttpContext
data in your command:
app
.MapAggregateCommands<Booking>()
.MapCommand<BookRoom>((cmd, ctx) => cmd with { UserId = ctx.User.Identity.Name });
When the command is mapped to the API endpoint like that, and the property is ignored, the OpenAPI specification won’t include the ignored property, and the application service will get the command populated with the user id from HttpContext
.