Command handlers
A command handler is the application service of one use case that changes the system. It loads an aggregate through a command repository, calls it, saves it, and returns the outcome in a Result. The business rules stay in the aggregate.
export class PlaceOrderHandler extends CommandHandler<PlaceOrder, void, PlaceOrderError> {
async handle({ orderId, total }: PlaceOrder): Promise<Result<void, PlaceOrderError>> {
const order = await this.orders.findById(new OrderId(orderId));
if (order === undefined) {
return err(new OrderNotFound({ orderId }));
}
const placed = order.place(total, this.ids.next(), this.clock.now());
if (!placed.ok) {
return placed;
}
await this.orders.save(order);
return ok();
}
}When to use
Write one command handler per request that changes state: create an order, place it, cancel it. Reads go through a query handler. A handler coordinates; when you catch it deciding a business rule, move that rule into the aggregate or a domain service.
Usage
Declare the command
The command is the input of the handler: a plain type named after the request, in the imperative, in the same file as its handler. It is what a driving adapter has to provide.
export interface PlaceOrder {
readonly orderId: string;
readonly total: number;
}
export type PlaceOrderError = OrderNotFound | InvalidTotal | OrderAlreadyPlaced;Write the handler
The handler receives its dependencies in its constructor, as abstract classes: repositories, ports, the unit of work, the outbox. It imports no framework: no decorator, no container. The composition root builds it, by hand or with the container of your framework, and the abstract classes double as injection tokens: see Integrations.
import { Clock, CommandHandler, err, IdGenerator, ok, type Result } from "@alveolus/core";
import { OrderNotFound } from "../../domain/errors/order-not-found.error";
import { Orders } from "../../domain/repositories/orders.repository";
import { OrderId } from "../../domain/value-objects/order-id.identifier";
export class PlaceOrderHandler extends CommandHandler<PlaceOrder, void, PlaceOrderError> {
constructor(
private readonly orders: Orders,
private readonly clock: Clock,
private readonly ids: IdGenerator,
) {
super();
}
async handle({ orderId, total }: PlaceOrder): Promise<Result<void, PlaceOrderError>> {
const order = await this.orders.findById(new OrderId(orderId));
if (order === undefined) {
return err(new OrderNotFound({ orderId }));
}
const placed = order.place(total, this.ids.next(), this.clock.now());
if (!placed.ok) {
return placed;
}
await this.orders.save(order);
return ok();
}
}Return the failure of the aggregate as is
The error union of the handler includes the errors of the aggregate. A failed Result is returned unchanged: no re-wrapping, no exception.
const placed = order.place(total, this.ids.next(), this.clock.now());
if (!placed.ok) {
throw new Error(placed.error.type);
}const placed = order.place(total, this.ids.next(), this.clock.now());
if (!placed.ok) {
return placed;
}Change state and record events atomically
When the change produces events for other contexts, save the aggregate and add its translated events to the outbox in one unit of work.
async handle({ orderId, total }: PlaceOrder): Promise<Result<void, PlaceOrderError>> {
return this.unitOfWork.run(async () => {
const order = await this.orders.findById(new OrderId(orderId));
if (order === undefined) {
return err(new OrderNotFound({ orderId }));
}
const placed = order.place(total, this.ids.next(), this.clock.now());
if (!placed.ok) {
return placed;
}
await this.orders.save(order);
await this.outbox.add(order.pullDomainEvents().map((event) => this.translator.translate(event, { correlationId: orderId })));
return ok();
});
}Return data
A command may return data, such as the identifier of what it created. Set Output. A command that cannot fail may narrow its return type to Ok, so callers read the value without checking.
export class CreateOrderHandler extends CommandHandler<void, OrderId> {
constructor(
private readonly orders: Orders,
private readonly ids: IdGenerator,
) {
super();
}
async handle(): Promise<Ok<OrderId>> {
const order = Order.create(new OrderId(this.ids.next()));
await this.orders.save(order);
return ok(order.id);
}
}Call it from a driving adapter
A controller builds the command, calls handle and turns the Result into a response. Domain errors become HTTP errors there, and nowhere else.
const placed = await this.placeOrder.handle({ orderId, total: body.total });
if (!placed.ok) {
return { status: 422, body: { error: placed.error.type, details: placed.error.payload } };
}
return { status: 204 };Keep to the command side
A command handler loads aggregates through command repositories. It never receives a query repository: decisions come from aggregates, not from views.
constructor(private readonly summaries: OrderSummaries) {
super();
}constructor(private readonly orders: Orders) {
super();
}Checked by command-query-separation.
Reference
abstract class CommandHandler<Input, Output = void, Error extends AnyDomainError = never> {
abstract handle(command: Input): Promise<Result<Output, Error>>;
}| Type parameter | Description |
|---|---|
Input | The command: the data the handler needs. |
Output | What a success returns. Defaults to void. |
Error | Union of the domain errors the handler may return. Defaults to never. |
| Member | Type | Description |
|---|---|---|
handle(command) | Promise<Result<Output, Error>> | Runs the use case and returns its outcome. |
Caveats
Erroronly acceptsDomainErrorsubclasses. A technical failure, such as a lost database connection, is thrown and handled like any other exception.- Call
super()in the constructor of your handler. - Alveolus provides no bus and no container: wire handlers in the composition root, by hand or with the container of your framework.
- The events recorded by the aggregate stay on it after
save; publish them through the outbox.
Import from @alveolus/core or @alveolus/core/command-handlers.
See also
- Repositories, to load and save aggregates
- Unit of Work and Outbox, to change and record atomically
- Query handlers, for requests that only read
command-query-separation,layer-direction