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.
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.
// ── 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>());| Call | What 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 AddDbContext | Installs 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:
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 lastSplitting 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.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(CatalogDbContext).Assembly);
modelBuilder.ApplyNexoConventions();
}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:
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.csTransactions: 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:
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, withCommittedStoresandUncommittedStores. That window is the price of several databases, and no library closes it. - A partial commit is never retried. A
PartialCommitExceptionis 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.
// 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:
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 scopeWhat 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 Server | MySQL | MongoDB | |
|---|---|---|---|
| Package | Microsoft.EntityFrameworkCore.SqlServer | MySql.EntityFrameworkCore | MongoDB.EntityFrameworkCore |
| Call | UseSqlServer(cs) | UseMySQL(cs) | UseMongoDB(cs, database) |
| Transactions | Yes | Yes | Replica set only (UseTransactions = true) |
Duplicate key → DuplicateKeyException | Yes (2601, 2627) | Yes (1062) | Yes (E11000) |
Identity long key | Yes | Yes | No — use a Guid |
IHaveRowVersion | Yes (rowversion) | Not native | No |
| Joins between entities | Yes | Yes | No |
EnableRetryOnFailure() | Yes | Yes | n/a |
Requires InvariantGlobalization=false | Yes | No | No |
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:
// System.NotSupportedException:
// no value generator is available
// for properties of type 'long'
public sealed class ActivityEntry : Entity
{
public required string Kind { get; set; }
}// 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.
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.
<PropertyGroup>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup><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:
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=falseNo 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.
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; }
}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.
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.
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.
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 PurchasingDbContextThe errors, and what they mean
| Error | Cause | What to do |
|---|---|---|
EntityNotMappedException | No 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. |
AmbiguousEntityMappingException | Two contexts map the same entity. | Nearly always an unfiltered ApplyConfigurationsFromAssembly. See splitting the models. |
InvalidOperationException: … is registered with Nexo but not with the container | UseDbContext<T>() without the matching AddDbContext<T>(). | Nexo does not register the context: add its AddDbContext<T>(…). |
NotSupportedException: no value generator … for properties of type long | An entity with a long key in a MongoDB context. | Derive from Entity<Guid> instead of Entity. |
NotSupportedException: Globalization Invariant Mode is not supported | InvariantGlobalization 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 transactions | A hand-opened transaction with a retrying execution strategy. | From Nexo, it does not happen. From your code, wrap everything in Database.CreateExecutionStrategy().ExecuteAsync(…). |
PartialCommitException | One 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 exist | A 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:
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.
| Database | Context | Entities | Why |
|---|---|---|---|
| SQL Server | CatalogDbContext | Category, Product, PriceHistory | Relational integrity is the point: a product belongs to a category, history belongs to a product. |
| MySQL | PurchasingDbContext | Supplier, Shipment, ShipmentLine | A second relational database, so the sample faces the real cost of a second database. |
| MongoDB | ActivityDbContext | ActivityEntry | Append-only, read newest first, never joined. Genuinely document-shaped. |
# 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.ApiThe 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:
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 2And 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:
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.