Documentação
Vários bancos
Três DbContext, três provedores — SQL Server, MySQL e MongoDB — e handlers que não sabem em qual banco estão escrevendo.
A ideia: a entidade decide o banco
Um ICreate<Product> não recebe chave, nome nem string de conexão. Ele procura, entre os contextos registrados, aquele cujo modelo mapeia `Product` — e escreve lá. É só isso.
A consequência prática é que o handler não muda. Não existe ICreate<CatalogDbContext, Product>, não existe serviço nomeado, não existe atributo. Mover Shipment do MySQL para um terceiro banco é editar o DbSet de um contexto e mais nada.
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);
// Um produto veio rastreado do SQL Server: mexer nele é escrever no outro banco.
foreach (var product in products) product.Reprice(command.PriceFor(product));
await createLines.CreateAsync(BuildLines(command, shipment, products), ct);
return shipment.ExternalId;
// ↑ o pipeline salva os dois bancos e faz commit dos dois. Nada aqui diz isso.
}
}Registrando os três
Continua sendo o mesmo par de chamadas de sempre — AddDbContext para cada banco, AddNexo uma vez — com uma diferença: os contextos extras entram por UseDbContext<T>() dentro do AddNexo.
// ── 1. Um AddDbContext por banco. O Nexo nunca registra o contexto por você:
// provider, string de conexão e política de retry são decisões suas.
builder.Services.AddDbContext<CatalogDbContext>((sp, options) => options
.UseSqlServer(configuration.GetConnectionString("Catalog"),
sqlServer => sqlServer.EnableRetryOnFailure())
.UseNexo(sp)); // ← em todos, sempre
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. Um AddNexo. O tipo genérico é o primeiro banco; os outros vêm no UseDbContext.
builder.Services.AddNexo<CatalogDbContext>(nexo => nexo
.UseDbContext<PurchasingDbContext>()
.UseDbContext<ActivityDbContext>(session =>
{
// Replica set: tem transação. Standalone: false, e o Nexo salva sem uma.
session.UseTransactions = configuration.GetValue("Activity:UseTransactions", false);
session.SoftDelete = false; // log é append-only
})
.AddHandlersFromAssemblyOf<Program>()
.AddValidatorsFromAssemblyOf<Program>()
.UseCurrentUser<HttpCurrentUserAccessor>());| Chamada | O que faz |
|---|---|
AddNexo<TContext>(…) | Registra o mediator, as operações de dados, os pipelines na ordem certa e o primeiro banco. |
.UseDbContext<TOther>(session => …) | Adiciona outro banco ao mesmo conjunto, com as opções de sessão dele. Chame quantas vezes precisar. |
.UseNexo(sp) no AddDbContext | Instala o interceptor de auditoria. Em todos os contextos, inclusive o de documento: ele lê as opções do contexto que está salvando. |
AddNexoEntityFrameworkCore<TContext>(…) | A via de baixo nível, sem pipelines. Chame uma vez por banco quando você monta os pipelines à mão. |
Montando os pipelines manualmente, a forma é a mesma — AddNexoEntityFrameworkCore uma vez por banco:
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() // para [MessageTimeout]
.AddEnrichmentPipeline()
.AddValidationPipeline()
.AddUnitOfWorkPipeline()); // sempre por últimoSeparando os modelos
Esta é a armadilha número um, e ela não tem nada a ver com o Nexo: ApplyConfigurationsFromAssembly(assembly) sem filtro entrega todas as configurações do assembly para todos os contextos. Com três contextos, cada um tenta mapear os três bancos — e o erro que aparece fala de tabela inexistente, não de configuração duplicada.
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();
}Com o filtro, cada contexto aplica só as configurações que moram na pasta dele. A regra vira visível no disco:
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.csTransações: o que o Nexo garante e o que não garante
Não existe transação distribuída. Nenhum provider oferece uma, e fingir que existe seria pior do que dizer isso. O que o unit of work faz com vários bancos é:
SendAsync(ReceiveShipment)
│
└─ UnitOfWorkPipeline
├─ marca o escopo como transacional ← nenhuma transação aberta ainda
├─ ReceiveShipmentHandler.HandleAsync
│ ├─ ICreate<Shipment> → só rastreia
│ └─ product.Reprice(...) → só rastreia
├─ salva cada contexto que tem mudança:
│ ├─ MySQL abre transação → INSERT shipment, shipment_lines
│ └─ SQL Server abre transação → UPDATE product
├─ coleta e despacha os eventos de domínio dos dois
│ └─ ActivityRecorder → ICreate<ActivityEntry> → MongoDB (replica set: abre transação)
├─ salva o que os handlers escreveram
└─ commit de cada transação, na ordem de registro:
SQL Server (catálogo) → MySQL (compras) → MongoDB (atividade)- Nada é gravado até o handler retornar. Uma exceção em qualquer ponto de dentro do handler deixa os dois bancos intactos — sem transação distribuída, porque nem chegou a existir escrita.
- Só os bancos realmente escritos pagam transação. A transação abre na primeira gravação, no contexto que está sendo gravado. Uma requisição que só lê o catálogo não abre conexão com os outros dois.
- Cada banco tem a sua transação, e os commits são sequenciais, na ordem de registro — não na ordem em que o handler escreveu. Se um commit falha antes de qualquer outro ter acontecido, tudo volta e o comando pode ser repetido. Se falha depois que um banco já comitou, o Nexo desfaz os que faltavam e lança
PartialCommitException, comCommittedStoreseUncommittedStores. Essa janela é o preço de ter vários bancos, e nenhuma biblioteca a fecha. - Um commit parcial nunca é repetido. Uma
PartialCommitExceptionnão é transitória para nenhuma execution strategy: repetir o comando gravaria de novo no banco que já comitou. Ela chega ao seu código para ser reconciliada — logada, alertada, compensada. - Uma falha transitória antes do primeiro commit é repetida por inteiro. Com
EnableRetryOnFailure()em algum dos providers, o unit of work inteiro roda dentro da execution strategy dele; cada nova tentativa desfaz as transações abertas e limpa o change tracker de todos os contextos antes de rodar o handler de novo. - Um provider sem transação simplesmente salva. O MongoDB standalone reporta
IsRelational() == false, e o Nexo salva esse contexto sem transação — e impede o provider de abrir uma sozinho — em vez de estourar numa que o servidor não consegue abrir. O que ele salvou fica salvo mesmo que um commit posterior falhe.
Com um contexto registrado nada disso muda: continua sendo uma transação, aberta pela execution strategy do provider, com retry de falha transitória no escopo inteiro.
Opções de sessão por banco
Cada contexto tem o seu DbSessionOptions. Um services.Configure<DbSessionOptions>(…) define o padrão de todos; o callback do contexto ajusta um.
// O padrão de todos os bancos.
builder.Services.Configure<DbSessionOptions>(session => session.TrackReads = true);
builder.Services.AddNexo<CatalogDbContext>(nexo => nexo
// Herda TrackReads = true.
.UseDbContext<PurchasingDbContext>()
// Herda TrackReads = true e sobrescreve o resto.
.UseDbContext<ActivityDbContext>(session =>
{
session.UseTransactions = false; // MongoDB standalone
session.SoftDelete = false;
session.ApplyAudit = true;
}));Para conferir o que valeu — útil quando o comportamento de um banco não é o esperado — injete 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; // na ordem de registro
sessions.Active; // só os materializados neste escopoO que cada provider muda
O Nexo é o mesmo nos três. O que muda é o que o provider sabe fazer — e isso vale para qualquer código EF Core, não só para o Nexo.
| SQL Server | MySQL | MongoDB | |
|---|---|---|---|
| Pacote | Microsoft.EntityFrameworkCore.SqlServer | MySql.EntityFrameworkCore | MongoDB.EntityFrameworkCore |
| Chamada | UseSqlServer(cs) | UseMySQL(cs) | UseMongoDB(cs, database) |
| Transação | Sim | Sim | Só em replica set (UseTransactions = true) |
Chave duplicada → DuplicateKeyException | Sim (2601, 2627) | Sim (1062) | Sim (E11000) |
Chave long de identidade | Sim | Sim | Não — use Guid |
IHaveRowVersion | Sim (rowversion) | Não nativo | Não |
| Join entre entidades | Sim | Sim | Não |
EnableRetryOnFailure() | Sim | Sim | n/d |
Exige InvariantGlobalization=false | Sim | Não | Não |
O caso que mais pega gente desprevenida é a chave. Entity e AggregateRoot do Nexo têm chave long gerada pelo banco — o que o MongoDB não tem como fazer:
// System.NotSupportedException:
// no value generator is available
// for properties of type 'long'
public sealed class ActivityEntry : Entity
{
public required string Kind { get; set; }
}// O provider gera o Guid no insert
// e mapeia a chave para _id.
public sealed class ActivityEntry
: Entity<Guid>, IHaveAudit
{
public required string Kind { get; set; }
}Com chave Guid, os atalhos que assumem long não se aplicam: use IReadById<ActivityEntry, Guid> em vez de IReadById<ActivityEntry>. O mapeamento fica com HasKey puro — MapPrimaryKey<T, Guid>() pediria valor gerado pelo banco.
internal sealed class ActivityEntryConfiguration : IEntityTypeConfiguration<ActivityEntry>
{
public void Configure(EntityTypeBuilder<ActivityEntry> builder)
{
builder.ToCollection("activity");
// Só a chave: o provider mapeia para _id e gera o Guid no insert.
builder.HasKey(e => e.Id);
builder.Property(e => e.Kind).HasMaxLength(32);
// Documento aninhado, não segunda coleção. ApplyNexoConventions ignora owned types.
builder.OwnsOne(e => e.Change);
builder.HasIndex(e => e.Subject);
}
}Duas coisas que travam a aplicação antes do primeiro comando
Nenhuma das duas é do Nexo, e as duas só aparecem quando você troca SQLite por um banco de verdade. Foram exatamente o que quebrou o sample na primeira vez que ele rodou contra SQL Server, MySQL e MongoDB reais.
<PropertyGroup>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup><PropertyGroup>
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>Desligar a globalização invariante significa que o runtime precisa do ICU — e as imagens Alpine não trazem. No container, as duas linhas que faltam são estas:
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=falseSem foreign key, sem join
Um banco a mais custa duas coisas concretas, e as duas aparecem no modelo antes de aparecerem no código. Uma linha de shipment no MySQL não pode ter foreign key para um produto no SQL Server, e nenhuma query cruza as duas conexões.
No modelo: identificador externo mais snapshot, em vez de 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>
{
// O ExternalId do produto, não a chave:
// chave de outro banco não significa nada aqui.
public Guid ProductId { get; set; }
// Snapshot do que foi entregue, não cache.
public string ProductSku { get; set; } = "";
public string ProductName { get; set; } = "";
public int Quantity { get; set; }
public decimal UnitCost { get; set; }
}Na query: costura em memória, em vez de join. O handler faz três passos, e o segundo é uma só query em lote — nunca uma por linha.
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. Tudo o que o MySQL sabe, numa query — join e lista aninhada como sempre.
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. O lado do catálogo, em lote, pelas chaves que as linhas guardaram.
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. A costura. Um produto apagado do catálogo simplesmente não está no dicionário,
// e a linha continua mostrando o que foi recebido.
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 ?? "(fora do catálogo)"))
.ToList());
}
}Eventos atravessam bancos
Uma entidade do SQL Server levanta um evento; um handler reage escrevendo no MongoDB. Os dois lados são código normal do Nexo — o coletor de eventos varre todos os contextos materializados no escopo, e o despacho acontece dentro do mesmo 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 = $"Preço de {@event.From:0.00} para {@event.To:0.00}",
Change = new PriceChange { From = @event.From, To = @event.To },
},
ct);
}Compare com o PriceHistoryRecorder, que reage aos mesmos eventos escrevendo no SQL Server: os dois pedem um ICreate<T> e nenhum dos dois diz onde a escrita cai. A diferença é uma linha de mapeamento em outro arquivo.
Migrations, uma por banco
Cada contexto tem o seu histórico de migrations. O --context deixa de ser opcional no momento em que existe um segundo, e vale passar --output-dir para os arquivos ficarem junto do contexto.
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 PurchasingDbContextOs erros, e o que eles querem dizer
| Erro | Causa | O que fazer |
|---|---|---|
EntityNotMappedException | Nenhum contexto registrado mapeia a entidade. | Faltou o DbSet<T> (ou o IEntityTypeConfiguration) no contexto dono, ou faltou registrar o contexto com UseDbContext<T>(). A mensagem lista os contextos que foram procurados. Com um contexto só não há o que procurar, e o erro que aparece é o do próprio Entity Framework: Cannot create a DbSet for T because this type is not included in the model. |
AmbiguousEntityMappingException | Dois contextos mapeiam a mesma entidade. | Quase sempre é ApplyConfigurationsFromAssembly sem filtro. Veja separando os modelos. |
InvalidOperationException: … is registered with Nexo but not with the container | UseDbContext<T>() sem o AddDbContext<T>() correspondente. | O Nexo não registra o contexto: adicione o AddDbContext<T>(…) dele. |
NotSupportedException: no value generator … for properties of type long | Entidade com chave long num contexto MongoDB. | Herde de Entity<Guid> em vez de Entity. |
NotSupportedException: Globalization Invariant Mode is not supported | InvariantGlobalization ligado, com SQL Server. | Desligue no csproj, e instale icu-libs na imagem Alpine. Veja antes de rodar. |
InvalidOperationException: … does not support user-initiated transactions | Transação aberta à mão com uma execution strategy que faz retry. | Do Nexo, não acontece. Do seu código, envolva tudo em Database.CreateExecutionStrategy().ExecuteAsync(…). |
PartialCommitException | Um banco comitou e o commit de outro falhou em seguida. | CommittedStores e UncommittedStores dizem quais. Não repita o comando: reconcilie (compensação, alerta, reprocessamento do lado que faltou). |
InvalidOperationException: … never called UseNexo(serviceProvider) | Um contexto registrado sem .UseNexo(sp) no AddDbContext. | Sem o interceptor, auditoria e soft delete não seriam aplicados em silêncio; o Nexo prefere recusar. Adicione .UseNexo(sp), ou desligue a auditoria desse contexto com ApplyAudit = false. |
Invalid object name / Table doesn't exist | Um contexto mapeou entidades de outro banco. | O mesmo filtro por namespace no ApplyConfigurationsFromAssembly. |
Para conferir o roteamento antes de subir qualquer servidor, os modelos são construídos sem tocar no banco. Uma checagem de start-up de dez linhas paga o próprio custo:
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} (transação: {Tx})",
entity.Name, session.Name, session.SupportsTransactions);
}No sample
O sample em sample/ é exatamente esta configuração, funcionando: três bancos, um conjunto de handlers, nenhum deles nomeando um banco.
| Banco | Contexto | Entidades | Por quê |
|---|---|---|---|
| SQL Server | CatalogDbContext | Category, Product, PriceHistory | Integridade relacional é o ponto: produto pertence a categoria, histórico pertence a produto. |
| MySQL | PurchasingDbContext | Supplier, Shipment, ShipmentLine | Segundo banco relacional, para que o sample encare o custo real de um segundo banco. |
| MongoDB | ActivityDbContext | ActivityEntry | Append-only, lido do mais novo para o mais antigo, nunca com join. Genuinamente document-shaped. |
# Sobe os três bancos, a API e o site
docker compose up --build
# http://localhost:8081/health diz qual banco está de pé
# http://localhost:8081/openapi/v1.json
# Ou local: os três servidores via compose, a API na sua máquina
docker compose up -d sqlserver mysql mongo
./pack.sh
dotnet run --project sample/Nexo.Sample.ApiO caminho que vale seguir com o depurador é POST /shipments: ele lê do MySQL, lê e escreve no SQL Server, e o evento que a entidade levanta escreve no MongoDB — tudo num comando, com GET /activity mostrando o resultado do terceiro banco.
É este o log que esse comando produz, com o log de transação em Debug. Duas transações, uma por banco relacional escrito, abertas na primeira gravação de cada um; os eventos de domínio despachados antes dos commits; e o MongoDB gravado sem transação nenhuma:
Opening a transaction for ReceiveShipment (c399ea4e-...)
Beginning transaction with isolation level 'Unspecified'. <- MySQL, 1a gravação
Began transaction with isolation level 'Unspecified'.
Beginning transaction with isolation level 'Unspecified'. <- SQL Server, 1a gravação
Began transaction with isolation level 'ReadCommitted'.
Saved 4 row(s) for ReceiveShipment
Dispatching domain event ProductRepriced (round 1) <- grava histórico e atividade
Committing transaction. Committed transaction. <- commit 1
Committing transaction. Committed transaction. <- commit 2E um comando que toca só um banco relacional abre só uma transação, mesmo quando o evento dele escreve no MongoDB — porque o MongoDB de nó único não tem transação para abrir:
Opening a transaction for CreateProduct (19f4d699-...)
Beginning transaction with isolation level 'Unspecified'. <- SQL Server, e só
Saved 1 row(s) for CreateProduct
Dispatching domain event ProductCreated (round 1) <- documento no MongoDB
Committed transaction.