Nexo

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.

EntityDatabaseTraitsConsequence
CategorySQL Servernone, just EntityIDelete really removes; no audit columns.
ProductSQL ServerAggregateRoot, IHaveAudit, ISoftDeletableRecords events, stamps authorship, and is marked rather than removed.
PriceHistorySQL ServernoneWritten only by the domain event handler; IDeleteRange really removes.
SupplierMySQLIHaveAuditAudited, but not deletable: shipments depend on it.
Shipment and ShipmentLineMySQLIHaveAudit / noneHeader 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.
ActivityEntryMongoDBEntity<Guid>, IHaveAuditA 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.

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

Text
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 concern

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

One class, six use cases
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.
}
One handler per use case
// 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.

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>();
    }
}

And Program.cs goes back to being bootstrap: three registration calls, three HTTP pipeline ones.

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();

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:

Catalog/Categories/Category.cs
/// <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.

Catalog/Products/Product.cs
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));
    }

}
Catalog/Products/PriceHistory.cs
/// <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:

Catalog/Products/ProductRepriced.cs
/// <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.

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);

        // Last, so the explicit configuration always wins.
        modelBuilder.ApplyNexoConventions();
    }
}

Create: ICreate plus an event

Features/Products/CreateProduct/CreateProductHandler.cs
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;
}
Features/Products/CreateProduct/CreateProductEndpoint.cs
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);
});
Shell
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}'
JSON
{
  "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

Features/Products/GetProduct/GetProductHandler.cs
// 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);
    }
}
Shell
curl 'http://localhost:5080/products?page=1&size=2&minPrice=10'
JSON
{
  "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

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);   // 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.

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":"Claw Hammer","price":59.90}'

curl http://localhost:5080/products/01a0e09d-4067-720d-9c5e-a9a99d3e3bef
JSON
{
  "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

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

C#
// 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);
}
Shell
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...                # back

And the real delete, on the entity that does not implement ISoftDeletable. The same handler code, different behaviour — the entity is what decides.

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 has no trait: it goes
    return rows.Count;
}

The event handler that closes the loop

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)
    {
        // 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.

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

Features/Shipments/GetShipment/GetShipmentHandler.cs — step 1, in 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,

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

steps 2 and 3 — in SQL Server, and the stitching
// 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.

generated SQL, an excerpt
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:

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);
}

The call, and what it returns:

Shell
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}]}'
JSON
{
  "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

RouteDatabaseDemonstrates
GET /healthall threeSays which database is up, naming each one
POST /categoriesSQL ServerICreate on an entity with no trait at all
GET /categoriesSQL ServerA correlated subquery in the projection
PUT /categories/{id}SQL ServerA tracked entity + a mutation: no IUpdate, no SaveChanges
DELETE /categories/{id}SQL ServerA real delete, and a 409 while products remain
POST /productsSQL Server + MongoDBICreate, the first history row through an event, and the activity document the same event writes to the other database
POST /products/importSQL ServerAn atomic ICreateRange
GET /productsSQL ServerConditional filters + join + subquery + paging
GET /products/{id}SQL ServerAn explicit join and a projection into a record
PUT /products/{id}SQL Server + MongoDBRename, category move and reprice; the reprice writes history and activity
DELETE /products/{id}SQL ServerSoft delete
POST /products/{id}/restoreSQL ServerThe soft delete filter ignored + IRestore
GET /products/{id}/price-historySQL ServerRows written by the event handler
DELETE /products/{id}/price-historySQL ServerIDeleteRange with a real removal
POST /suppliersMySQLICreate on an audited aggregate
GET /suppliersMySQLA count subquery per supplier
GET /suppliers/offersnone + SQL ServerA 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 /shipmentsall threeHeader 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 ServerA join and a nested list inside MySQL, and the in-memory stitching with the catalogue
GET /shipmentsMySQLA join + an aggregate sum in the database, paged
GET /activityMongoDBLINQ and paging over a collection of documents