Nexo

Documentação

Mensagens e handlers

Commands, queries e events: quem pode ter quantos handlers, e por quê.

Os três tipos

MensagemBaseHandlerQuantos handlers
Comando sem retornoCommandICommandHandler<TCommand>exatamente 1
Comando com retornoCommand<TResult>ICommandHandler<TCommand, TResult>exatamente 1
QueryQuery<TResult>IQueryHandler<TQuery, TResult>exatamente 1
Query em streamStreamQuery<TItem>IStreamQueryHandler<TQuery, TItem>exatamente 1
EventoEventIEventHandler<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.

C#
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.

C#
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.

AtributoEfeito
[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().
C#
[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.

C#
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

C#
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:

C#
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.

C#
// 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.

C#
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:

C#
services.AddNexo<CatalogDbContext>(nexo => nexo
    .AddGeneratedHandlers()     // gerado para este assembly; em vez de AddHandlersFromAssemblyOf<Program>()
    .AddValidatorsFromAssemblyOf<Program>());
DiagnósticoQuandoSeveridade
NEXO001Dois handlers para o mesmo comando ou query — o erro que a varredura só daria no start-up aparece no build.erro
NEXO002Um 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.

C#
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.