Nexo

Documentation

Entity traits

Auditing, soft delete, a public identifier and concurrency: each one turns on because the entity implemented an interface.

Nothing is mandatory

An entity that implements only IEntity<TId> behaves like a plain POCO: no extra column, no query filter, no interceptor touching it. Each additional trait turns on a behaviour, and only for that entity.

TraitWhat it turns on
IEntity<TId>Enables the data operations for the type.
IHaveExternalIdA public Guid with a unique index, assigned on construction (v7 on .NET 9+).
IHaveTimestampsCreatedOn on insert, UpdatedOn on every update.
IHaveAuditPlus CreatedBy and UpdatedBy, from ICurrentUserAccessor.
ISoftDeletableIDelete<T> marks instead of removing; a query filter hides the row.
IHaveRowVersionOptimistic concurrency; the conflict arrives as ConcurrencyConflictException.
IHaveDomainEventsThe entity records events; the pipeline dispatches them after the save.

The ready-made bases

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

// long key + public Guid. What most projects want.
public abstract class Entity : Entity<long>, IHaveExternalId;

// The same, plus domain event recording.
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; }   // EF materialisation

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

The public identifier

The long key is good for the database and terrible for the API: it exposes your volume and the order records were created in, and invites enumeration. ExternalId solves that, and it is assigned in the constructor, not on save.

That choice is what makes domain events work. An event raised in the constructor already carries the identifier the outside world will see, while the database key does not yet exist — and the events are dispatched after the save precisely because the save is what creates the key.

C#
// Default: UUID v7 (RFC 9562), with the timestamp in the leading bytes. Consecutive
// inserts land next to each other in the index wherever GUIDs are compared byte by byte
// or as text: PostgreSQL, MySQL, SQLite, MongoDB.
nexo.UseExternalIdGenerator(ExternalIdGenerators.Version7);

// SQL Server compares uniqueidentifier starting from the LAST six bytes — where v7 keeps
// its random part. For a catalogue on SQL Server, use the layout it actually orders by
// (the same one EF Core's sequential generator uses):
nexo.UseExternalIdGenerator(ExternalIdGenerators.SqlServer);

The generator is process-wide (Identifiers.Default), because the identifier is born in the entity constructor, far from any container. Implement IExternalIdGenerator for a strategy of your own.

For entities that do not derive from the bases, the interceptor assigns an identifier on insert if the field is still empty — a safety net, controlled by AssignMissingExternalIds.

Auditing

The AuditInterceptor runs on every save. An insert fills CreatedOn/CreatedBy and clears UpdatedOn/UpdatedBy; an update fills UpdatedOn/UpdatedBy.

The interceptor has to be installed. That is what UseNexo(sp) does, inside AddDbContext:

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

Use the overload that hands you the IServiceProvider. It is the one that resolves ICurrentUserAccessor from the current scope, rather than capturing one from outside.

Who the user is comes from ICurrentUserAccessor. The default is anonymous:

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

// services.AddNexo(nexo => nexo.UseCurrentUser<HttpCurrentUserAccessor>());
// or, for something quick:
// services.AddNexo(nexo => nexo.UseCurrentUser(sp => "system"));

The time comes from the registered TimeProvider. In tests, inject a FakeTimeProvider and the audit columns become deterministic.

Soft delete

With ISoftDeletable, IDelete<T> marks the row and stamps DeletedOn/DeletedBy instead of removing it — writing only those columns. The query filter added by ApplyNexoConventions (or by MapSoftDelete) hides it from every read. It is a named filter, NexoQueryFilters.SoftDelete, which coexists with yours.

C#
// In the handler: exactly like an ordinary delete.
var product = await byExternalId.ReadRequiredAsync(command.ProductId, ct);
await delete.DeleteAsync(product, ct);

// After that, the row is still in the database but leaves every read:
await products.CountAsync(ct);                        // does not count it
await byExternalId.ReadAsync(command.ProductId, ct);  // null

// To see it, step past the soft delete filter — and only that one:
await products.IgnoreQueryFilters([NexoQueryFilters.SoftDelete])
    .Where(p => p.IsDeleted)
    .ToListAsync(ct);

// And to bring it back:
await restore.RestoreAsync(product, ct);

Soft delete does not depend on which API removed the row. A DbSet.Remove, or a cascade from the principal, reach the interceptor as Deleted, and it converts them into a soft delete — keeping the entity's owned types, which would otherwise be wiped along with it.

Turn it off globally with session.SoftDelete = false and IDelete goes back to really removing, including for entities that implement the interface.

Optimistic concurrency

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

ApplyNexoConventions marks the property as a concurrency token. When the row has changed since it was read, the provider's DbUpdateConcurrencyException is translated into ConcurrencyConflictException — which lives in Nexo.Abstractions, so the web layer maps it to a 409 without referencing EF Core.

Mapping

One line covers every entity:

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

    // Last: the unique index on ExternalId, the size of the authorship columns, the
    // concurrency token and the soft delete query filter, for every entity that opted
    // into the trait. Your explicit configuration always wins.
    modelBuilder.ApplyNexoConventions();
}

Or entity by entity, when you want to decide the details:

C#
modelBuilder.Entity<Product>(product =>
{
    product.MapPrimaryKey();                       // key generated by the database
    product.MapPrimaryKey(generatedOnAdd: false);  // key supplied by the application
    product.MapExternalId();                       // unique index
    product.MapExternalId(asAlternateKey: true);   // alternate key
    product.MapAudit(userIdLength: 64);
    product.MapSoftDelete(addQueryFilter: true);
    product.MapRowVersion();
});