Nexo

Documentation

Multiple databases

Three DbContexts, three providers — SQL Server, MySQL and MongoDB — and handlers that do not know which database they are writing to.

The idea: the entity decides the database

An ICreate<Product> receives no key, no name and no connection string. It looks, among the registered contexts, for the one whose model maps `Product` — and writes there. That is all.

The practical consequence is that the handler does not change. There is no ICreate<CatalogDbContext, Product>, no named service, no attribute. Moving Shipment from MySQL to a third database is editing one context's DbSet and nothing else.

ReceiveShipmentHandler.cs — two databases, no database named
internal sealed class ReceiveShipmentHandler(
    IReadByExternalId<Supplier> supplierByExternalId,     // MySQL
    IReadByExternalIdRange<Product> productsByExternalId,  // SQL Server
    ICreate<Shipment> createShipment,                      // MySQL
    ICreateRange<ShipmentLine> createLines,                // MySQL
    TimeProvider timeProvider)
    : ICommandHandler<ReceiveShipment, Guid>
{
    public async Task<Guid> HandleAsync(ReceiveShipment command, CancellationToken ct)
    {
        var supplier = await supplierByExternalId.ReadRequiredAsync(command.SupplierId, ct);
        var products = await productsByExternalId.ReadAsync(command.ProductIds, ct);

        var shipment = await createShipment.CreateAsync(
            new Shipment(supplier.Id, command.Reference, timeProvider.GetUtcNow()), ct);

        // A product came back tracked from SQL Server: touching it writes to the other database.
        foreach (var product in products) product.Reprice(command.PriceFor(product));

        await createLines.CreateAsync(BuildLines(command, shipment, products), ct);

        return shipment.ExternalId;
        // ↑ the pipeline saves both databases and commits both. Nothing here says so.
    }
}

Registering all three

It is still the same pair of calls as always — AddDbContext per database, AddNexo once — with one difference: the extra contexts come in through UseDbContext<T>() inside AddNexo.

Program.cs / composition root
// ── 1. One AddDbContext per database. Nexo never registers the context for you:
//       provider, connection string and retry policy are your decisions.

builder.Services.AddDbContext<CatalogDbContext>((sp, options) => options
    .UseSqlServer(configuration.GetConnectionString("Catalog"),
        sqlServer => sqlServer.EnableRetryOnFailure())
    .UseNexo(sp));                       // ← on all of them, always

builder.Services.AddDbContext<PurchasingDbContext>((sp, options) => options
    .UseMySQL(configuration.GetConnectionString("Purchasing")!,
        mySql => mySql.EnableRetryOnFailure())
    .UseNexo(sp));

builder.Services.AddDbContext<ActivityDbContext>((sp, options) => options
    .UseMongoDB(configuration.GetConnectionString("Activity")!, "nexo_activity")
    .UseNexo(sp));

// ── 2. One AddNexo. The generic type is the first database; the rest come in UseDbContext.

builder.Services.AddNexo<CatalogDbContext>(nexo => nexo
    .UseDbContext<PurchasingDbContext>()
    .UseDbContext<ActivityDbContext>(session =>
    {
        // Replica set: it has transactions. Standalone: false, and Nexo saves without one.
        session.UseTransactions = configuration.GetValue("Activity:UseTransactions", false);
        session.SoftDelete = false;        // a log is append-only
    })
    .AddHandlersFromAssemblyOf<Program>()
    .AddValidatorsFromAssemblyOf<Program>()
    .UseCurrentUser<HttpCurrentUserAccessor>());
CallWhat it does
AddNexo<TContext>(…)Registers the mediator, the data operations, the pipelines in the right order and the first database.
.UseDbContext<TOther>(session => …)Adds another database to the same set, with its own session options. Call it as often as you need.
.UseNexo(sp) in AddDbContextInstalls the audit interceptor. On every context, the document one included: it reads the options of whichever context is saving.
AddNexoEntityFrameworkCore<TContext>(…)The low-level route, without pipelines. Call it once per database when you assemble the pipelines by hand.

Assembling the pipelines manually, the shape is the same — AddNexoEntityFrameworkCore once per database:

C#
builder.Services.AddNexoEntityFrameworkCore<CatalogDbContext>();
builder.Services.AddNexoEntityFrameworkCore<PurchasingDbContext>();
builder.Services.AddNexoEntityFrameworkCore<ActivityDbContext>(s => s.UseTransactions = false);

builder.Services.AddNexo(nexo => nexo
    .AddHandlersFromAssemblyOf<Program>()
    .AddLoggingPipeline()
    .AddTimeoutPipeline()                // for [MessageTimeout]
    .AddEnrichmentPipeline()
    .AddValidationPipeline()
    .AddUnitOfWorkPipeline());           // always last

Splitting the models

This is trap number one, and it has nothing to do with Nexo: ApplyConfigurationsFromAssembly(assembly) without a filter hands every configuration in the assembly to every context. With three contexts, each tries to map all three databases — and the error you get talks about a missing table, not a duplicate configuration.

One context: works
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(
        typeof(CatalogDbContext).Assembly);

    modelBuilder.ApplyNexoConventions();
}
Three contexts: filter by namespace
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(
        typeof(CatalogDbContext).Assembly,
        type => type.Namespace == typeof(CatalogDbContext).Namespace);

    modelBuilder.ApplyNexoConventions();
}

With the filter, each context applies only the configurations that live in its own folder. The rule becomes visible on disk:

Text
Persistence/
├─ Catalog/                      → SQL Server
│  ├─ CatalogDbContext.cs
│  ├─ CategoryConfiguration.cs
│  ├─ ProductConfiguration.cs
│  └─ PriceHistoryConfiguration.cs
├─ Purchasing/                   → MySQL
│  ├─ PurchasingDbContext.cs
│  ├─ SupplierConfiguration.cs
│  ├─ ShipmentConfiguration.cs
│  └─ ShipmentLineConfiguration.cs
├─ Activity/                     → MongoDB
│  ├─ ActivityDbContext.cs
│  └─ ActivityEntryConfiguration.cs
└─ DatabaseInitializer.cs

Transactions: what Nexo guarantees and what it does not

There is no distributed transaction. No provider offers one, and pretending otherwise would be worse than saying so. What the unit of work does with several databases is this:

Text
SendAsync(ReceiveShipment)
│
└─ UnitOfWorkPipeline
   ├─ marks the scope as transactional     ← no transaction open yet
   ├─ ReceiveShipmentHandler.HandleAsync
   │  ├─ ICreate<Shipment>       → only tracks
   │  └─ product.Reprice(...)    → only tracks
   ├─ saves every context that has changes:
   │  ├─ MySQL       opens a transaction → INSERT shipment, shipment_lines
   │  └─ SQL Server  opens a transaction → UPDATE product
   ├─ collects and dispatches the domain events from both
   │  └─ ActivityRecorder → ICreate<ActivityEntry> → MongoDB (replica set: opens one)
   ├─ saves what the handlers wrote
   └─ commits each transaction, in registration order:
      SQL Server (catalogue) → MySQL (purchasing) → MongoDB (activity)
  • Nothing is written until the handler returns. An exception anywhere inside the handler leaves both databases untouched — no distributed transaction needed, because no write ever happened.
  • Only the databases actually written to pay for a transaction. The transaction opens on the first write, on the context being written. A request that only reads the catalogue never opens a connection to the other two.
  • Each database has its own transaction, and the commits are sequential, in registration order — not in the order the handler wrote. If a commit fails before any other has happened, everything rolls back and the command can be retried. If it fails after one database has committed, Nexo rolls back the remaining ones and throws PartialCommitException, with CommittedStores and UncommittedStores. That window is the price of several databases, and no library closes it.
  • A partial commit is never retried. A PartialCommitException is not transient for any execution strategy: replaying the command would write again to the database that already committed. It reaches your code to be reconciled — logged, alerted on, compensated.
  • A transient failure before the first commit is retried in full. With EnableRetryOnFailure() on one of the providers, the whole unit of work runs inside its execution strategy; each retry rolls back the open transactions and clears the change tracker of every context before running the handler again.
  • A provider without transactions simply saves. Standalone MongoDB reports IsRelational() == false, and Nexo saves that context without a transaction — and stops the provider from opening one on its own — instead of blowing up on one the server cannot open. What it saved stays saved even if a later commit fails.

With one context registered none of this changes: it is still one transaction, opened through the provider's execution strategy, with transient-failure retries over the whole scope.

Session options per database

Each context has its own DbSessionOptions. A services.Configure<DbSessionOptions>(…) sets the default for all of them; the context callback tunes one.

C#
// The default for every database.
builder.Services.Configure<DbSessionOptions>(session => session.TrackReads = true);

builder.Services.AddNexo<CatalogDbContext>(nexo => nexo
    // Inherits TrackReads = true.
    .UseDbContext<PurchasingDbContext>()

    // Inherits TrackReads = true and overrides the rest.
    .UseDbContext<ActivityDbContext>(session =>
    {
        session.UseTransactions = false;   // standalone MongoDB
        session.SoftDelete = false;
        session.ApplyAudit = true;
    }));

To check what actually applied — useful when one database is not behaving as expected — inject DbSessionProvider:

C#
var sessions = scope.ServiceProvider.GetRequiredService<DbSessionProvider>();

sessions.ForEntity<Product>().Name;                        // "CatalogDbContext"
sessions.ForEntity<ActivityEntry>().SupportsTransactions;  // false
sessions.ForEntity<ActivityEntry>().Options.SoftDelete;    // false
sessions.ContextTypes;                                     // in registration order
sessions.Active;                                           // only those materialised in this scope

What each provider changes

Nexo is the same across all three. What changes is what the provider can do — and that holds for any EF Core code, not just Nexo.

SQL ServerMySQLMongoDB
PackageMicrosoft.EntityFrameworkCore.SqlServerMySql.EntityFrameworkCoreMongoDB.EntityFrameworkCore
CallUseSqlServer(cs)UseMySQL(cs)UseMongoDB(cs, database)
TransactionsYesYesReplica set only (UseTransactions = true)
Duplicate key → DuplicateKeyExceptionYes (2601, 2627)Yes (1062)Yes (E11000)
Identity long keyYesYesNo — use a Guid
IHaveRowVersionYes (rowversion)Not nativeNo
Joins between entitiesYesYesNo
EnableRetryOnFailure()YesYesn/a
Requires InvariantGlobalization=falseYesNoNo

The case that catches people out most is the key. Nexo's Entity and AggregateRoot have a database-generated long key — which MongoDB has no way of producing:

MongoDB: fails on Add
// System.NotSupportedException:
// no value generator is available
// for properties of type 'long'
public sealed class ActivityEntry : Entity
{
    public required string Kind { get; set; }
}
MongoDB: a Guid key
// The provider generates the Guid on insert
// and maps the key to _id.
public sealed class ActivityEntry
    : Entity<Guid>, IHaveAudit
{
    public required string Kind { get; set; }
}

With a Guid key, the shortcuts that assume long no longer apply: use IReadById<ActivityEntry, Guid> instead of IReadById<ActivityEntry>. The mapping stays a plain HasKey — MapPrimaryKey<T, Guid>() would ask for a database-generated value.

ActivityEntryConfiguration.cs
internal sealed class ActivityEntryConfiguration : IEntityTypeConfiguration<ActivityEntry>
{
    public void Configure(EntityTypeBuilder<ActivityEntry> builder)
    {
        builder.ToCollection("activity");

        // Just the key: the provider maps it to _id and generates the Guid on insert.
        builder.HasKey(e => e.Id);

        builder.Property(e => e.Kind).HasMaxLength(32);

        // A nested document, not a second collection. ApplyNexoConventions skips owned types.
        builder.OwnsOne(e => e.Change);

        builder.HasIndex(e => e.Subject);
    }
}

Two things that stop the application before the first command

Neither is Nexo's, and both only show up when you swap SQLite for a real database. They were exactly what broke the sample the first time it ran against real SQL Server, MySQL and MongoDB.

csproj: fails on every connection
<PropertyGroup>
  <InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
csproj: what SqlClient requires
<PropertyGroup>
  <InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>

Turning invariant globalization off means the runtime needs ICU — and Alpine images do not ship it. In the container, the two missing lines are these:

Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS runtime

RUN apk add --no-cache icu-libs icu-data-full
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false

No foreign keys, no joins

One extra database costs two concrete things, and both show up in the model before they show up in the code. A shipment line in MySQL cannot have a foreign key to a product in SQL Server, and no query crosses the two connections.

In the model: an external identifier plus a snapshot, instead of a foreign key.

One database
public sealed class ShipmentLine : Entity<long>
{
    public long ProductId { get; set; }
    public Product? Product { get; set; }

    public int Quantity { get; set; }
    public decimal UnitCost { get; set; }
}
Two databases
public sealed class ShipmentLine : Entity<long>
{
    // The product's ExternalId, not the key:
    // another database's key means nothing here.
    public Guid ProductId { get; set; }

    // A snapshot of what was delivered, not a cache.
    public string ProductSku { get; set; } = "";
    public string ProductName { get; set; } = "";

    public int Quantity { get; set; }
    public decimal UnitCost { get; set; }
}

In the query: stitching in memory, instead of a join. The handler takes three steps, and the second is one batched query — never one per line.

GetShipmentHandler.cs
internal sealed class GetShipmentHandler(
    IQueryable<Shipment> shipments,          // MySQL
    IQueryable<ShipmentLine> shipmentLines,  // MySQL
    IQueryable<Supplier> suppliers,          // MySQL
    IQueryable<Product> products,            // SQL Server
    IQueryable<Category> categories)         // SQL Server
    : IQueryHandler<GetShipment, ShipmentDetails?>
{
    public async Task<ShipmentDetails?> HandleAsync(GetShipment query, CancellationToken ct)
    {
        // 1. Everything MySQL knows, in one query — joins and a nested list as usual.
        var header = await (
            from shipment in shipments
            join supplier in suppliers on shipment.SupplierId equals supplier.Id
            where shipment.ExternalId == query.ShipmentId
            select new
            {
                shipment.Reference, supplier.Code,
                Lines = (from line in shipmentLines
                         where line.ShipmentId == shipment.Id
                         select new { line.ProductId, line.ProductSku, line.Quantity }).ToList(),
            }).FirstOrDefaultAsync(ct);

        if (header is null) return null;

        // 2. The catalogue side, batched, by the keys the lines kept.
        var productIds = header.Lines.Select(l => l.ProductId).Distinct().ToArray();

        var current = await (
            from product in products
            join category in categories on product.CategoryId equals category.Id
            where productIds.Contains(product.ExternalId)
            select new { product.ExternalId, product.Price, Category = category.Name })
            .ToDictionaryAsync(x => x.ExternalId, ct);

        // 3. The stitching. A product deleted from the catalogue is simply not in the
        //    dictionary, and the line goes on showing what was received.
        return new ShipmentDetails(header.Reference, header.Code, header.Lines
            .Select(line => new ShipmentLineDetails(
                line.ProductSku,
                current.GetValueOrDefault(line.ProductId)?.Price ?? 0m,
                current.GetValueOrDefault(line.ProductId)?.Category ?? "(out of catalogue)"))
            .ToList());
    }
}

Events cross databases

An entity in SQL Server raises an event; a handler reacts by writing to MongoDB. Both sides are ordinary Nexo code — the event collector sweeps every context materialised in the scope, and dispatch happens inside the same unit of work.

ActivityRecorder.cs
internal sealed class ActivityRecorder(ICreate<ActivityEntry> createEntry)
    : IEventHandler<ProductCreated>,
      IEventHandler<ProductRepriced>
{
    public Task HandleAsync(ProductRepriced @event, CancellationToken ct) =>
        createEntry.CreateAsync(
            new ActivityEntry
            {
                Kind = "product-repriced",
                Subject = @event.ProductId,
                Summary = $"Price from {@event.From:0.00} to {@event.To:0.00}",
                Change = new PriceChange { From = @event.From, To = @event.To },
            },
            ct);
}

Compare it with PriceHistoryRecorder, which reacts to the same events by writing to SQL Server: both ask for an ICreate<T> and neither says where the write lands. The difference is one line of mapping in another file.

Migrations, one set per database

Each context has its own migration history. --context stops being optional the moment a second one exists, and it is worth passing --output-dir so the files sit next to the context.

Shell
dotnet ef migrations add Initial \
    --context CatalogDbContext \
    --output-dir Persistence/Catalog/Migrations

dotnet ef migrations add Initial \
    --context PurchasingDbContext \
    --output-dir Persistence/Purchasing/Migrations

dotnet ef database update --context CatalogDbContext
dotnet ef database update --context PurchasingDbContext

The errors, and what they mean

ErrorCauseWhat to do
EntityNotMappedExceptionNo registered context maps the entity.Either the DbSet<T> (or the IEntityTypeConfiguration) is missing from the owning context, or the context was never registered with UseDbContext<T>(). The message lists the contexts it searched. With one context there is nothing to search, and the error you get is Entity Framework's own: Cannot create a DbSet for T because this type is not included in the model.
AmbiguousEntityMappingExceptionTwo contexts map the same entity.Nearly always an unfiltered ApplyConfigurationsFromAssembly. See splitting the models.
InvalidOperationException: … is registered with Nexo but not with the containerUseDbContext<T>() without the matching AddDbContext<T>().Nexo does not register the context: add its AddDbContext<T>(…).
NotSupportedException: no value generator … for properties of type longAn entity with a long key in a MongoDB context.Derive from Entity<Guid> instead of Entity.
NotSupportedException: Globalization Invariant Mode is not supportedInvariantGlobalization turned on, with SQL Server.Turn it off in the csproj, and install icu-libs in the Alpine image. See before running.
InvalidOperationException: … does not support user-initiated transactionsA hand-opened transaction with a retrying execution strategy.From Nexo, it does not happen. From your code, wrap everything in Database.CreateExecutionStrategy().ExecuteAsync(…).
PartialCommitExceptionOne database committed and another's commit failed right after.CommittedStores and UncommittedStores say which. Do not replay the command: reconcile (compensate, alert, reprocess the missing side).
InvalidOperationException: … never called UseNexo(serviceProvider)A context registered without .UseNexo(sp) in AddDbContext.Without the interceptor, auditing and soft delete would silently not apply; Nexo would rather refuse. Add .UseNexo(sp), or turn auditing off for that context with ApplyAudit = false.
Invalid object name / Table doesn't existA context mapped entities belonging to another database.The same namespace filter on ApplyConfigurationsFromAssembly.

To check the routing before bringing any server up, the models are built without touching the database. A ten-line start-up check pays for itself:

C#
using var scope = app.Services.CreateScope();
var sessions = scope.ServiceProvider.GetRequiredService<DbSessionProvider>();

foreach (var entity in new[] { typeof(Product), typeof(Shipment), typeof(ActivityEntry) })
{
    var session = sessions.ForEntity(entity);
    app.Logger.LogInformation(
        "{Entity} -> {Context} (transactions: {Tx})",
        entity.Name, session.Name, session.SupportsTransactions);
}

In the sample

The sample in sample/ is exactly this configuration, working: three databases, one set of handlers, none of them naming a database.

DatabaseContextEntitiesWhy
SQL ServerCatalogDbContextCategory, Product, PriceHistoryRelational integrity is the point: a product belongs to a category, history belongs to a product.
MySQLPurchasingDbContextSupplier, Shipment, ShipmentLineA second relational database, so the sample faces the real cost of a second database.
MongoDBActivityDbContextActivityEntryAppend-only, read newest first, never joined. Genuinely document-shaped.
Shell
# Brings up the three databases, the API and the site
docker compose up --build

# http://localhost:8081/health         says which database is up
# http://localhost:8081/openapi/v1.json

# Or locally: the three servers through compose, the API on your machine
docker compose up -d sqlserver mysql mongo
./pack.sh
dotnet run --project sample/Nexo.Sample.Api

The path worth following with a debugger is POST /shipments: it reads from MySQL, reads and writes SQL Server, and the event the entity raises writes to MongoDB — all in one command, with GET /activity showing the result from the third database.

This is the log that command produces, with transaction logging at Debug. Two transactions, one per relational database written, each opened on its first write; the domain events dispatched before the commits; and MongoDB written with no transaction at all:

POST /shipments
Opening a transaction for ReceiveShipment (c399ea4e-...)
  Beginning transaction with isolation level 'Unspecified'.     <- MySQL, first write
  Began transaction with isolation level 'Unspecified'.
  Beginning transaction with isolation level 'Unspecified'.     <- SQL Server, first write
  Began transaction with isolation level 'ReadCommitted'.
  Saved 4 row(s) for ReceiveShipment
  Dispatching domain event ProductRepriced (round 1)            <- writes history and activity
  Committing transaction.   Committed transaction.              <- commit 1
  Committing transaction.   Committed transaction.              <- commit 2

And a command touching only one relational database opens only one transaction, even when its event writes to MongoDB — because single-node MongoDB has no transaction to open:

POST /products
Opening a transaction for CreateProduct (19f4d699-...)
  Beginning transaction with isolation level 'Unspecified'.     <- SQL Server, and only that
  Saved 1 row(s) for CreateProduct
  Dispatching domain event ProductCreated (round 1)             <- a document in MongoDB
  Committed transaction.