Documentação
Mensagens e handlers
Commands, queries e events: quem pode ter quantos handlers, e por quê.
Os três tipos
| Mensagem | Base | Handler | Quantos handlers |
|---|---|---|---|
| Comando sem retorno | Command | ICommandHandler<TCommand> | exatamente 1 |
| Comando com retorno | Command<TResult> | ICommandHandler<TCommand, TResult> | exatamente 1 |
| Query | Query<TResult> | IQueryHandler<TQuery, TResult> | exatamente 1 |
| Query em stream | StreamQuery<TItem> | IStreamQueryHandler<TQuery, TItem> | exatamente 1 |
| Evento | Event | IEventHandler<TEvent> | 0 ou mais |
A cardinalidade não é decoração. Comando e query têm um dono: se dois handlers disputarem o mesmo comando, o registro falha no start-up com DuplicateHandlerException, em vez de um vencer em silêncio dependendo da ordem de varredura do assembly. Evento é notificação: qualquer número de interessados, e zero interessados não é erro.
Metadados: IMessage
Toda mensagem carrega três campos, herdados de IMessage. É isso que permite ao pipeline de logging abrir um escopo com o id da mensagem sem conhecer o tipo concreto, e ao de enriquecimento preencher a autoria.
public interface IMessage
{
Guid Id { get; }
DateTimeOffset CreatedOn { get; }
// Settable de propósito: o pipeline de enriquecimento preenche a partir do
// ICurrentUserAccessor quando está vazio.
string? CreatedBy { get; set; }
}Um valor já definido é respeitado. Um comando reenviado por um job em background pode declarar o autor original, e o enriquecimento não sobrescreve.
Escrevendo mensagens
Use init nas propriedades. A mensagem é um dado de entrada, não um objeto mutável — a única exceção é CreatedBy, que o pipeline preenche.
public sealed class CreateProduct : Command<Guid>
{
public Guid CategoryId { get; init; }
public string Sku { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public decimal Price { get; init; }
}
public sealed class DeleteProduct : Command
{
public Guid ProductId { get; init; }
}
public sealed class GetProduct : Query<ProductDetails?>
{
public Guid ProductId { get; init; }
}
public sealed class ProductRepriced(Guid productId, decimal from, decimal to) : Event
{
public Guid ProductId { get; } = productId;
public decimal From { get; } = from;
public decimal To { get; } = to;
}Opções por mensagem
Três atributos mudam o que os pipelines fazem com uma mensagem específica, sem mexer na configuração global. São lidos uma vez por tipo e guardados em cache.
| Atributo | Efeito |
|---|---|
[NoTransaction] | O comando é salvo no fim, como sempre, mas sem transação em volta. Para imports longos que gravam em lotes. |
[SkipValidation] | O pipeline de validação não roda para esta mensagem — um comando interno, disparado por código que já validou tudo. |
[MessageTimeout(segundos)] | O token do handler é cancelado no prazo, e a mensagem falha com TimeoutException. AddNexo<TContext> já inclui o pipeline; montando à mão, AddTimeoutPipeline(). |
[MessageTimeout(5)]
public sealed class SearchProducts : PagedQuery<ProductSummary>
{
public string? Term { get; init; }
}
// Montando os pipelines à mão (AddNexo<TContext> já o inclui):
services.AddNexo(nexo => nexo
.AddLoggingPipeline()
.AddTimeoutPipeline()
.AddUnitOfWorkPipeline());Um handler, várias mensagens
Uma classe pode implementar quantas interfaces de handler quiser, e é registrada uma vez por interface. Isso mantém junto o que muda junto: todos os writes de um agregado num arquivo, com as dependências declaradas uma só vez.
public sealed class ProductCommandHandlers(
ICreate<Product> create,
IDelete<Product> delete,
IReadByExternalId<Product> byExternalId)
: ICommandHandler<CreateProduct, Guid>,
ICommandHandler<RenameProduct>,
ICommandHandler<DeleteProduct>
{
public async Task<Guid> HandleAsync(CreateProduct command, CancellationToken ct)
{
var product = await create.CreateAsync(new Product(command.Sku, command.Price), ct);
return product.ExternalId;
}
public async Task HandleAsync(RenameProduct command, CancellationToken ct)
{
// Leitura por chave vem rastreada: mutar é o update inteiro.
var product = await byExternalId.ReadRequiredAsync(command.ProductId, ct);
product.Rename(command.Name);
}
public async Task HandleAsync(DeleteProduct command, CancellationToken ct)
{
var product = await byExternalId.ReadRequiredAsync(command.ProductId, ct);
await delete.DeleteAsync(product, ct);
}
}Despachando
public interface IMediator
{
Task SendAsync(ICommand command, CancellationToken ct = default);
Task<TResult> SendAsync<TResult>(ICommand<TResult> command, CancellationToken ct = default);
Task<TResult> FetchAsync<TResult>(IQuery<TResult> query, CancellationToken ct = default);
IAsyncEnumerable<TItem> StreamAsync<TItem>(IStreamQuery<TItem> query, CancellationToken ct = default);
Task BroadcastAsync(IEvent @event, CancellationToken ct = default);
}Uma query em stream devolve os itens um a um, à medida que o banco os entrega, em vez de materializar a lista inteira — para exportar um CSV grande, alimentar um relatório ou responder com IAsyncEnumerable num endpoint:
public sealed class ExportProducts : StreamQuery<ProductRow>;
internal sealed class ExportProductsHandler(IQueryable<Product> products)
: IStreamQueryHandler<ExportProducts, ProductRow>
{
public IAsyncEnumerable<ProductRow> HandleAsync(ExportProducts query, CancellationToken ct) =>
products
.OrderBy(p => p.Id)
.Select(p => new ProductRow(p.ExternalId, p.Sku, p.Price))
.AsAsyncEnumerable();
}
// No endpoint: o ASP.NET Core serializa o stream conforme ele chega.
app.MapGet("/products/export", (IMediator mediator, CancellationToken ct) =>
mediator.StreamAsync(new ExportProducts(), ct));Os pipelines também veem streams (OnStreamAsync): logging, validação e telemetria se aplicam. O unit of work não — um stream é leitura.
O IMediator é scoped. Cada requisição, cada mensagem consumida de uma fila, cada iteração de um worker deve rodar no seu próprio escopo de DI — é o escopo que define a fronteira do unit of work.
// Num worker, crie o escopo explicitamente.
await using var scope = serviceProvider.CreateAsyncScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.SendAsync(new ImportProducts { /* ... */ }, ct);Registro e varredura
A varredura pega classes concretas, não genéricas, que implementem qualquer interface de handler — incluindo tipos internal, que é onde a maioria dos handlers deveria estar.
services.AddNexo(nexo => nexo
// Um assembly por marcador de tipo.
.AddHandlersFromAssemblyOf<Program>()
// Vários de uma vez.
.AddHandlersFromAssemblies(typeof(Program).Assembly, typeof(Billing).Assembly)
// Com filtro: deixa de fora o que você quer registrar à mão.
.AddHandlersFromAssemblyOf<Program>(filter: t => !t.Namespace!.Contains("Legacy"))
// Um handler específico, de um assembly de terceiro.
.AddHandler<VendorWebhookHandler>());O tempo de vida padrão é Scoped, que é o que combina com o unit of work pelo qual eles escrevem. Ajuste com nexo.HandlerLifetime se precisar. Um assembly com um tipo que não carrega — uma dependência opcional ausente — não derruba a varredura: os tipos que carregam são registrados, os outros ficam de fora.
O pacote Nexo traz um gerador de código que faz a varredura em tempo de compilação. Ele escreve, no seu assembly, um AddGeneratedHandlers() com cada handler registrado por nome — sem reflexão no start-up — e prepara o despacho de cada tipo de mensagem, que de outro modo seria montado com MakeGenericType no primeiro uso. É o caminho para trimming e native AOT:
services.AddNexo<CatalogDbContext>(nexo => nexo
.AddGeneratedHandlers() // gerado para este assembly; em vez de AddHandlersFromAssemblyOf<Program>()
.AddValidatorsFromAssemblyOf<Program>());| Diagnóstico | Quando | Severidade |
|---|---|---|
NEXO001 | Dois handlers para o mesmo comando ou query — o erro que a varredura só daria no start-up aparece no build. | erro |
NEXO002 | Um handler private aninhado, que o código gerado não consegue alcançar. | aviso |
Eventos: sequencial ou paralelo
Por padrão os handlers de um evento rodam um depois do outro, na ordem de registro. Não é conservadorismo: eles compartilham o escopo, e portanto o DbContext, que não é thread-safe.
services.AddNexo(nexo => nexo
.ConfigureMediator(mediator => mediator.EventDispatchMode = EventDispatchMode.Parallel));Como o despacho funciona por dentro
SendAsync(ICommand) só conhece a interface. Para chamar o handler tipado sem reflexão no caminho quente, o Nexo constrói um *invoker* fechado por tipo de mensagem, na primeira vez que vê aquele tipo, e guarda em cache para sempre.
A partir daí um despacho é uma busca em ConcurrentDictionary mais uma chamada virtual. A cadeia de pipelines é composta com delegates comuns. Não há Expression.Compile, nem MakeGenericMethod por chamada.