Create a command
Add a Feature that changes state, with its command, validator and handler, and leave the commit to the Kit.
What you build
A Feature that changes state: a command record, an optional validator, and exactly one handler that changes aggregates and returns a Result. This guide follows the Proving Sample's RenameProduct Feature in Catalog's Products Business Area, Products/Features/RenameProduct/.
The handler never saves
No SaveChanges, transaction or Outbox call: the Unit of Work commits what the handler changed once it returns a success, and rolls it all back when it fails.
When to use it
For every change a caller asks a Module to make. A read is a query instead: see Answer a paged query.
Steps
Declare the command
A sealed record, named after its intent with the Command suffix. Command has no value; Command<TResponse> answers one. Keep it internal: callers outside the Module reach it through its Gateway. This one also names the cache entries it makes stale, which only matters with Caching.
internal sealed record RenameProductCommand(Guid ProductId, string Name)
: Command,
IInvalidatesCache
{
public IReadOnlyCollection<string> InvalidatedCacheTags =>
[GetProductQuery.ProductTag(ProductId)];
}Check its input
One validator beside the command, for the shape of its input. A rule that depends on state belongs in the aggregate. See Validate a command or query.
internal sealed class RenameProductCommandValidator : AbstractValidator<RenameProductCommand>
{
public RenameProductCommandValidator()
{
RuleFor(command => command.ProductId).NotEmpty();
RuleFor(command => command.Name).NotEmpty().MaximumLength(200);
}
}Write its one handler
Derive from CommandHandler<TCommand>, or CommandHandler<TCommand, TResponse> for a command with a value. Load the aggregate through its repository, change it through its methods, and return the Result: an Error when the business says no. See Operation, Result and Error.
internal sealed class RenameProductCommandHandler(IRepository<Product, ProductId> products)
: CommandHandler<RenameProductCommand>
{
public override async Task<Result> Handle(
RenameProductCommand command,
CancellationToken cancellationToken
)
{
var product = await products.FindAsync(new ProductId(command.ProductId), cancellationToken);
return product is null ? ProductErrors.NotFound : product.Rename(command.Name);
}
}Register the Module's Features
The Module's Composition Root already calls AddPouryaKitCqrs() and AddPouryaKitValidation(). With no assembly named, each scans the Module's own assembly, so a new Feature needs no registration of its own.
Serve it and test it
Map an endpoint that sends the command through the Module's Gateway, as Add an endpoint shows, and test it as Test a Feature shows: assert on the Result's Errors, never on exceptions.
What the Kit guarantees
| Guarantee | Stated in |
|---|---|
| One handler per command, and what fails the Host when there is not | Commands and queries |
| The fixed pipeline, and nested commands | The pipeline |
| One commit or nothing | What the Kit guarantees |
| How the repository tracks and when it refuses to write | The repository |