Documentation
First steps
From installed package to a first command writing to the database, in five steps.
Install
dotnet add package Nexo.EntityFrameworkCore
dotnet add package Nexo.FluentValidation # optionalNexo.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
Model the entity
Derive from
Entityfor alongkey and a publicGuid, or fromAggregateRootif 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)); } }Map the context
ApplyNexoConventions()at the end ofOnModelCreatingmaps 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(); } }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? OneAddDbContextper database and oneUseDbContext<T>()insideAddNexo— 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>());Write the command and the handler
The handler asks for exactly the capabilities it uses. No
DbContext, noSaveChanges.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; } }Call it through the mediator
From an endpoint, a worker, a job.
IMediatoris 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
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
└─ commitAssembling 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:
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 lastWith 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>():
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.