Nexo

Documentação

Primeiros passos

De pacote instalado a primeiro comando gravando no banco, em cinco passos.

Instalação

Shell
dotnet add package Nexo.EntityFrameworkCore
dotnet add package Nexo.FluentValidation   # opcional

Nexo.EntityFrameworkCore já traz Nexo e Nexo.Abstractions. Se você for implementar outro provider de persistência, referencie só Nexo.Abstractions no projeto de domínio. O provider do banco é escolha sua e vem à parte — Microsoft.EntityFrameworkCore.SqlServer, MySql.EntityFrameworkCore, Npgsql.EntityFrameworkCore.PostgreSQL, MongoDB.EntityFrameworkCore…

Os cinco passos

  1. Modele a entidade

    Herde de Entity para ganhar chave long e um Guid público, ou de AggregateRoot se ela for registrar eventos de domínio. Os traits (IHaveAudit, ISoftDeletable) são opcionais e aditivos.

    Product.cs
    using Nexo.Data;
    
    public sealed class Product : AggregateRoot, IHaveAudit, ISoftDeletable
    {
        private Product() { Sku = string.Empty; }   // materialização do EF
    
        public Product(string sku, decimal price)
        {
            Sku = sku;
            Price = price;
            Raise(new ProductCreated(ExternalId, sku, price));
        }
    
        public string Sku { get; private set; }
        public decimal Price { get; private set; }
    
        public DateTimeOffset CreatedOn { get; set; }
        public DateTimeOffset? UpdatedOn { get; set; }
        public string? CreatedBy { get; set; }
        public string? UpdatedBy { get; set; }
    
        public bool IsDeleted { get; set; }
        public DateTimeOffset? DeletedOn { get; set; }
        public string? DeletedBy { get; set; }
    
        public void Reprice(decimal price)
        {
            if (price == Price) return;
    
            var previous = Price;
            Price = price;
            Raise(new ProductRepriced(ExternalId, previous, price));
        }
    }
  2. Mapeie o contexto

    ApplyNexoConventions() no fim do OnModelCreating mapeia todos os traits de todas as entidades: unicidade do identificador externo, tamanho das colunas de auditoria, token de concorrência e o query filter que esconde as linhas apagadas. Chame por último, para que a sua configuração explícita sempre ganhe.

    CatalogDbContext.cs
    using Microsoft.EntityFrameworkCore;
    
    public sealed class CatalogDbContext(DbContextOptions<CatalogDbContext> options)
        : DbContext(options)
    {
        public DbSet<Product> Products => Set<Product>();
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Product>(product =>
            {
                product.MapPrimaryKey();
                product.Property(e => e.Sku).IsRequired().HasMaxLength(32);
                product.HasIndex(e => e.Sku).IsUnique();
            });
    
            modelBuilder.ApplyNexoConventions();
        }
    }
  3. Registre

    Duas chamadas. UseNexo(sp) instala o interceptor de auditoria; AddNexo<TContext> registra o mediator, as operações de dados e os pipelines na ordem correta. Mais de um banco? Um AddDbContext por banco e um UseDbContext<T>() dentro do AddNexo — veja Vários bancos.

    Program.cs
    builder.Services.AddDbContext<CatalogDbContext>((sp, options) => options
        .UseSqlServer(connectionString)
        .UseNexo(sp));
    
    builder.Services.AddNexo<CatalogDbContext>(nexo => nexo
        .AddHandlersFromAssemblyOf<Program>()
        .AddValidatorsFromAssemblyOf<Program>()
        .UseCurrentUser<HttpCurrentUserAccessor>());
  4. Escreva o comando e o handler

    O handler pede exatamente as capacidades que usa. Nada de DbContext, nada de SaveChanges.

    CreateProduct.cs
    public sealed class CreateProduct : Command<Guid>
    {
        public string Sku { get; init; } = string.Empty;
        public decimal Price { get; init; }
    }
    
    public sealed class CreateProductValidator : AbstractValidator<CreateProduct>
    {
        public CreateProductValidator()
        {
            RuleFor(c => c.Sku).NotEmpty().MaximumLength(32);
            RuleFor(c => c.Price).GreaterThan(0);
        }
    }
    
    public sealed class CreateProductHandler(ICreate<Product> create)
        : ICommandHandler<CreateProduct, Guid>
    {
        public async Task<Guid> HandleAsync(CreateProduct command, CancellationToken ct)
        {
            var product = await create.CreateAsync(new Product(command.Sku, command.Price), ct);
            return product.ExternalId;
        }
    }
  5. Chame pelo mediator

    No endpoint, no worker, no job. O IMediator é scoped, então cada requisição tem seu próprio unit of work.

    C#
    app.MapPost("/products", async (
        CreateProduct command,
        IMediator mediator,
        CancellationToken ct) =>
    {
        var id = await mediator.SendAsync(command, ct);
        return Results.Created($"/products/{id}", new { id });
    });

O que acontece nessa chamada

Texto
SendAsync(CreateProduct)
│
├─ LoggingPipeline          abre escopo de log com nome e id da mensagem
├─ MessageEnrichmentPipeline  preenche CreatedBy a partir do usuário ambiente
├─ ValidationPipeline       roda o CreateProductValidator — falhou? para aqui
└─ UnitOfWorkPipeline
   ├─ abre transação (com a execution strategy do provider)
   ├─ CreateProductHandler.HandleAsync
   │  └─ ICreate<Product>.CreateAsync   → só rastreia, não vai ao banco
   ├─ SaveChanges                        → INSERT, a chave é atribuída
   ├─ coleta ProductCreated da entidade
   ├─ BroadcastAsync(ProductCreated)     → entra neste mesmo unit of work
   ├─ SaveChanges do que os handlers escreveram
   └─ commit

Montando os pipelines à mão

AddNexo<TContext> é um atalho opinativo. Quando você quer controlar a ordem ou inserir um pipeline próprio no meio, use as duas chamadas separadas:

C#
builder.Services.AddNexoEntityFrameworkCore<CatalogDbContext>(session =>
{
    session.TrackReads = true;
    session.SoftDelete = true;
});

builder.Services.AddNexo(nexo => nexo
    .AddHandlersFromAssemblyOf<Program>()
    .AddLoggingPipeline()
    .AddEnrichmentPipeline()
    .AddPipeline<TenantPipeline>()       // o seu, entre validação e transação
    .AddValidationPipeline()
    .AddUnitOfWorkPipeline());           // sempre por último

Com vários bancos a forma é a mesma: AddNexoEntityFrameworkCore<T> uma vez por banco, e o AddNexo uma só vez no fim.

Mais de um banco

Os passos acima não mudam. O que muda é que existe um AddDbContext por banco, e os contextos extras entram por UseDbContext<T>():

C#
builder.Services.AddDbContext<CatalogDbContext>((sp, o) => o
    .UseSqlServer(configuration.GetConnectionString("Catalog")).UseNexo(sp));

builder.Services.AddDbContext<PurchasingDbContext>((sp, o) => o
    .UseMySQL(configuration.GetConnectionString("Purchasing")!).UseNexo(sp));

builder.Services.AddDbContext<ActivityDbContext>((sp, o) => o
    .UseMongoDB(configuration.GetConnectionString("Activity")!, "activity").UseNexo(sp));

builder.Services.AddNexo<CatalogDbContext>(nexo => nexo
    .UseDbContext<PurchasingDbContext>()
    .UseDbContext<ActivityDbContext>(session => session.UseTransactions = false)
    .AddHandlersFromAssemblyOf<Program>()
    .AddValidatorsFromAssemblyOf<Program>());

E os handlers não mudam nada: ICreate<Product> acha o SQL Server porque é lá que Product está mapeado. Vários bancos cobre o resto — separar os modelos, transação entre bancos, e o que cada provider muda.