Documentation
A complete CRUD
The whole sample, endpoint by endpoint: LINQ with joins and projections, an atomic batch, soft delete, restore and domain events.
The scenario
A catalogue whose entities opted into different traits, spread across three databases. The contrast between the traits is deliberate — it shows what each one turns on and what happens when an entity opts into nothing — and so is the contrast between the databases: no handler in the sample knows which one it is writing to.
| Entity | Database | Traits | Consequence |
|---|---|---|---|
Category | SQL Server | none, just Entity | IDelete really removes; no audit columns. |
Product | SQL Server | AggregateRoot, IHaveAudit, ISoftDeletable | Records events, stamps authorship, and is marked rather than removed. |
PriceHistory | SQL Server | none | Written only by the domain event handler; IDeleteRange really removes. |
Supplier | MySQL | IHaveAudit | Audited, but not deletable: shipments depend on it. |
Shipment and ShipmentLine | MySQL | IHaveAudit / none | Header and lines, written by the same command. The line keeps the product's ExternalId and a snapshot of it: a foreign key across databases does not exist. |
ActivityEntry | MongoDB | Entity<Guid>, IHaveAudit | A document, written by an event handler. A Guid key because Mongo has no long identity. |
Why three: Multiple databases explains the configuration and what it costs. Here the point is the effect — the handler signatures below are the ones you would write with a single database.
# Brings up the three databases, the API and the documentation site
docker compose up --build
# Or: the databases in containers, the API on your machine
docker compose up -d sqlserver mysql mongo
./pack.sh
dotnet run --project sample/Nexo.Sample.Api
# /health says which database is up. In Development, the X-User header sets authorship.
curl -s http://localhost:5080/health | jq
curl -s http://localhost:5080/openapi/v1.json | jq '.paths | keys'How the sample is laid out
Vertical slices: one folder per use case, holding the command or query, the validator, the handler and the endpoint. What changes together stays together — adding a use case is adding a folder, with no existing file edited beyond the route group's list.
Nexo.Sample.Api/
├─ Program.cs # bootstrap, 20 lines
├─ Api/ # IEndpoint + how an endpoint joins the app
│
│ # The domain, with no web and no EF. One folder per database.
├─ Catalog/ # → SQL Server
│ ├─ Categories/Category.cs
│ └─ Products/ # Product, PriceHistory, the 2 events, the exception
├─ Purchasing/ # → MySQL
│ ├─ Suppliers/Supplier.cs
│ └─ Shipments/ # Shipment, ShipmentLine, the exception
├─ Activity/ # → MongoDB
│ └─ ActivityEntry.cs # + PriceChange, the nested document
│
├─ Persistence/ # one DbContext per database, configs alongside
│ ├─ Catalog/ # CatalogDbContext + 3 IEntityTypeConfiguration
│ ├─ Purchasing/ # PurchasingDbContext + 3
│ ├─ Activity/ # ActivityDbContext + 1
│ └─ DatabaseInitializer.cs # EnsureCreated on all three, with retries
├─ Features/
│ ├─ Categories/
│ │ ├─ CategoryEndpoints.cs # the /categories group
│ │ ├─ Contracts/ # the feature's DTOs
│ │ ├─ 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/ # the paged read over somebody else's API
│ ├─ Shipments/
│ └─ Activity/
│ ├─ ActivityRecorder.cs # event handler → MongoDB
│ └─ SearchActivity/ # the paged read over the collection
│
├─ Integrations/ # what this service reads from other services
│ └─ SupplierCatalog/ # typed client + their contract + the stand-in
└─ Infrastructure/
├─ Identity/ # ICurrentUserAccessor over HttpContext
├─ ErrorHandling/ # exception → problem details
└─ DependencyInjection/ # the composition root, by concern128 files, one type per file, the largest 165 lines — and a good part of those are comments. Each handler asks for only what that use case uses, so the constructor documents the use case.
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>
{
// nine dependencies: the union of what the six
// use cases need. None of them needs all of it.
}// 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 use static abstract: an endpoint has no state, so there is nothing to construct and nothing to register in the container. Registration goes through MapEndpoint<T>(), checked by the compiler — no assembly scanning, and a typo is a build error rather than a missing route.
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>();
}
}And Program.cs goes back to being bootstrap: three registration calls, three HTTP pipeline ones.
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();The entities
One file per entity, in a folder per database — Catalog/, Purchasing/, Activity/ — and none of them referencing web or Entity Framework. Nothing on the entity says which database it lives in: that is a context's DbSet, in another file. Start with the one that opts into nothing:
/// <summary>
/// The category, and deliberately the simplest entity in the sample: it opts into no trait
/// at all. No audit columns, no soft delete, no domain events.
/// </summary>
public sealed class Category : Entity
{
public required string Name { get; set; }
}Now the aggregate. State changes go through methods; the setters that do exist belong to the traits, because the interceptor and Entity Framework are what write to them.
public sealed class Product : AggregateRoot, IHaveAudit, ISoftDeletable
{
// Entity Framework materialisation.
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;
// Here, not after the insert: ExternalId already exists, the database key does not.
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>
/// The history row appears because of the event, in the same transaction — this method
/// does not know the table exists.
/// </summary>
public void Reprice(decimal price)
{
if (price == Price)
{
return;
}
var previous = Price;
Price = price;
Raise(new ProductRepriced(ExternalId, previous, price));
}
}/// <summary>
/// Nothing in the products feature writes this directly: the rows appear because the
/// PriceHistoryRecorder reacts to the domain events, inside the same transaction.
/// </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; }
}The events sit next to the entity that raises them, one per file, because it is the entity that defines the contract:
/// <summary>A product changed price. It carries both values, so the handler can audit the 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;
}And the mapping leaves the context: one IEntityTypeConfiguration per entity, in the folder of the database it belongs to — Persistence/Catalog, Persistence/Purchasing, Persistence/Activity. Adding an entity stops meaning editing the DbContext, and each context applies only the configurations sitting beside it.
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);
// Last, so the explicit configuration always wins.
modelBuilder.ApplyNexoConventions();
}
}Create: ICreate plus an event
public async Task<Guid> HandleAsync(CreateProduct command, CancellationToken ct)
{
var category = await categoryByExternalId.ReadRequiredAsync(command.CategoryId, ct);
// Deleted products included: the unique index covers them too.
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)
{
// "already exists" — or "it belongs to a deleted product; restore it": a 409 that explains.
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, then query: the way CQRS intends.
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":"HAM-01","name":"Hammer","price":49.90}'{
"id": "01a0e09d-4067-720d-9c5e-a9a99d3e3bef",
"sku": "HAM-01",
"name": "Hammer",
"price": 49.9,
"category": "Tools",
"createdOn": "2026-09-27T02:06:37.1945954+00:00",
"createdBy": "carlos",
"updatedOn": null,
"updatedBy": null
}The first PriceHistory row already exists at that point — the ProductCreated handler wrote it, in the same transaction. No product code knows the table exists.
Read: LINQ with joins and projections
// One handler per use case: the constructor documents what it needs.
internal sealed class GetProductHandler(
IQueryable<Product> products,
IQueryable<Category> categories)
: IQueryHandler<GetProduct, ProductDetails?>
{
// An explicit join and a projection: the database returns only the columns the API sends.
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>>
{
// Conditional filters, a join by navigation, a correlated subquery, paging.
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": "HAM-01", "price": 59.9, "category": "Garden", "priceChanges": 2 },
{ "sku": "CAN-01", "price": 19.5, "category": "Garden", "priceChanges": 1 }
],
"totalCount": 3,
"number": 1,
"size": 2,
"totalPages": 2,
"hasPrevious": false,
"hasNext": true
}Update: a tracked entity, zero ceremony
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); // records ProductRepriced if the value changed
}Three changes, no call to IUpdate, no SaveChanges. The read by key came back tracked, and Reprice recorded an event whose handler writes a history row — all in one 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":"Claw Hammer","price":59.90}'
curl http://localhost:5080/products/01a0e09d-4067-720d-9c5e-a9a99d3e3bef{
"name": "Claw Hammer",
"price": 59.9,
"category": "Garden",
"createdOn": "2026-09-27T02:06:37.1945954+00:00",
"createdBy": "carlos",
"updatedOn": "2026-09-27T02:06:37.9318008+00:00",
"updatedBy": "maria"
}createdBy is still carlos and updatedBy became maria. Both history rows are there — 49.90 from creation and 59.90 from the repricing.
Batch: ICreateRange, all or nothing
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;
}# Three products, one batch, one transaction.
curl -X POST http://localhost:5080/products/import \
-H 'Content-Type: application/json' \
-d '{"categoryId":"01a0...","products":[
{"sku":"SHE-01","name":"Shears","price":29.9},
{"sku":"CAN-01","name":"Watering can","price":19.5},
{"sku":"GLO-01","name":"Gloves","price":9.9}]}'
# {"imported":3}
# Now with a SKU that already exists, in the middle of the batch:
curl -X POST http://localhost:5080/products/import \
-H 'Content-Type: application/json' \
-d '{"categoryId":"01a0...","products":[
{"sku":"NEW-99","name":"New","price":1},
{"sku":"SHE-01","name":"Duplicate","price":1}]}'
# HTTP 409 — and NEW-99 does not exist in the database.NEW-99 never came into existence. The rollback guaranteed that, not a careful ordering of checks in the handler.
Delete and restore
// Product is ISoftDeletable: it marks and stamps, it does not remove.
public async Task HandleAsync(DeleteProduct command, CancellationToken ct)
{
var product = await productByExternalId.ReadRequiredAsync(command.ProductId, ct);
await deleteProduct.DeleteAsync(product, ct);
}
// The soft delete filter hides the row from every read, so it is read past that filter
// — and only that one — and handed to IRestore, which clears the flag, the instant and
// the author and writes nothing else.
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: it left every read
# The SKU is still taken by the deleted product: a 409 saying it can be restored.
curl -i -X POST http://localhost:5080/products -H 'Content-Type: application/json' -d '{"categoryId":"01a0...","sku":"HAM-01","name":"Another hammer","price":10}'
curl -X POST http://localhost:5080/products/01a0.../restore # 204
curl http://localhost:5080/products/01a0... # backAnd the real delete, on the entity that does not implement ISoftDeletable. The same handler code, different behaviour — the entity is what decides.
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 has no trait: it goes
return rows.Count;
}The event handler that closes the loop
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)
{
// Dispatch happens after the save, so the key already exists.
var product = await productByExternalId.ReadRequiredAsync(productId, ct);
await createPriceHistory.CreateAsync(
new PriceHistory
{
ProductId = product.Id,
Price = price,
RecordedOn = timeProvider.GetUtcNow(),
},
ct);
}
}A second handler listens to the same events and only writes a log line, to show the fan-out. They run in sequence, inside the unit of work of whoever published.
Several entities in one handler
The cases above touch one entity each. It gets interesting when a use case needs several — and that is where the injected operations show what they are for: the constructor becomes the summary of what that handler can do, and nothing beyond it is within reach.
The shipment flow exists in the sample for exactly this. Recording a supplier delivery creates the header and the lines in MySQL, reprices the products in SQL Server — and the repricing raises the event that writes the history in SQL Server and an activity document in MongoDB. Five entity types, three databases, and not one line of the handler names a database.
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>One query for every product in the payload, then a dictionary for the 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)
{
// The product came back tracked from the batch read: this is already the update.
// And Reprice records an event, so the history is written by its
// handler, in this same transaction.
product.Reprice(retailPrice);
}
lines.Add(new ShipmentLine
{
// The navigation, not ShipmentId: the header has not been inserted yet, so
// its key is 0. EF fills the FK in when it saves the graph.
Shipment = shipment,
// The ExternalId plus a snapshot, because the product is in another database
// and a delivery note records what was delivered.
ProductId = product.ExternalId,
ProductSku = product.Sku,
ProductName = product.Name,
Quantity = received.Quantity,
UnitCost = received.UnitCost,
});
}
return lines;
}
}Joining N entities — and where it stops
On the read side, the injected IQueryable<T> is the entity set: it composes like any query, so query syntax with join ... on ... equals works exactly as it would over a DbContext. The difference is that this handler cannot write, cannot save, and says so in its signature.
Inside one database, everything Entity Framework does still applies. This is the sample's GetShipment: header and supplier in MySQL, with the lines nested in the projection — a single query.
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,
// A nested list built inside the projection: EF translates that into a join,
// and no entity is materialised.
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);What the query cannot do is reach into the catalogue for today's price and the category: those rows are in SQL Server, and no join crosses two connections. So the handler does the joining itself — one batched query against the other database, and the stitching in memory.
// One query for every product on the lines, by the keys they kept.
var productIds = header.Lines.Select(l => l.ProductId).Distinct().ToArray();
var current = await (
from product in products
join category in categories on product.CategoryId equals category.Id
where productIds.Contains(product.ExternalId)
select new { product.ExternalId, product.Price, Category = category.Name }
).ToDictionaryAsync(x => x.ExternalId, ct);
// The stitching. A product deleted from the catalogue is simply not in the dictionary, and
// the line goes on showing what was received — which is what the snapshot is for.
var lines = header.Lines
.Select(line =>
{
var catalogued = current.GetValueOrDefault(line.ProductId);
return new ShipmentLineDetails(
line.ProductId,
line.ProductSku,
line.ProductName,
catalogued?.Category ?? "(out of catalogue)",
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();Within a single database, the soft delete filter still slots itself into any level of the query — a nested subquery included. Nobody writes the WHERE NOT IsDeleted: it is part of the model and travels with the entity.
INNER JOIN (
SELECT "p"."Id", "p"."CategoryId", "p"."ExternalId", "p"."Name", "p"."Price", "p"."Sku"
FROM "Products" AS "p"
WHERE NOT ("p"."IsDeleted") -- the soft delete filter slots itself in
) AS "p0" ON ...And the join that stays entirely inside MySQL, aggregating rather than listing — the sum happens in the database and the application never sees a single row:
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);
}The call, and what it returns:
curl -X POST http://localhost:5080/shipments \
-H 'Content-Type: application/json' -H 'X-User: maria' \
-d '{"supplierId":"01a0...","reference":"INV-2026-001","lines":[
{"productId":"01a0...","quantity":10,"unitCost":30.00,"retailPrice":59.90},
{"productId":"01a0...","quantity":25,"unitCost":11.50,"retailPrice":24.90}]}'{
"reference": "INV-2026-001",
"supplierCode": "HW-UK",
"supplierName": "Hardware Supplies Ltd",
"totalCost": 587.5,
"lineCount": 2,
"createdBy": "maria",
"lines": [
{
"sku": "SCR-01", "productName": "Screwdriver", "category": "Tools",
"quantity": 25, "unitCost": 11.5, "lineCost": 287.5,
"currentPrice": 24.9, "marginPercent": 116.52
},
{
"sku": "HAM-01", "productName": "Hammer", "category": "Tools",
"quantity": 10, "unitCost": 30.0, "lineCost": 300.0,
"currentPrice": 59.9, "marginPercent": 99.67
}
]
}The complete map
| Route | Database | Demonstrates |
|---|---|---|
GET /health | all three | Says which database is up, naming each one |
POST /categories | SQL Server | ICreate on an entity with no trait at all |
GET /categories | SQL Server | A correlated subquery in the projection |
PUT /categories/{id} | SQL Server | A tracked entity + a mutation: no IUpdate, no SaveChanges |
DELETE /categories/{id} | SQL Server | A real delete, and a 409 while products remain |
POST /products | SQL Server + MongoDB | ICreate, the first history row through an event, and the activity document the same event writes to the other database |
POST /products/import | SQL Server | An atomic ICreateRange |
GET /products | SQL Server | Conditional filters + join + subquery + paging |
GET /products/{id} | SQL Server | An explicit join and a projection into a record |
PUT /products/{id} | SQL Server + MongoDB | Rename, category move and reprice; the reprice writes history and activity |
DELETE /products/{id} | SQL Server | Soft delete |
POST /products/{id}/restore | SQL Server | The soft delete filter ignored + IRestore |
GET /products/{id}/price-history | SQL Server | Rows written by the event handler |
DELETE /products/{id}/price-history | SQL Server | IDeleteRange with a real removal |
POST /suppliers | MySQL | ICreate on an audited aggregate |
GET /suppliers | MySQL | A count subquery per supplier |
GET /suppliers/offers | none + SQL Server | A page read from another API: their envelope becoming Page<T>, enriched by one query over this page's SKUs, projected with Map — and the paging the caller asked for arriving intact. With [MessageTimeout], because a query that waits on a third party waits as long as it likes |
POST /shipments | all three | Header and lines in MySQL, a product repriced in SQL Server, history in SQL Server and activity in MongoDB — one command, five entity types |
GET /shipments/{id} | MySQL + SQL Server | A join and a nested list inside MySQL, and the in-memory stitching with the catalogue |
GET /shipments | MySQL | A join + an aggregate sum in the database, paged |
GET /activity | MongoDB | LINQ and paging over a collection of documents |