Documentation
Entity Framework Core
How the provider works, what it registers, and the session options.
What gets registered
services.AddNexoEntityFrameworkCore<CatalogDbContext>();That call registers, all of them Scoped:
DbSessionProvider— hands out each context's session and finds the right one for an entity. It is what makes the operations add up to one unit of work per database.DbSession— the bridge to theDbContext. Without a generic argument, it resolves the first registered context.IUnitOfWork,IDataStore,IDomainEventCollector,AuditInterceptor.- The open generics:
IQueryable<>,ICreate<>,ICreateRange<>,IUpdate<>,IUpdateRange<>,IDelete<>,IDeleteRange<>,IRestore<>,IBulkUpdate<>,IBulkDelete<>. - The executor that wires the
Nexo.Linqoperators to EF Core. - The reads, in both shapes:
IReadById<,>andIReadById<>(longkey),IReadByExternalId<,>andIReadByExternalId<>(Guid), the ranges and theIExists*. NexoDbContextRegistry— the list of registered contexts, in registration order.
Call it once per database. The operations are open generics registered once; what a second call adds is the context. From then on each operation finds its database by looking for the context that maps the entity — ICreate<Invoice> writes to the billing database and ICreate<Product> to the catalogue one, without changing either handler.
services.AddNexoEntityFrameworkCore<CatalogDbContext>();
services.AddNexoEntityFrameworkCore<BillingDbContext>();
// Or, with the pipelines assembled for you:
services.AddNexo<CatalogDbContext>(nexo => nexo.UseDbContext<BillingDbContext>());Installing the interceptor
builder.Services.AddDbContext<CatalogDbContext>((sp, options) => options
.UseSqlServer(connectionString)
.UseNexo(sp));Forgetting UseNexo does not pass in silence. Without the interceptor, auditing, soft delete through Remove and identifier assignment would simply not happen — and nobody would notice until an audit asked who created the row. So the first session of a context without it fails, with a message saying what to add. A context that genuinely does not want auditing says so with ApplyAudit = false and is excused.
On every context, document ones included. There is only one interceptor, and it reads the options of whichever context is saving — so a relational database stamps its audit columns while another, configured with ApplyAudit = false, does not.
Session options
services.AddNexoEntityFrameworkCore<CatalogDbContext>(session =>
{
session.SaveBehavior = SaveBehavior.UnitOfWork;
session.TrackReads = true;
session.SoftDelete = true;
session.ApplyAudit = true;
session.AssignMissingExternalIds = true;
session.UseTransactions = null;
});The options are per context: this callback tunes one database. For the default across all of them, use services.Configure<DbSessionOptions>(…) — each context starts from that and its own callback overrides whatever it likes.
| Option | Default | Effect |
|---|---|---|
SaveBehavior | UnitOfWork | When changes reach the database. Immediate saves on every operation — convenient outside a mediator, but it gives up atomicity. |
TrackReads | true | Reads by key come back tracked, so mutating the entity is the update. Injected queries never track, regardless. |
SoftDelete | true | IDelete marks ISoftDeletable entities instead of removing them. |
ApplyAudit | true | Turns on the interceptor's stamps. |
AssignMissingExternalIds | true | Assigns a sequential Guid on insert when the field is empty. A safety net: the base classes already assign on construction. |
UseTransactions | null | null detects from the provider (IsRelational). Set it explicitly to force the decision. |
ApplyNexoConventions
It walks every entity in the model and applies what each trait asks for. Call it last, after your own configuration — what you set explicitly always wins.
| Trait on the entity | What the convention does |
|---|---|
IHaveExternalId | ExternalId required, unique index. |
IHaveTimestamps | CreatedOn required. |
IHaveAudit | CreatedBy and UpdatedBy with HasMaxLength (128 by default). |
IHaveRowVersion | RowVersion as a concurrency token. |
ISoftDeletable | DeletedBy with a length, an index on IsDeleted and the query filter e => !e.IsDeleted. |
Owned types are skipped: they share the parent's configuration.
How the injected IQueryable works
The container will not hand out a DbSet<T> from an open generic registration, so there is a thin wrapper. It passes Provider and Expression through untouched — every LINQ operator composes exactly as it does on the set itself — and implements IAsyncEnumerable<T>, which is what makes ToListAsync work directly on the injected instance, with no operator in front of it.
// Works because the wrapper is an IAsyncEnumerable.
var all = await products.ToListAsync(ct);
// Works because the Provider is EF's: the Where returns EF's real query.
var some = await products.Where(p => p.Price > 100).ToListAsync(ct);Concurrency
DbUpdateConcurrencyException is translated into ConcurrencyConflictException, with the entity name in the message and the original exception in InnerException. Whoever handles it never has to reference EF Core.
ConcurrencyConflictException conflict => new ProblemDetails
{
Status = StatusCodes.Status409Conflict,
Title = "Concurrent modification",
Detail = conflict.Message,
},Providers
Nexo talks to a DbContext and leaves the translation to EF Core's provider, so the provider is your decision and Nexo takes no part in it. The only rule is EF Core's own: the provider has to be from the 10 line, the EF Core that Nexo uses.
# Install only what you will use. For several databases, one per database.
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package MySql.EntityFrameworkCore # MySQL on EF Core 10
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package MongoDB.EntityFrameworkCore # yes, Mongo is an EF Core provider
dotnet add package Microsoft.EntityFrameworkCore.SqliteThe sample runs three at once — SQL Server, MySQL and MongoDB. How to register more than one, and what that changes about transactions and queries, is in Multiple databases.
| Provider | Transactions | Generated long key | IHaveRowVersion |
|---|---|---|---|
| SQL Server | Yes | Yes | Yes, native rowversion |
| PostgreSQL | Yes | Yes | Through xmin, with explicit mapping |
| MySQL | Yes | Yes | Not native |
| SQLite | Yes | Yes | A mapped column, not maintained by the database |
| MongoDB | Replica set only | No — use a Guid | No |
| In-memory | No | Yes | No |
Stepping out of the abstraction
For what the operations do not cover — raw SQL, a third-party bulk library — inject DbSession and use the DbContext. You are still in the same unit of work. (For an UPDATE/DELETE by predicate, prefer IBulkUpdate<T>/IBulkDelete<T>, which already stamp auditing and soft delete.)
public sealed class ArchiveOldOrdersHandler(DbSession session)
: ICommandHandler<ArchiveOldOrders, int>
{
public Task<int> HandleAsync(ArchiveOldOrders command, CancellationToken ct) =>
session.Context.Orders
.Where(o => o.PlacedOn < command.Before)
.ExecuteUpdateAsync(o => o.SetProperty(x => x.Archived, true), ct);
}Health checks
One health check per registered context, named after the context — because with three databases "unhealthy" on its own answers nothing: which one is down decides whether the whole API is out or only part of it.
// After AddNexo: one check per context Nexo knows about.
builder.Services.AddHealthChecks().AddNexoDbContextChecks(tags: ["ready"]);
app.MapHealthChecks("/health");{
"status": "healthy",
"stores": {
"CatalogDbContext": "up",
"PurchasingDbContext": "up",
"ActivityDbContext": "up"
}
}That shape is the sample's, which passes its own ResponseWriter to MapHealthChecks. One database down makes the endpoint answer 503, which is what an orchestrator needs in order to stop sending traffic.