Nexo

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.

ReceiveShipmentHandler.cs — dois bancos, nenhum nome de banco
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.

Program.cs / composition root
// ── 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>());
ChamadaO 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 AddDbContextInstala 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:

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()                // para [MessageTimeout]
    .AddEnrichmentPipeline()
    .AddValidationPipeline()
    .AddUnitOfWorkPipeline());           // sempre por último

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

Um contexto: funciona
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(
        typeof(CatalogDbContext).Assembly);

    modelBuilder.ApplyNexoConventions();
}
Três contextos: filtre por namespace
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:

Texto
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

Transaçõ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 é:

Texto
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, com CommittedStores e UncommittedStores. Essa janela é o preço de ter vários bancos, e nenhuma biblioteca a fecha.
  • Um commit parcial nunca é repetido. Uma PartialCommitException nã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.

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

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;                                     // na ordem de registro
sessions.Active;                                           // só os materializados neste escopo

O 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 ServerMySQLMongoDB
PacoteMicrosoft.EntityFrameworkCore.SqlServerMySql.EntityFrameworkCoreMongoDB.EntityFrameworkCore
ChamadaUseSqlServer(cs)UseMySQL(cs)UseMongoDB(cs, database)
TransaçãoSimSimSó em replica set (UseTransactions = true)
Chave duplicada → DuplicateKeyExceptionSim (2601, 2627)Sim (1062)Sim (E11000)
Chave long de identidadeSimSimNão — use Guid
IHaveRowVersionSim (rowversion)Não nativoNão
Join entre entidadesSimSimNão
EnableRetryOnFailure()SimSimn/d
Exige InvariantGlobalization=falseSimNãoNã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:

MongoDB: falha no 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: chave Guid
// 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.

ActivityEntryConfiguration.cs
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.

csproj: falha em toda conexão
<PropertyGroup>
  <InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
csproj: exigido pelo SqlClient
<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:

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

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

Um banco
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; }
}
Dois bancos
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.

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

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 = $"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.

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

Os erros, e o que eles querem dizer

ErroCausaO que fazer
EntityNotMappedExceptionNenhum 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.
AmbiguousEntityMappingExceptionDois contextos mapeiam a mesma entidade.Quase sempre é ApplyConfigurationsFromAssembly sem filtro. Veja separando os modelos.
InvalidOperationException: … is registered with Nexo but not with the containerUseDbContext<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 longEntidade com chave long num contexto MongoDB.Herde de Entity<Guid> em vez de Entity.
NotSupportedException: Globalization Invariant Mode is not supportedInvariantGlobalization ligado, com SQL Server.Desligue no csproj, e instale icu-libs na imagem Alpine. Veja antes de rodar.
InvalidOperationException: … does not support user-initiated transactionsTransaçã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(…).
PartialCommitExceptionUm 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 existUm 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:

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} (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.

BancoContextoEntidadesPor quê
SQL ServerCatalogDbContextCategory, Product, PriceHistoryIntegridade relacional é o ponto: produto pertence a categoria, histórico pertence a produto.
MySQLPurchasingDbContextSupplier, Shipment, ShipmentLineSegundo banco relacional, para que o sample encare o custo real de um segundo banco.
MongoDBActivityDbContextActivityEntryAppend-only, lido do mais novo para o mais antigo, nunca com join. Genuinamente document-shaped.
Shell
# 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.Api

O 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:

POST /shipments
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 2

E 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:

POST /products
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.