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.
| Entidade | Banco | Traits | Consequência |
|---|---|---|---|
Category | SQL Server | nenhum, só Entity | IDelete remove de verdade; sem colunas de auditoria. |
Product | SQL Server | AggregateRoot, IHaveAudit, ISoftDeletable | Registra eventos, estampa autoria e é marcada em vez de removida. |
PriceHistory | SQL Server | nenhum | Escrita apenas pelo handler de evento de domínio; IDeleteRange remove de verdade. |
Supplier | MySQL | IHaveAudit | Auditado, mas não apagável: remessas dependem dele. |
Shipment e ShipmentLine | MySQL | IHaveAudit / nenhum | Cabeç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. |
ActivityEntry | MongoDB | Entity<Guid>, IHaveAudit | Documento, 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ó.
# 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.
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 concern128 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.
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.
}// 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.
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.
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:
/// <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.
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));
}
}/// <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:
/// <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.
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
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;
}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);
});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}'{
"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
// 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);
}
}curl 'http://localhost:5080/products?page=1&size=2&minPrice=10'{
"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
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.
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{
"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
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;
}# 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
// 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);
}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... # voltouE o delete real, na entidade que não implementa ISoftDeletable. Mesmo código de handler, comportamento diferente — quem decide é a entidade.
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
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.
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ó.
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.
// 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.
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:
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:
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}]}'{
"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
| Rota | Banco | Demonstra |
|---|---|---|
GET /health | os três | Diz qual banco está de pé, nomeando cada um |
POST /categories | SQL Server | ICreate numa entidade sem trait nenhum |
GET /categories | SQL Server | Subquery correlacionada na projeção |
PUT /categories/{id} | SQL Server | Entidade rastreada + mutação: sem IUpdate, sem SaveChanges |
DELETE /categories/{id} | SQL Server | Delete real, e 409 quando ainda há produtos |
POST /products | SQL Server + MongoDB | ICreate, a primeira linha de histórico via evento, e o documento de atividade que o mesmo evento grava no outro banco |
POST /products/import | SQL Server | ICreateRange atômico |
GET /products | SQL Server | Filtros condicionais + join + subquery + paginação |
GET /products/{id} | SQL Server | Join explícito e projeção em record |
PUT /products/{id} | SQL Server + MongoDB | Rename, troca de categoria e reprice; o reprice grava histórico e atividade |
DELETE /products/{id} | SQL Server | Soft delete |
POST /products/{id}/restore | SQL Server | Filtro de soft delete ignorado + IRestore |
GET /products/{id}/price-history | SQL Server | Linhas escritas pelo handler de evento |
DELETE /products/{id}/price-history | SQL Server | IDeleteRange com remoção real |
POST /suppliers | MySQL | ICreate num agregado auditado |
GET /suppliers | MySQL | Subquery de contagem por fornecedor |
GET /suppliers/offers | nenhum + SQL Server | Pá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 /shipments | os três | Cabeç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 Server | Join e lista aninhada dentro do MySQL, e a costura em memória com o catálogo |
GET /shipments | MySQL | Join + soma agregada no banco, paginado |
GET /activity | MongoDB | LINQ e paginação sobre uma coleção de documentos |