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.
| Trait | O que liga |
|---|---|
IEntity<TId> | Habilita as operações de dados para o tipo. |
IHaveExternalId | Guid público com índice único, atribuído na construção (v7 no .NET 9+). |
IHaveTimestamps | CreatedOn no insert, UpdatedOn em todo update. |
IHaveAudit | Mais CreatedBy e UpdatedBy, do ICurrentUserAccessor. |
ISoftDeletable | IDelete<T> marca em vez de remover; um query filter esconde a linha. |
IHaveRowVersion | Concorrência otimista; o conflito chega como ConcurrencyConflictException. |
IHaveDomainEvents | A entidade registra eventos; o pipeline despacha depois do save. |
As bases prontas
// 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;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.
// 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:
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:
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.
// 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
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:
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:
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();
});