You have climbed the ladder. Every pattern with its payoff and its skip-if. Read in isolation they are a vocabulary; the question a practitioner actually asks is how they fit together in one running thing. So here is one running thing, and it is the same app you have met in every chapter so far.
A customer opens the food-delivery app, builds an order from a restaurant's menu, and taps place. From there the work fans out. The order is written and an OrderPlaced event leaves through the Outbox. A kitchen worker and a courier-assignment worker pick it up. A payment Saga charges the customer and pays out the restaurant and the courier. The customer watches live status climb from placed to delivered. Three sides touch this: the customer, the restaurant (the tenant), and the courier. One slice of the marketplace, assembled.
We will walk it in the order an order flows: in through the API, down to the database, out to the queue, into the workers, through the payment Saga, and back to the customer as live status. At each stop, the pattern doing the work is named where it lives. Nothing here is a toy fragment. It is the shape you would copy.
An order comes in over HTTP. The endpoint does almost nothing itself; it hands the request to a mediator and returns. That thin edge is deliberate. The Mediator (MediatR, Jimmy Bogard) keeps the controller from knowing anything about how an order is stored or queued, and the command/query split means the read side never touches the write side.
app.MapPost("/orders", async (PlaceOrder cmd, IMediator mediator, CancellationToken ct) =>
{
var id = await mediator.Send(cmd, ct);
return Results.Accepted($"/orders/{id}", new { id });
});
app.MapGet("/orders/{id:guid}", async (Guid id, IMediator mediator, CancellationToken ct) =>
await mediator.Send(new GetOrderStatus(id), ct) is { } status
? Results.Ok(status)
: Results.NotFound());The handler is where the persistence altitude shows up. Two patterns share one database transaction: the OrderGateway, a Data Gateway (Fowler, Patterns of Enterprise Application Architecture) that owns the SQL, and the Outbox (Richardson, microservices.io) that records the OrderPlaced intent-to-publish in the same commit. This is the part people get wrong. If you write the order row and then publish to the queue as a separate step, a crash between them either drops the order or duplicates it. The customer is charged for food no kitchen ever sees, or the kitchen cooks an order twice. The Outbox closes that gap by making the message part of the write.
public sealed class PlaceOrderHandler(IOrderGateway gateway) : IRequestHandler<PlaceOrder, Guid>
{
public async Task<Guid> Handle(PlaceOrder cmd, CancellationToken ct)
{
var order = Order.Place(cmd.RestaurantId, cmd.Lines, cmd.Total); // State: starts Placed
await gateway.InsertWithOutbox(order, ct); // Data Gateway + Outbox, one tx
return order.Id;
}
}The menu those Lines came from is a Composite (GoF): sections hold items, items hold modifier-groups, modifier-groups hold modifiers, and a price or an availability flag rolls up the same way at every level. The customer priced the order by walking that tree on the client; the server stores the flat lines it produced. The Composite earns its keep on the read side of the menu, not here, but it is the structure the order references.
The gateway itself is hand-written SQL behind a thin interface, calling a stored procedure through Dapper. No ORM, no change-tracking. The procedure inserts the order and the outbox row inside a single transaction, so either both land or neither does. Tenancy is not passed as a parameter you might forget; the restaurant is the tenant, and tenant_id is set as a session variable that Row-Level Security reads.
public sealed class OrderGateway(IDbConnectionFactory factory, ITenantContext tenant) : IOrderGateway
{
public async Task InsertWithOutbox(Order order, CancellationToken ct)
{
using var conn = await factory.OpenAsync(ct);
using var tx = await conn.BeginTransactionAsync(ct);
await conn.ExecuteAsync(
"select set_config('app.tenant_id', @value, true)",
new { value = tenant.Id.ToString() }, tx); // RLS sees this for the tx
var msgId = Guid.NewGuid();
var payload = JsonSerializer.Serialize(new OrderPlaced(order.Id, order.RestaurantId, order.Total));
await conn.ExecuteAsync("place_order",
new { order.Id, Tenant = order.RestaurantId, order.Total, MsgId = msgId, Payload = payload },
tx, commandType: CommandType.StoredProcedure); // inserts order + outbox row
await tx.CommitAsync(ct);
}
}If the
OrderPlacedmessage isn't written in the same transaction as the order, you don't have a queue. You have a race.
Multitenancy here is the dense default from the persistence chapter: one database, one schema, a tenant_id column on every restaurant-owned table, and a Row-Level Security policy (PostgreSQL RLS) that filters every query to the current tenant. The application sets app.tenant_id once per request; the database enforces isolation whether the developer remembers to filter or not. A query for one restaurant's orders can never return another's, even when the code is wrong. That is the whole point of putting the guarantee in the engine rather than the query.
Download the full PDF for free?
Free download — no account required