Nexo

Documentation

Entity Framework Core

How the provider works, what it registers, and the session options.

What gets registered

C#
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 the DbContext. 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.Linq operators to EF Core.
  • The reads, in both shapes: IReadById<,> and IReadById<> (long key), IReadByExternalId<,> and IReadByExternalId<> (Guid), the ranges and the IExists*.
  • 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.

C#
services.AddNexoEntityFrameworkCore<CatalogDbContext>();
services.AddNexoEntityFrameworkCore<BillingDbContext>();

// Or, with the pipelines assembled for you:
services.AddNexo<CatalogDbContext>(nexo => nexo.UseDbContext<BillingDbContext>());

Installing the interceptor

C#
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

C#
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.

OptionDefaultEffect
SaveBehaviorUnitOfWorkWhen changes reach the database. Immediate saves on every operation — convenient outside a mediator, but it gives up atomicity.
TrackReadstrueReads by key come back tracked, so mutating the entity is the update. Injected queries never track, regardless.
SoftDeletetrueIDelete marks ISoftDeletable entities instead of removing them.
ApplyAudittrueTurns on the interceptor's stamps.
AssignMissingExternalIdstrueAssigns a sequential Guid on insert when the field is empty. A safety net: the base classes already assign on construction.
UseTransactionsnullnull 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 entityWhat the convention does
IHaveExternalIdExternalId required, unique index.
IHaveTimestampsCreatedOn required.
IHaveAuditCreatedBy and UpdatedBy with HasMaxLength (128 by default).
IHaveRowVersionRowVersion as a concurrency token.
ISoftDeletableDeletedBy 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.

C#
// 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.

C#
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.

Shell
# 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.Sqlite

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

ProviderTransactionsGenerated long keyIHaveRowVersion
SQL ServerYesYesYes, native rowversion
PostgreSQLYesYesThrough xmin, with explicit mapping
MySQLYesYesNot native
SQLiteYesYesA mapped column, not maintained by the database
MongoDBReplica set onlyNo — use a GuidNo
In-memoryNoYesNo

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

C#
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.

C#
// After AddNexo: one check per context Nexo knows about.
builder.Services.AddHealthChecks().AddNexoDbContextChecks(tags: ["ready"]);

app.MapHealthChecks("/health");
JSON
{
  "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.