The problem: business logic that imports the database, the message broker, and the HTTP client is a knot you can't test in isolation or move to a new dependency.
Ports and Adapters (Cockburn, 2005, also called Hexagonal) draws one line. Inside it: your order domain, which defines ports, the interfaces it needs (IOrderGateway, IPaymentAdapter, IMapsService). Outside it: adapters, the concrete implementations that talk to the database, the payment provider, and the mapping API. The domain depends only on its own ports. Everything technical points inward, which is Uncle Bob's Dependency Inversion again at the level of a whole service.
// Inside the hexagon: a port the order domain owns.
public interface IPaymentAdapter { Task<PaymentResult> Charge(Money amount, CardToken card, CancellationToken ct); }
// Outside: an adapter the infrastructure layer provides.
public sealed class StripePaymentAdapter(StripeClient client) : IPaymentAdapter
{
public Task<PaymentResult> Charge(Money amount, CardToken card, CancellationToken ct) => /* Stripe call */;
}What it buys you in production: the order domain has no idea whether a charge goes to Stripe, Adyen, or an in-memory fake. You test order placement with fakes and never spin up a payment sandbox or a maps API. The day a new market needs a different payment provider, you write a new adapter and the domain doesn't notice.
Keep it minimal. The skip-if here is the ceremony, not the idea. You do not need the full Onion or Clean Architecture stack of four concentric layers, a separate assembly per ring, and a mapping class between every pair. For one service, the pattern is a folder of interfaces the domain owns and a folder of adapters that implement them. Add a ring only when a ring earns its keep.
The problem: every request needs the same cross-cutting work (logging, auth, validation, error shaping), and copying it into each handler guarantees one of them drifts.
A pipeline composes that work into ordered stages, each wrapping the next. It's Chain of Responsibility (GoF) at the request level, and in .NET it's ASP.NET Core middleware: each component does its bit, then calls next. In the food-delivery API the ordered stages are tenant-resolution, auth, then logging, and order matters: you resolve the restaurant from the request before you check that the caller is allowed to act on it.
// Resolve the restaurant tenant from the subdomain or header, before anything else runs.
app.Use(async (ctx, next) =>
{
var tenantId = TenantResolver.From(ctx.Request); // e.g. host header -> tenant_id
ctx.Items["tenant_id"] = tenantId;
using (LogContext.PushProperty("tenant_id", tenantId))
await next(); // auth and the rest run inside this scope
});What it buys you in production: cross-cutting concerns live in one ordered place. Tenant resolution, auth, correlation IDs, and exception-to-problem-detail mapping become stages you add once and every request inherits. Order is explicit, so you can reason about what runs before auth and what runs after, and every log line downstream already carries the tenant_id.
The skip-if: don't build your own pipeline abstraction for two stages you could write as two method calls. The pattern pays off when the cross-cutting list is long enough that order and reuse matter. Below that, it's indirection you maintain for no gain.
Download the full PDF for free?
Free download — no account required