Nexo

Documentação

CRUD completo

O sample inteiro, endpoint por endpoint: LINQ com join e projeção, lote atômico, soft delete, restore e eventos de domínio.

O cenário

Um catálogo com entidades que optaram por traits diferentes, espalhado por três bancos. O contraste dos traits é intencional — mostra o que cada um liga e o que acontece quando a entidade não opta por nada — e o dos bancos também: nenhum handler do sample sabe em qual deles está escrevendo.

EntidadeBancoTraitsConsequência
CategorySQL Servernenhum, só EntityIDelete remove de verdade; sem colunas de auditoria.
ProductSQL ServerAggregateRoot, IHaveAudit, ISoftDeletableRegistra eventos, estampa autoria e é marcada em vez de removida.
PriceHistorySQL ServernenhumEscrita apenas pelo handler de evento de domínio; IDeleteRange remove de verdade.
SupplierMySQLIHaveAuditAuditado, mas não apagável: remessas dependem dele.
Shipment e ShipmentLineMySQLIHaveAudit / nenhumCabeçalho e linhas, escritos pelo mesmo comando. A linha guarda o ExternalId do produto e um snapshot dele: foreign key entre bancos não existe.
ActivityEntryMongoDBEntity<Guid>, IHaveAuditDocumento, escrito por handler de evento. Chave Guid porque o Mongo não tem identidade long.

Por que três: Vários bancos explica a configuração e o que ela custa. Aqui o ponto é o efeito — as assinaturas dos handlers abaixo são as mesmas que seriam com um banco só.

Shell
# Sobe os três bancos, a API e o site de documentação
docker compose up --build

# Ou: os bancos em container, a API na sua máquina
docker compose up -d sqlserver mysql mongo
./pack.sh
dotnet run --project sample/Nexo.Sample.Api

# /health diz qual banco está de pé. Em Development, o header X-User define a autoria.
curl -s http://localhost:5080/health | jq
curl -s http://localhost:5080/openapi/v1.json | jq '.paths | keys'

Como o sample está organizado

Vertical slices: uma pasta por caso de uso, contendo o comando ou query, o validator, o handler e o endpoint. O que muda junto fica junto — adicionar um caso de uso é adicionar uma pasta, sem editar nenhum arquivo existente além da lista do grupo de rotas.

Texto
Nexo.Sample.Api/
├─ Program.cs                       # bootstrap, 20 linhas
├─ Api/                             # IEndpoint + como um endpoint entra no app
│
│  # O domínio, sem web e sem EF. Uma pasta por banco.
├─ Catalog/                         # → SQL Server
│  ├─ Categories/Category.cs
│  └─ Products/                     # Product, PriceHistory, os 2 eventos, a exceção
├─ Purchasing/                      # → MySQL
│  ├─ Suppliers/Supplier.cs
│  └─ Shipments/                    # Shipment, ShipmentLine, a exceção
├─ Activity/                        # → MongoDB
│  └─ ActivityEntry.cs              # + PriceChange, o documento aninhado
│
├─ Persistence/                     # um DbContext por banco, com as configs ao lado
│  ├─ Catalog/                      # CatalogDbContext + 3 IEntityTypeConfiguration
│  ├─ Purchasing/                   # PurchasingDbContext + 3
│  ├─ Activity/                     # ActivityDbContext + 1
│  └─ DatabaseInitializer.cs        # EnsureCreated nos três, com retry
├─ Features/
│  ├─ Categories/
│  │  ├─ CategoryEndpoints.cs       # o grupo /categories
│  │  ├─ Contracts/                 # os DTOs da feature
│  │  ├─ ListCategories/            # query + handler + endpoint
│  │  ├─ CreateCategory/            # command + validator + handler + endpoint
│  │  ├─ RenameCategory/
│  │  └─ DeleteCategory/
│  ├─ Products/
│  │  ├─ ProductEndpoints.cs
│  │  ├─ Contracts/
│  │  ├─ SearchProducts/  GetProduct/  GetPriceHistory/
│  │  ├─ CreateProduct/  ImportProducts/  UpdateProduct/
│  │  ├─ DeleteProduct/  RestoreProduct/  PurgePriceHistory/
│  │  └─ PriceTracking/           # PriceHistoryRecorder → SQL Server
│  ├─ Suppliers/
│  │  ├─ ListSuppliers/  CreateSupplier/
│  │  └─ SearchSupplierOffers/     # a leitura paginada sobre a API de outro
│  ├─ Shipments/
│  └─ Activity/
│     ├─ ActivityRecorder.cs       # handler de evento → MongoDB
│     └─ SearchActivity/           # a leitura paginada da coleção
│
├─ Integrations/                    # o que este serviço lê de outros serviços
│  └─ SupplierCatalog/              # typed client + o contrato deles + o stand-in
└─ Infrastructure/
   ├─ Identity/                     # ICurrentUserAccessor sobre HttpContext
   ├─ ErrorHandling/                # exceção → problem details
   └─ DependencyInjection/          # a composition root, por concern

128 arquivos, um tipo por arquivo, maior arquivo com 165 linhas — e boa parte delas é comentário. Cada handler pede só o que aquele caso de uso usa, então o construtor documenta o caso de uso.

Uma classe, seis casos de uso
public sealed class ProductCommandHandlers(
    ICreate<Product> createProduct,
    ICreateRange<Product> createProducts,
    IUpdate<Product> updateProduct,
    IDelete<Product> deleteProduct,
    IDeleteRange<PriceHistory> deletePriceHistory,
    IReadByExternalId<Product> productByExternalId,
    IReadByExternalId<Category> categoryByExternalId,
    IQueryable<Product> products,
    IQueryable<PriceHistory> priceHistory)
    : ICommandHandler<CreateProduct, Guid>,
      ICommandHandler<UpdateProduct>,
      ICommandHandler<DeleteProduct>,
      ICommandHandler<RestoreProduct>,
      ICommandHandler<ImportProducts, int>,
      ICommandHandler<PurgePriceHistory, int>
{
    // nove dependências: a união do que os seis
    // casos de uso precisam. Nenhum precisa de todas.
}
Um handler por caso de uso
// Features/Products/DeleteProduct/DeleteProductHandler.cs
internal sealed class DeleteProductHandler(
    IDelete<Product> delete,
    IReadByExternalId<Product> byExternalId)
    : ICommandHandler<DeleteProduct>
{
    public async Task HandleAsync(DeleteProduct command, CancellationToken ct)
    {
        var product = await byExternalId.ReadRequiredAsync(command.ProductId, ct);

        await delete.DeleteAsync(product, ct);
    }
}

// Features/Categories/RenameCategory/RenameCategoryHandler.cs
internal sealed class RenameCategoryHandler(IReadByExternalId<Category> byExternalId)
    : ICommandHandler<RenameCategory>
{
    public async Task HandleAsync(RenameCategory command, CancellationToken ct)
    {
        var category = await byExternalId.ReadRequiredAsync(command.CategoryId, ct);

        category.Name = command.Name;
    }
}

Endpoints usam static abstract: um endpoint não tem estado, então não há o que construir nem registrar no container. O registro passa por MapEndpoint<T>(), verificado pelo compilador — sem varredura de assembly, e um erro de digitação é erro de build em vez de rota faltando.

Features/Products/ProductEndpoints.cs
internal static class ProductEndpoints
{
    public static void Map(IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/products").WithTags("Products");

        group
            .MapEndpoint<SearchProductsEndpoint>()
            .MapEndpoint<GetProductEndpoint>()
            .MapEndpoint<GetPriceHistoryEndpoint>()
            .MapEndpoint<CreateProductEndpoint>()
            .MapEndpoint<ImportProductsEndpoint>()
            .MapEndpoint<UpdateProductEndpoint>()
            .MapEndpoint<DeleteProductEndpoint>()
            .MapEndpoint<RestoreProductEndpoint>()
            .MapEndpoint<PurgePriceHistoryEndpoint>();
    }
}

E o Program.cs volta a ser bootstrap: três chamadas de registro, três de pipeline HTTP.

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddCatalogPersistence(builder.Configuration)
    .AddCatalogFeatures(builder.Configuration)
    .AddCatalogIntegrations(builder.Configuration)
    .AddCatalogWeb();

var app = builder.Build();

app.UseExceptionHandler();
app.MapOpenApi();
app.MapCatalogEndpoints();

await using (var scope = app.Services.CreateAsyncScope())
{
    await scope.ServiceProvider
        .GetRequiredService<CatalogDbContext>()
        .Database.EnsureCreatedAsync();
}

await app.RunAsync();

As entidades

Um arquivo por entidade, numa pasta por banco — Catalog/, Purchasing/, Activity/ — e nenhuma delas com referência a web ou a Entity Framework. Nada na entidade diz em que banco ela vive: isso é o DbSet de um contexto, em outro arquivo. Comece pela que opta por nada:

Catalog/Categories/Category.cs
/// <summary>
/// A categoria, e de propósito a entidade mais simples do sample: ela não opta por nenhum
/// trait. Sem colunas de auditoria, sem soft delete, sem eventos de domínio.
/// </summary>
public sealed class Category : Entity
{
    public required string Name { get; set; }
}

Agora o agregado. As mudanças de estado passam por métodos; os setters que existem pertencem aos traits, porque é o interceptor e o Entity Framework que escrevem neles.

Catalog/Products/Product.cs
public sealed class Product : AggregateRoot, IHaveAudit, ISoftDeletable
{
    // Materialização do Entity Framework.
    private Product()
    {
        Sku = string.Empty;
        Name = string.Empty;
    }

    public Product(long categoryId, string sku, string name, decimal price)
    {
        CategoryId = categoryId;
        Sku = sku;
        Name = name;
        Price = price;

        // Aqui, não depois do insert: ExternalId já existe, a chave do banco não.
        Raise(new ProductCreated(ExternalId, sku, price));
    }

    public long CategoryId { get; private set; }
    public Category? Category { get; private set; }
    public string Sku { get; private set; }
    public string Name { get; private set; }
    public decimal Price { get; private set; }

    public DateTimeOffset CreatedOn { get; set; }
    public DateTimeOffset? UpdatedOn { get; set; }
    public string? CreatedBy { get; set; }
    public string? UpdatedBy { get; set; }

    public bool IsDeleted { get; set; }
    public DateTimeOffset? DeletedOn { get; set; }
    public string? DeletedBy { get; set; }

    public void Rename(string name) => Name = name;

    public void MoveTo(long categoryId) => CategoryId = categoryId;

    /// <summary>
    /// A linha de histórico aparece por causa do evento, na mesma transação — este método
    /// não sabe que a tabela existe.
    /// </summary>
    public void Reprice(decimal price)
    {
        if (price == Price)
        {
            return;
        }

        var previous = Price;
        Price = price;
        Raise(new ProductRepriced(ExternalId, previous, price));
    }

}
Catalog/Products/PriceHistory.cs
/// <summary>
/// Nada na feature de produtos escreve isto diretamente: as linhas aparecem porque o
/// PriceHistoryRecorder reage aos eventos de domínio, dentro da mesma transação.
/// </summary>
public sealed class PriceHistory : Entity<long>
{
    public long ProductId { get; set; }
    public Product? Product { get; set; }
    public decimal Price { get; set; }
    public DateTimeOffset RecordedOn { get; set; }
}

Os eventos ficam ao lado da entidade que os dispara, um por arquivo, porque é ela quem define o contrato:

Catalog/Products/ProductRepriced.cs
/// <summary>Um produto mudou de preço. Carrega os dois valores, para o handler auditar o delta.</summary>
public sealed class ProductRepriced(Guid productId, decimal from, decimal to) : Event
{
    public Guid ProductId { get; } = productId;
    public decimal From { get; } = from;
    public decimal To { get; } = to;
}

E o mapeamento sai do contexto: um IEntityTypeConfiguration por entidade, na pasta do banco a que ela pertence — Persistence/Catalog, Persistence/Purchasing, Persistence/Activity. Adicionar uma entidade deixa de significar editar o DbContext, e cada contexto aplica só as configurações ao lado dele.

Persistence/CatalogDbContext.cs
public sealed class CatalogDbContext(DbContextOptions<CatalogDbContext> options)
    : DbContext(options)
{
    public DbSet<Category> Categories => Set<Category>();
    public DbSet<Product> Products => Set<Product>();
    public DbSet<PriceHistory> PriceHistory => Set<PriceHistory>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly);

        // Por último, para que a configuração explícita sempre ganhe.
        modelBuilder.ApplyNexoConventions();
    }
}

Create: ICreate mais um evento

Features/Products/CreateProduct/CreateProductHandler.cs
public async Task<Guid> HandleAsync(CreateProduct command, CancellationToken ct)
{
    var category = await categoryByExternalId.ReadRequiredAsync(command.CategoryId, ct);

    // Produtos apagados incluídos: o índice único vale para eles também.
    var holder = await products
        .IgnoreQueryFilters([NexoQueryFilters.SoftDelete])
        .Where(p => p.Sku == command.Sku)
        .Select(p => new { p.IsDeleted })
        .FirstOrDefaultAsync(ct);

    if (holder is not null)
    {
        // "já existe" — ou "é de um produto apagado; restaure-o": um 409 que explica.
        throw new DuplicateSkuException(command.Sku, holder.IsDeleted);
    }

    var product = await createProduct.CreateAsync(
        new Product(category.Id, command.Sku, command.Name, command.Price),
        ct);

    return product.ExternalId;
}
Features/Products/CreateProduct/CreateProductEndpoint.cs
products.MapPost("/", async (CreateProduct command, IMediator mediator, CancellationToken ct) =>
{
    // Command, depois query: como CQRS pretende.
    var id = await mediator.SendAsync(command, ct);
    var product = await mediator.FetchAsync(new GetProduct { ProductId = id }, ct);

    return Results.Created($"/products/{id}", product);
});
Shell
curl -X POST http://localhost:5080/products \
  -H 'Content-Type: application/json' \
  -H 'X-User: carlos' \
  -d '{"categoryId":"01a0...","sku":"MART-01","name":"Martelo","price":49.90}'
JSON
{
  "id": "01a0e09d-4067-720d-9c5e-a9a99d3e3bef",
  "sku": "MART-01",
  "name": "Martelo",
  "price": 49.9,
  "category": "Ferramentas",
  "createdOn": "2026-09-27T02:06:37.1945954+00:00",
  "createdBy": "carlos",
  "updatedOn": null,
  "updatedBy": null
}

A primeira linha de PriceHistory já existe nesse ponto — foi o handler de ProductCreated que a escreveu, na mesma transação. Nenhum código de produto sabe que a tabela existe.

Read: LINQ com join e projeção

Features/Products/GetProduct/GetProductHandler.cs
// Um handler por caso de uso: o construtor documenta o que ele precisa.
internal sealed class GetProductHandler(
    IQueryable<Product> products,
    IQueryable<Category> categories)
    : IQueryHandler<GetProduct, ProductDetails?>
{
    // Join explícito e projeção: o banco devolve só as colunas que a API envia.
    public Task<ProductDetails?> HandleAsync(GetProduct query, CancellationToken ct) =>
        products
            .Where(p => p.ExternalId == query.ProductId)
            .Join(
                categories,
                product => product.CategoryId,
                category => category.Id,
                (product, category) => new ProductDetails(
                    product.ExternalId,
                    product.Sku,
                    product.Name,
                    product.Price,
                    category.ExternalId,
                    category.Name,
                    product.CreatedOn,
                    product.CreatedBy,
                    product.UpdatedOn,
                    product.UpdatedBy))
            .FirstOrDefaultAsync(ct);
}

// Features/Products/SearchProducts/SearchProductsHandler.cs
internal sealed class SearchProductsHandler(
    IQueryable<Product> products,
    IQueryable<PriceHistory> priceHistory)
    : IQueryHandler<SearchProducts, Page<ProductSummary>>
{
    // Filtros condicionais, join por navegação, subquery correlacionada, paginação.
    public Task<Page<ProductSummary>> HandleAsync(SearchProducts query, CancellationToken ct)
    {
        var filtered = products;

        if (!string.IsNullOrWhiteSpace(query.Term))
        {
            var term = query.Term.Trim();
            filtered = filtered.Where(p => p.Sku.Contains(term) || p.Name.Contains(term));
        }

        if (query.CategoryId is { } categoryId)
        {
            filtered = filtered.Where(p => p.Category!.ExternalId == categoryId);
        }

        if (query.MinPrice is { } minPrice)
        {
            filtered = filtered.Where(p => p.Price >= minPrice);
        }

        return filtered
            .OrderBy(p => p.Sku)
            .Select(p => new ProductSummary(
                p.ExternalId,
                p.Sku,
                p.Name,
                p.Price,
                p.Category!.Name,
                priceHistory.Count(h => h.ProductId == p.Id)))   // subquery
            .ToPageAsync(query.Request, ct);
    }
}
Shell
curl 'http://localhost:5080/products?page=1&size=2&minPrice=10'
JSON
{
  "items": [
    { "sku": "MART-01", "price": 59.9, "category": "Jardim", "priceChanges": 2 },
    { "sku": "REG-01",  "price": 19.5, "category": "Jardim", "priceChanges": 1 }
  ],
  "totalCount": 3,
  "number": 1,
  "size": 2,
  "totalPages": 2,
  "hasPrevious": false,
  "hasNext": true
}

Update: entidade rastreada, zero cerimônia

C#
public async Task HandleAsync(UpdateProduct command, CancellationToken ct)
{
    var product = await productByExternalId.ReadRequiredAsync(command.ProductId, ct);
    var category = await categoryByExternalId.ReadRequiredAsync(command.CategoryId, ct);

    product.Rename(command.Name);
    product.MoveTo(category.Id);
    product.Reprice(command.Price);   // registra ProductRepriced se o valor mudou
}

Três mudanças, nenhuma chamada a IUpdate, nenhum SaveChanges. A leitura por chave veio rastreada, e o Reprice registrou um evento cujo handler grava uma linha de histórico — tudo num commit.

Shell
curl -X PUT http://localhost:5080/products/01a0e09d-4067-720d-9c5e-a9a99d3e3bef \
  -H 'Content-Type: application/json' \
  -H 'X-User: maria' \
  -d '{"categoryId":"01a0...","name":"Martelo de Unha","price":59.90}'

curl http://localhost:5080/products/01a0e09d-4067-720d-9c5e-a9a99d3e3bef
JSON
{
  "name": "Martelo de Unha",
  "price": 59.9,
  "category": "Jardim",
  "createdOn": "2026-09-27T02:06:37.1945954+00:00",
  "createdBy": "carlos",
  "updatedOn": "2026-09-27T02:06:37.9318008+00:00",
  "updatedBy": "maria"
}

createdBy continua carlos e updatedBy virou maria. As duas linhas de histórico estão lá — 49,90 do cadastro e 59,90 do reajuste.

Lote: ICreateRange, tudo ou nada

C#
public async Task<int> HandleAsync(ImportProducts command, CancellationToken ct)
{
    var category = await categoryByExternalId.ReadRequiredAsync(command.CategoryId, ct);

    var incoming = command.Products.Select(p => p.Sku).ToArray();
    var clashing = await products
        .IgnoreQueryFilters([NexoQueryFilters.SoftDelete])
        .Where(p => incoming.Contains(p.Sku))
        .Select(p => new { p.Sku, p.IsDeleted })
        .FirstOrDefaultAsync(ct);

    if (clashing is not null)
    {
        throw new DuplicateSkuException(clashing.Sku, clashing.IsDeleted);
    }

    var created = await createProducts.CreateAsync(
        command.Products.Select(p => new Product(category.Id, p.Sku, p.Name, p.Price)),
        ct);

    return created.Count;
}
Shell
# Três produtos, um batch, uma transação.
curl -X POST http://localhost:5080/products/import \
  -H 'Content-Type: application/json' \
  -d '{"categoryId":"01a0...","products":[
        {"sku":"TES-01","name":"Tesoura","price":29.9},
        {"sku":"REG-01","name":"Regador","price":19.5},
        {"sku":"LUV-01","name":"Luvas","price":9.9}]}'
# {"imported":3}

# Agora com um SKU que já existe no meio do lote:
curl -X POST http://localhost:5080/products/import \
  -H 'Content-Type: application/json' \
  -d '{"categoryId":"01a0...","products":[
        {"sku":"NOVO-99","name":"Novo","price":1},
        {"sku":"TES-01","name":"Duplicado","price":1}]}'
# HTTP 409 — e NOVO-99 não existe no banco.

O NOVO-99 nunca chegou a existir. Foi o rollback que garantiu isso, não uma ordem cuidadosa de verificações no handler.

Delete e restore

C#
// Product é ISoftDeletable: marca e estampa, não remove.
public async Task HandleAsync(DeleteProduct command, CancellationToken ct)
{
    var product = await productByExternalId.ReadRequiredAsync(command.ProductId, ct);
    await deleteProduct.DeleteAsync(product, ct);
}

// O filtro de soft delete esconde a linha de toda leitura, então ela é lida por
// cima dele — e só dele — e entregue ao IRestore, que limpa a marca, o instante e
// o autor e não grava mais nada.
public async Task HandleAsync(RestoreProduct command, CancellationToken ct)
{
    var product = await products
                      .IgnoreQueryFilters([NexoQueryFilters.SoftDelete])
                      .FirstOrDefaultAsync(p => p.ExternalId == command.ProductId, ct)
                  ?? throw new EntityNotFoundException(typeof(Product), command.ProductId);

    await restore.RestoreAsync(product, ct);
}
Shell
curl -X DELETE http://localhost:5080/products/01a0...      # 204
curl -i http://localhost:5080/products/01a0...            # 404: saiu de todas as leituras

# O SKU continua ocupado pelo produto apagado: 409, dizendo que dá para restaurar.
curl -i -X POST http://localhost:5080/products -H 'Content-Type: application/json'   -d '{"categoryId":"01a0...","sku":"MART-01","name":"Outro martelo","price":10}'

curl -X POST http://localhost:5080/products/01a0.../restore  # 204
curl http://localhost:5080/products/01a0...                # voltou

E o delete real, na entidade que não implementa ISoftDeletable. Mesmo código de handler, comportamento diferente — quem decide é a entidade.

C#
public async Task<int> HandleAsync(PurgePriceHistory command, CancellationToken ct)
{
    var product = await productByExternalId.ReadRequiredAsync(command.ProductId, ct);

    var rows = await priceHistory.Where(h => h.ProductId == product.Id).ToListAsync(ct);

    await deletePriceHistory.DeleteAsync(rows, ct);   // PriceHistory não tem trait: vai embora
    return rows.Count;
}

O handler de evento que fecha o ciclo

Features/Products/PriceTracking/PriceHistoryRecorder.cs
internal sealed class PriceHistoryRecorder(
    ICreate<PriceHistory> createPriceHistory,
    IReadByExternalId<Product> productByExternalId,
    TimeProvider timeProvider)
    : IEventHandler<ProductCreated>, IEventHandler<ProductRepriced>
{
    public Task HandleAsync(ProductCreated @event, CancellationToken ct) =>
        RecordAsync(@event.ProductId, @event.Price, ct);

    public Task HandleAsync(ProductRepriced @event, CancellationToken ct) =>
        RecordAsync(@event.ProductId, @event.To, ct);

    private async Task RecordAsync(Guid productId, decimal price, CancellationToken ct)
    {
        // O despacho acontece depois do save, então a chave já existe.
        var product = await productByExternalId.ReadRequiredAsync(productId, ct);

        await createPriceHistory.CreateAsync(
            new PriceHistory
            {
                ProductId = product.Id,
                Price = price,
                RecordedOn = timeProvider.GetUtcNow(),
            },
            ct);
    }
}

Um segundo handler escuta os mesmos eventos e só registra em log, para mostrar o fan-out. Rodam em sequência, dentro do unit of work de quem publicou.

Várias entidades num handler só

Os casos acima tocam uma entidade cada. O interessante começa quando um caso de uso precisa de várias — e é aí que as operações injetadas mostram para que servem: o construtor vira o resumo do que aquele handler pode fazer, e nada além disso está ao alcance.

O fluxo de remessa existe no sample para isso. Registrar uma entrega de fornecedor cria o cabeçalho e as linhas no MySQL, reajusta o preço dos produtos no SQL Server — e o reajuste dispara o evento que grava o histórico no SQL Server e um documento de atividade no MongoDB. Cinco tipos de entidade, três bancos, e nenhuma linha do handler nomeia um banco.

Features/Shipments/ReceiveShipment/ReceiveShipmentHandler.cs
internal sealed partial class ReceiveShipmentHandler(
    ILogger<ReceiveShipmentHandler> logger,
    IQueryable<Shipment> shipments,
    IReadByExternalId<Supplier> supplierByExternalId,
    IReadByExternalIdRange<Product> productsByExternalId,
    ICreate<Shipment> createShipment,
    ICreateRange<ShipmentLine> createLines,
    TimeProvider timeProvider)
    : ICommandHandler<ReceiveShipment, Guid>
{
    public async Task<Guid> HandleAsync(ReceiveShipment command, CancellationToken ct)
    {
        var supplier = await supplierByExternalId.ReadRequiredAsync(command.SupplierId, ct);

        await EnsureReferenceIsFreeAsync(supplier, command.Reference, ct);

        var products = await ReadProductsAsync(command, ct);

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

        var lines = BuildLines(command, shipment, products);

        await createLines.CreateAsync(lines, ct);

        shipment.Summarize(lines.Sum(l => l.LineCost), lines.Count);

        ShipmentReceived(supplier.Code, command.Reference, lines.Count);

        return shipment.ExternalId;
    }

    /// <summary>Uma query para todos os produtos do payload, depois um dicionário para o lookup.</summary>
    private async Task<Dictionary<Guid, Product>> ReadProductsAsync(
        ReceiveShipment command,
        CancellationToken ct)
    {
        var requested = command.Lines.Select(l => l.ProductId).ToArray();

        var found = await productsByExternalId.ReadAsync(requested, ct);
        var byExternalId = found.ToDictionary(p => p.ExternalId);

        var missing = requested.FirstOrDefault(id => !byExternalId.ContainsKey(id));
        if (missing != Guid.Empty)
        {
            throw new EntityNotFoundException(typeof(Product), missing);
        }

        return byExternalId;
    }

    private static List<ShipmentLine> BuildLines(
        ReceiveShipment command,
        Shipment shipment,
        Dictionary<Guid, Product> products)
    {
        var lines = new List<ShipmentLine>(command.Lines.Count);

        foreach (var received in command.Lines)
        {
            var product = products[received.ProductId];

            if (received.RetailPrice is { } retailPrice)
            {
                // O produto voltou rastreado da leitura em lote: isto já é o update.
                // E Reprice registra um evento, então o histórico é escrito pelo
                // handler dele, nesta mesma transação.
                product.Reprice(retailPrice);
            }

            lines.Add(new ShipmentLine
            {
                // A navegação, não ShipmentId: o cabeçalho ainda não foi inserido, então
                // a chave dele é 0. O EF preenche a FK quando salva o grafo.
                Shipment = shipment,

                // O ExternalId e um snapshot, porque o produto está em outro banco e
                // uma nota de entrega registra o que foi entregue.
                ProductId = product.ExternalId,
                ProductSku = product.Sku,
                ProductName = product.Name,

                Quantity = received.Quantity,
                UnitCost = received.UnitCost,
            });
        }

        return lines;
    }
}

Join entre N entidades — e onde ele para

Do lado da leitura, o IQueryable<T> injetado é o conjunto de entidades: compõe como qualquer query, então query syntax com join ... on ... equals funciona exatamente como funcionaria sobre um DbContext. A diferença é que este handler não consegue escrever, não consegue salvar, e diz isso na assinatura.

Dentro de um banco, tudo que o Entity Framework faz continua valendo. Este é o GetShipment do sample: cabeçalho e fornecedor no MySQL, com as linhas aninhadas na projeção — uma consulta só.

Features/Shipments/GetShipment/GetShipmentHandler.cs — passo 1, no MySQL
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.Id,
        shipment.ExternalId,
        shipment.Reference,
        shipment.ReceivedOn,
        shipment.TotalCost,
        shipment.LineCount,
        shipment.CreatedBy,
        SupplierId = supplier.ExternalId,
        SupplierCode = supplier.Code,
        SupplierName = supplier.Name,

        // Lista aninhada montada dentro da projeção: o EF traduz isso para um join,
        // e nenhuma entidade é materializada.
        Lines = (
            from line in shipmentLines
            where line.ShipmentId == shipment.Id
            orderby line.ProductSku
            select new { line.ProductId, line.ProductSku, line.ProductName, line.Quantity, line.UnitCost }
        ).ToList(),
    }
).FirstOrDefaultAsync(ct);

O que a query não pode fazer é alcançar o catálogo para o preço de hoje e a categoria: essas linhas estão no SQL Server, e nenhum join cruza duas conexões. Então o handler faz a junção ele mesmo — uma query em lote no outro banco, e a costura em memória.

passo 2 e 3 — no SQL Server, e a costura
// Uma query para todos os produtos das linhas, pelas chaves que elas 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);

// A costura. Um produto apagado do catálogo simplesmente não está no dicionário, e a
// linha continua mostrando o que foi recebido — que é para isso que o snapshot existe.
var lines = header.Lines
    .Select(line =>
    {
        var catalogued = current.GetValueOrDefault(line.ProductId);

        return new ShipmentLineDetails(
            line.ProductId,
            line.ProductSku,
            line.ProductName,
            catalogued?.Category ?? "(fora do catálogo)",
            line.Quantity,
            line.UnitCost,
            line.Quantity * line.UnitCost,
            catalogued?.Price ?? 0m,
            line.UnitCost == 0 || catalogued is null
                ? 0
                : Math.Round((catalogued.Price - line.UnitCost) / line.UnitCost * 100, 2));
    })
    .ToList();

Dentro de um banco só, o filtro de soft delete continua entrando sozinho em qualquer nível da consulta — inclusive dentro de uma subconsulta aninhada. Ninguém escreve o WHERE NOT IsDeleted: ele é parte do modelo e acompanha a entidade.

SQL gerado, trecho
INNER JOIN (
    SELECT "p"."Id", "p"."CategoryId", "p"."ExternalId", "p"."Name", "p"."Price", "p"."Sku"
    FROM "Products" AS "p"
    WHERE NOT ("p"."IsDeleted")      -- o filtro de soft delete entra sozinho
) AS "p0" ON ...

E a junção que fica inteira dentro do MySQL, agregando em vez de listar — a soma acontece no banco e a aplicação não vê uma linha sequer:

Features/Shipments/SearchShipments/SearchShipmentsHandler.cs
public Task<Page<ShipmentSummary>> HandleAsync(SearchShipments query, CancellationToken ct)
{
    var filtered =
        from shipment in shipments
        join supplier in suppliers
            on shipment.SupplierId equals supplier.Id
        select new { shipment, supplier };

    if (query.SupplierId is { } supplierId)
    {
        filtered = filtered.Where(x => x.supplier.ExternalId == supplierId);
    }

    if (!string.IsNullOrWhiteSpace(query.Reference))
    {
        var reference = query.Reference.Trim();
        filtered = filtered.Where(x => x.shipment.Reference.Contains(reference));
    }

    return filtered
        .OrderByDescending(x => x.shipment.Id)
        .Select(x => new ShipmentSummary(
            x.shipment.ExternalId,
            x.shipment.Reference,
            x.shipment.ReceivedOn,
            x.supplier.Name,
            x.shipment.LineCount,
            shipmentLines
                .Where(l => l.ShipmentId == x.shipment.Id)
                .Sum(l => (int?)l.Quantity) ?? 0,
            x.shipment.TotalCost))
        .ToPageAsync(query.Request, ct);
}

A chamada e o que ela devolve:

Shell
curl -X POST http://localhost:5080/shipments \
  -H 'Content-Type: application/json' -H 'X-User: maria' \
  -d '{"supplierId":"01a0...","reference":"NF-2026-001","lines":[
        {"productId":"01a0...","quantity":10,"unitCost":30.00,"retailPrice":59.90},
        {"productId":"01a0...","quantity":25,"unitCost":11.50,"retailPrice":24.90}]}'
JSON
{
  "reference": "NF-2026-001",
  "supplierCode": "FER-BR",
  "supplierName": "Ferragens Brasil Ltda",
  "totalCost": 587.5,
  "lineCount": 2,
  "createdBy": "maria",
  "lines": [
    {
      "sku": "CHAV-01", "productName": "Chave de fenda", "category": "Ferramentas",
      "quantity": 25, "unitCost": 11.5, "lineCost": 287.5,
      "currentPrice": 24.9, "marginPercent": 116.52
    },
    {
      "sku": "MART-01", "productName": "Martelo", "category": "Ferramentas",
      "quantity": 10, "unitCost": 30.0, "lineCost": 300.0,
      "currentPrice": 59.9, "marginPercent": 99.67
    }
  ]
}

O mapa completo

RotaBancoDemonstra
GET /healthos trêsDiz qual banco está de pé, nomeando cada um
POST /categoriesSQL ServerICreate numa entidade sem trait nenhum
GET /categoriesSQL ServerSubquery correlacionada na projeção
PUT /categories/{id}SQL ServerEntidade rastreada + mutação: sem IUpdate, sem SaveChanges
DELETE /categories/{id}SQL ServerDelete real, e 409 quando ainda há produtos
POST /productsSQL Server + MongoDBICreate, a primeira linha de histórico via evento, e o documento de atividade que o mesmo evento grava no outro banco
POST /products/importSQL ServerICreateRange atômico
GET /productsSQL ServerFiltros condicionais + join + subquery + paginação
GET /products/{id}SQL ServerJoin explícito e projeção em record
PUT /products/{id}SQL Server + MongoDBRename, troca de categoria e reprice; o reprice grava histórico e atividade
DELETE /products/{id}SQL ServerSoft delete
POST /products/{id}/restoreSQL ServerFiltro de soft delete ignorado + IRestore
GET /products/{id}/price-historySQL ServerLinhas escritas pelo handler de evento
DELETE /products/{id}/price-historySQL ServerIDeleteRange com remoção real
POST /suppliersMySQLICreate num agregado auditado
GET /suppliersMySQLSubquery de contagem por fornecedor
GET /suppliers/offersnenhum + SQL ServerPágina vinda de outra API: o envelope deles virando Page<T>, enriquecido com uma query só pelas SKUs daquela página, projetado com Map — e a paginação que o chamador pediu chegando intacta. Com [MessageTimeout], porque query que espera terceiro espera o quanto ele quiser
POST /shipmentsos trêsCabeçalho e linhas no MySQL, reajuste de produto no SQL Server, histórico no SQL Server e atividade no MongoDB — um comando, cinco tipos de entidade
GET /shipments/{id}MySQL + SQL ServerJoin e lista aninhada dentro do MySQL, e a costura em memória com o catálogo
GET /shipmentsMySQLJoin + soma agregada no banco, paginado
GET /activityMongoDBLINQ e paginação sobre uma coleção de documentos