Nexo

Documentation

First steps

From installed package to a first command writing to the database, in five steps.

Install

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

Nexo.EntityFrameworkCore already brings Nexo and Nexo.Abstractions with it. If you are going to implement another persistence provider, reference only Nexo.Abstractions from the domain project. The database provider is your choice and comes separately — Microsoft.EntityFrameworkCore.SqlServer, MySql.EntityFrameworkCore, Npgsql.EntityFrameworkCore.PostgreSQL, MongoDB.EntityFrameworkCore…

The five steps

  1. Model the entity

    Derive from Entity for a long key and a public Guid, or from AggregateRoot if it is going to record domain events. The traits (IHaveAudit, ISoftDeletable) are optional and additive.

    Product.cs
    using Nexo.Data;
    
    public sealed class Product : AggregateRoot, IHaveAudit, ISoftDeletable
    {
        private Product() { Sku = string.Empty; }   // EF materialisation
    
        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. Map the context

    ApplyNexoConventions() at the end of OnModelCreating maps every trait on every entity: uniqueness of the external identifier, the size of the audit columns, the concurrency token and the query filter that hides deleted rows. Call it last, so your explicit configuration always wins.

    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. Register

    Two calls. UseNexo(sp) installs the audit interceptor; AddNexo<TContext> registers the mediator, the data operations and the pipelines in the right order. More than one database? One AddDbContext per database and one UseDbContext<T>() inside AddNexo — see Multiple databases.

    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. Write the command and the handler

    The handler asks for exactly the capabilities it uses. No DbContext, no 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. Call it through the mediator

    From an endpoint, a worker, a job. IMediator is scoped, so every request gets its own 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 });
    });

What happens in that call

Text
SendAsync(CreateProduct)
│
├─ LoggingPipeline            opens a log scope with the message name and id
├─ MessageEnrichmentPipeline  fills CreatedBy from the ambient user
├─ ValidationPipeline         runs CreateProductValidator — failed? it stops here
└─ UnitOfWorkPipeline
   ├─ opens the transaction (with the provider's execution strategy)
   ├─ CreateProductHandler.HandleAsync
   │  └─ ICreate<Product>.CreateAsync   → only tracks, does not hit the database
   ├─ SaveChanges                        → INSERT, the key is assigned
   ├─ collects ProductCreated from the entity
   ├─ BroadcastAsync(ProductCreated)     → joins this same unit of work
   ├─ SaveChanges for whatever the handlers wrote
   └─ commit

Assembling the pipelines by hand

AddNexo<TContext> is an opinionated shortcut. When you want to control the order or slot a pipeline of your own into the middle, use the two calls separately:

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

builder.Services.AddNexo(nexo => nexo
    .AddHandlersFromAssemblyOf<Program>()
    .AddLoggingPipeline()
    .AddEnrichmentPipeline()
    .AddPipeline<TenantPipeline>()       // yours, between validation and the transaction
    .AddValidationPipeline()
    .AddUnitOfWorkPipeline());           // always last

With several databases the shape is the same: AddNexoEntityFrameworkCore<T> once per database, and AddNexo exactly once at the end.

More than one database

The steps above do not change. What changes is that there is one AddDbContext per database, and the extra contexts come in through 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>());

And the handlers change nothing at all: ICreate<Product> finds SQL Server because that is where Product is mapped. Multiple databases covers the rest — splitting the models, transactions across databases, and what each provider changes.