Commands and Queries
Nexum enforces strict CQRS: commands, queries, and streaming queries each have their own contracts, handlers, dispatchers, and pipelines. The compiler prevents you from routing a command through a query dispatcher, and vice versa.
Commands
A command expresses an intent to change state.
// Command returning a value
public record CreateOrderCommand(string CustomerId, List<OrderItemDto> Items) : ICommand<Guid>;
// Void command (no result)
public record DeleteOrderCommand(Guid OrderId) : IVoidCommand;
ICommand— non-generic marker (enables exception handlers to constrain on all commands).ICommand<TResult>— command that returnsTResult.IVoidCommand : ICommand<Unit>— commands without a result.Unitis Nexum'svoidequivalent for generic contexts (areadonly structwith zero size).
Because IVoidCommand inherits from ICommand<Unit>, a single ICommandBehavior<TCommand, TResult> interface covers both variants.
Command handlers
[CommandHandler]
public sealed class CreateOrderHandler : ICommandHandler<CreateOrderCommand, Guid>
{
private readonly IOrderRepository _repo;
public CreateOrderHandler(IOrderRepository repo) => _repo = repo;
public async ValueTask<Guid> HandleAsync(CreateOrderCommand command, CancellationToken ct)
{
var order = Order.Create(command.CustomerId, command.Items);
await _repo.SaveAsync(order, ct);
return order.Id;
}
}
- Handlers return
ValueTask<TResult>— synchronous paths allocate nothing. - The
[CommandHandler]attribute is optional; it is consumed by the Source Generator for compile-time discovery. - For void commands, implement
ICommandHandler<TCommand, Unit>and returnUnit.Value.
Queries
A query expresses an intent to read state without modifying it.
public record GetOrderQuery(Guid OrderId) : IQuery<OrderDto?>;
public record GetOrdersByCustomerQuery(string CustomerId) : IQuery<IReadOnlyList<OrderDto>>;
[QueryHandler]
public sealed class GetOrderQueryHandler : IQueryHandler<GetOrderQuery, OrderDto?>
{
private readonly IOrderRepository _repo;
public GetOrderQueryHandler(IOrderRepository repo) => _repo = repo;
public async ValueTask<OrderDto?> HandleAsync(GetOrderQuery query, CancellationToken ct)
{
var order = await _repo.GetByIdAsync(query.OrderId, ct);
return order?.ToDto();
}
}
IQuery— non-generic marker enabling value-type support in exception handlers.IQuery<TResult>— the normal query interface.
Dispatchers
Nexum provides three dispatchers, registered automatically by AddNexum():
public interface ICommandDispatcher
{
ValueTask<TResult> DispatchAsync<TResult>(ICommand<TResult> command, CancellationToken ct = default);
}
public interface IQueryDispatcher
{
ValueTask<TResult> DispatchAsync<TResult>(IQuery<TResult> query, CancellationToken ct = default);
IAsyncEnumerable<TResult> StreamAsync<TResult>(IStreamQuery<TResult> query, CancellationToken ct = default);
}
public interface INotificationPublisher
{
ValueTask PublishAsync<TNotification>(TNotification notification, CancellationToken ct = default)
where TNotification : INotification;
}
All dispatchers are thread-safe and can be registered as singletons. Handler resolution goes through a ConcurrentDictionary<Type, Lazy<Type?>> cache, so repeated dispatch of the same type is effectively free.
Re-entrant dispatch protection
Nexum guards against unbounded re-entrant dispatch (handler A dispatches command B, which dispatches command A, ...) using an AsyncLocal<int> depth counter. The default maximum depth is 16 and is configurable via NexumOptions.MaxDispatchDepth.
Naming conventions
| Contract | Method | Return type |
|---|---|---|
ICommand<T> |
DispatchAsync |
ValueTask<T> |
IQuery<T> |
DispatchAsync |
ValueTask<T> |
IStreamQuery<T> |
StreamAsync |
IAsyncEnumerable<T> |
INotification |
PublishAsync |
ValueTask |
All handlers implement HandleAsync() — never Handle, never Execute.