Nexo

Documentação

Traits de entidade

Auditoria, soft delete, identificador público e concorrência: cada um liga porque a entidade implementou uma interface.

Nada é obrigatório

Uma entidade que implementa apenas IEntity<TId> se comporta como POCO puro: sem coluna extra, sem query filter, sem interceptor mexendo nela. Cada trait adicional liga um comportamento, e só para aquela entidade.

TraitO que liga
IEntity<TId>Habilita as operações de dados para o tipo.
IHaveExternalIdGuid público com índice único, atribuído na construção (v7 no .NET 9+).
IHaveTimestampsCreatedOn no insert, UpdatedOn em todo update.
IHaveAuditMais CreatedBy e UpdatedBy, do ICurrentUserAccessor.
ISoftDeletableIDelete<T> marca em vez de remover; um query filter esconde a linha.
IHaveRowVersionConcorrência otimista; o conflito chega como ConcurrencyConflictException.
IHaveDomainEventsA entidade registra eventos; o pipeline despacha depois do save.

As bases prontas

C#
// Chave livre.
public abstract class Entity<TId> : IEntity<TId> where TId : IEquatable<TId>;

// Chave long + Guid público. O que a maioria dos projetos quer.
public abstract class Entity : Entity<long>, IHaveExternalId;

// Idem, mais o registro de eventos de domínio.
public abstract class AggregateRoot<TId> : Entity<TId>, IHaveDomainEvents;
public abstract class AggregateRoot : AggregateRoot<long>, IHaveExternalId;
C#
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;
    }

    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; }
}

Identificador público

A chave long é boa para o banco e péssima para a API: expõe volume e ordem de cadastro, e convida a enumerar. O ExternalId resolve isso, e é atribuído no construtor, não no save.

Essa escolha é o que faz eventos de domínio funcionarem. Um evento disparado no construtor já carrega o identificador que o mundo externo vai ver, enquanto a chave do banco ainda não existe — e os eventos são despachados depois do save justamente porque o save é que cria a chave.

C#
// Padrão: UUID v7 (RFC 9562), com o timestamp nos bytes iniciais. Inserções
// consecutivas ficam vizinhas no índice em quem compara GUID byte a byte ou como
// texto: PostgreSQL, MySQL, SQLite, MongoDB.
nexo.UseExternalIdGenerator(ExternalIdGenerators.Version7);

// O SQL Server compara uniqueidentifier começando pelos ÚLTIMOS seis bytes — onde o
// v7 guarda a parte aleatória. Para um catálogo no SQL Server, use o layout que ele
// ordena (o mesmo do gerador sequencial do EF Core):
nexo.UseExternalIdGenerator(ExternalIdGenerators.SqlServer);

O gerador vale para o processo inteiro (Identifiers.Default), porque o identificador nasce no construtor da entidade, longe de qualquer container. Implemente IExternalIdGenerator para uma estratégia sua.

Para entidades que não herdam das bases, o interceptor atribui um identificador no insert se o campo ainda estiver vazio — uma rede de segurança, controlada por AssignMissingExternalIds.

Auditoria

O AuditInterceptor roda em todo save. Insert preenche CreatedOn/CreatedBy e zera UpdatedOn/UpdatedBy; update preenche UpdatedOn/UpdatedBy.

O interceptor precisa estar instalado. É o que UseNexo(sp) faz, dentro do AddDbContext:

C#
builder.Services.AddDbContext<CatalogDbContext>((sp, options) => options
    .UseSqlServer(connectionString)
    .UseNexo(sp));

Use a sobrecarga que entrega o IServiceProvider. É ela que resolve o ICurrentUserAccessor do escopo atual, em vez de capturar um de fora.

Quem é o usuário vem do ICurrentUserAccessor. O padrão é anônimo:

C#
public sealed class HttpCurrentUserAccessor(IHttpContextAccessor accessor) : ICurrentUserAccessor
{
    public string? UserId =>
        accessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
}

// services.AddNexo(nexo => nexo.UseCurrentUser<HttpCurrentUserAccessor>());
// ou, para algo rápido:
// services.AddNexo(nexo => nexo.UseCurrentUser(sp => "sistema"));

O horário vem do TimeProvider registrado. Nos testes, injete um FakeTimeProvider e as colunas de auditoria ficam determinísticas.

Soft delete

Com ISoftDeletable, IDelete<T> marca a linha e estampa DeletedOn/DeletedBy em vez de removê-la — gravando só essas colunas. O query filter adicionado por ApplyNexoConventions (ou por MapSoftDelete) a esconde de todas as leituras. É um filtro nomeado, NexoQueryFilters.SoftDelete, que convive com os seus.

C#
// No handler: exatamente igual a um delete normal.
var product = await byExternalId.ReadRequiredAsync(command.ProductId, ct);
await delete.DeleteAsync(product, ct);

// Depois disso, a linha continua no banco mas sai de toda leitura:
await products.CountAsync(ct);                        // não conta
await byExternalId.ReadAsync(command.ProductId, ct);  // null

// Para vê-la, passe por cima do filtro de soft delete — e só dele:
await products.IgnoreQueryFilters([NexoQueryFilters.SoftDelete])
    .Where(p => p.IsDeleted)
    .ToListAsync(ct);

// E para trazê-la de volta:
await restore.RestoreAsync(product, ct);

O soft delete não depende de qual API removeu a linha. Um DbSet.Remove, ou uma cascata a partir do principal, chegam ao interceptor como Deleted, e ele os converte em soft delete — mantendo os owned types da entidade, que de outro modo seriam apagados junto.

Desligue globalmente com session.SoftDelete = false e IDelete volta a remover de verdade, inclusive para entidades que implementam a interface.

Concorrência otimista

C#
public sealed class Invoice : Entity, IHaveRowVersion
{
    public byte[]? RowVersion { get; set; }
}

ApplyNexoConventions marca a propriedade como token de concorrência. Quando a linha mudou desde a leitura, o DbUpdateConcurrencyException do provider é traduzido para ConcurrencyConflictException — que vive em Nexo.Abstractions, então a camada web mapeia para 409 sem referenciar EF Core.

Mapeamento

Uma linha cobre todas as entidades:

C#
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>(product =>
    {
        product.MapPrimaryKey();
        product.Property(e => e.Sku).IsRequired().HasMaxLength(32);
    });

    // Por último: índice único do ExternalId, tamanho das colunas de autoria,
    // token de concorrência e o query filter de soft delete, para toda entidade
    // que tenha optado pelo trait. Sua configuração explícita sempre ganha.
    modelBuilder.ApplyNexoConventions();
}

Ou entidade por entidade, quando você quer decidir os detalhes:

C#
modelBuilder.Entity<Product>(product =>
{
    product.MapPrimaryKey();                       // chave gerada pelo banco
    product.MapPrimaryKey(generatedOnAdd: false);  // chave vinda da aplicação
    product.MapExternalId();                       // índice único
    product.MapExternalId(asAlternateKey: true);   // chave alternativa
    product.MapAudit(userIdLength: 64);
    product.MapSoftDelete(addQueryFilter: true);
    product.MapRowVersion();
});