Operation, Result and Error
An Operation is one command or query running inside one Module; it ends in a Result, and a business failure is an Error in it.
What it is
An Operation is one command or query running inside one Module, from entry to outcome. A command changes state and a query only reads. Each is a sealed record with exactly one handler, and every handler returns a Result: a value on success, one or more Errors on failure.
An Error is a named business reason the Operation did not succeed. It has a stable code, a message and a kind, and it carries no HTTP status: the Web Capability turns its kind into one when the Result becomes a response. Result and Error lists the kinds, and Problem responses the status each answers.
A business failure is an Error, never an exception
Return an Error when the Operation cannot succeed for a reason the business knows, such as a missing product or a name already in use. Exceptions are for bugs and infrastructure. A test of a business or validation failure asserts on the Result's Errors, never on an exception.
When it matters
Every Feature has one. A command that changes something, such as renaming a product, is a Command or a Command<TResponse>. A read, such as one product or a page of them, is a Query<TResponse>. Each passes the same fixed steps, so a Feature supplies only its record, its handler and, when it needs them, a validator and an authorizer.
How it works
A Business Area declares its Errors once, as static fields:
public static readonly Error NameRequired = Error.Validation(
"Product.NameRequired",
"A product needs a name.",
"Name"
);
public static readonly Error NotFound = Error.NotFound(
"Product.NotFound",
"The product does not exist."
);
// Mapped to the unique index on the name in CatalogModule: the database, not a check beforehand, refuses a duplicate.
public static readonly Error NameInUse = Error.Conflict(
"Product.NameInUse",
"Another product already has this name."
);A handler returns either a value or an Error, and each converts to a Result. This one, from the Proving Sample's RenameProduct Feature, answers the Business Area's not-found Error when there is no such product, and otherwise whatever the aggregate's rule answers:
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);
}
}Every command and query passes the same fixed pipeline before and around its handler; The pipeline lists its steps. A command's handler runs inside the Unit of Work, which commits once for the whole Operation: see Unit of Work. A failure at any step, a nested command's included, fails the Operation with its Errors.
- Pass a failed Result's Errors on rather than ignoring them: every Error is kept.
- Check
IsSuccessorIsFailurebefore you readValue. - Check a business rule that depends on state in the aggregate, with
CheckRule, and input shape in a validator.
What the Kit guarantees
| Guarantee | Stated in |
|---|---|
| How a value or an Error becomes a Result | Result and Error |
| One handler per command or query | Commands and queries |
| The fixed pipeline, and what a failed nested command does | The pipeline |
| What a query, an authorizer or a validator may change | Code that only reads |
| Which HTTP status each kind of Error answers | Problem responses |
Failure messages
Next steps
Host and Module
A Host is the application you deploy; a Module is a business boundary inside it, reached only through its Gateway.
Operation Context, Actor and tenant
Every Operation carries who caused it, the tenant it runs for and its chain of work, passed explicitly and never read from an ambient request.