The problem: a controller that news up handlers, orchestrates them, and shapes results becomes a god-object that knows about everything downstream.
A Mediator sits between the caller and the handlers. The caller sends a command (do something) or a query (ask something) as a message; the mediator routes it to the one handler that knows how. The controller takes a PlaceOrder command off the wire and hands it to the mediator, and a single handler owns placing the order. In .NET this is commonly the MediatR library (Jimmy Bogard). Note the two senses of the word: GoF's Mediator is an object-level pattern for decoupling colleagues, and the application-level Mediator here is the request-routing version. Same idea, larger altitude.
public record PlaceOrder(Guid TenantId, Guid CustomerId, IReadOnlyList<LineItem> Items)
: IRequest<OrderId>;
public sealed class PlaceOrderHandler(IOrderGateway orders, IPaymentAdapter payments)
: IRequestHandler<PlaceOrder, OrderId>
{
public Task<OrderId> Handle(PlaceOrder cmd, CancellationToken ct)
=> /* validate, charge, persist, return the new OrderId */;
}What it buys you in production: handlers become small, single-purpose, and independently testable, and the command/query split makes intent obvious in the type. The PlaceOrder write and the live-orders read stop sharing a fat service. Pipeline behaviours (validation, logging, transactions) can wrap every handler in one place, which is the same composition idea as middleware applied to messages rather than HTTP.
The skip-if: a small service with a handful of endpoints doesn't need a message bus between its own classes. Reaching for Mediator there adds a layer of indirection and a stack trace that's harder to follow, for the price of decoupling you don't have a problem with yet. It earns its place when the handler count grows or when you want cross-cutting behaviour around every operation.
The problem: a messy external API or a legacy system leaks its model into yours, and soon your clean domain is shaped by someone else's bad decisions.
An Anti-Corruption Layer (Evans, Domain-Driven Design, 2003) is a translation boundary. It's the higher-altitude cousin of the Adapter from Chapter 3: where an Adapter reshapes one interface, an ACL insulates your whole model from a foreign one, translating their concepts into yours at the edge and never letting their vocabulary inside. The food-delivery case is a restaurant still running a decade-old point-of-sale system: you have to send it orders, but you do not want its TicketRecord and its single-character status codes leaking into your domain.
public sealed class LegacyPosTickets(ILegacyPosClient pos) : IKitchenTickets
{
public async Task<KitchenTicket> Fetch(OrderId id, CancellationToken ct)
{
var rec = await pos.GetTicket(id.Value.ToString(), ct); // their model: TicketRecord, state "P"/"D"
return new KitchenTicket(id, rec.LineText, // ours: a clean domain type
accepted: rec.State == "P");
}
}What it buys you in production: the POS vendor's breaking changes stop at the boundary. When the legacy system renames a field or returns a new status code, you fix one translator, not every call site that places or tracks an order. Your domain stays expressed in your language, which keeps it readable to the people who maintain it.
The skip-if: don't build an ACL around a dependency you trust and control. If the external model is clean and stable, a plain Adapter or a direct call is enough. The ACL earns its cost specifically when the other side is messy, legacy, or out of your control, like a restaurant's ageing POS, and the translation is worth maintaining to keep that mess contained.
The problem: placing an order grows a tail of side effects (notify the kitchen, reserve stock, send the customer a confirmation), and stuffing them all into the place-order method couples things that have nothing to do with each other.
A Domain Event records that something meaningful happened in the domain (OrderPlaced, OrderCancelled), and interested handlers react to it (Evans, Domain-Driven Design; Martin Fowler's bliki, "Domain Event"). The originating code raises the event and moves on; it doesn't know or care who's listening. In .NET these are often dispatched in-process as MediatR notifications.
public sealed record OrderPlaced(OrderId OrderId, Guid TenantId, DateTime PlacedAt) : INotification;
// One handler among several; each is independent.
public sealed class NotifyKitchenOnOrderPlaced(IKitchenTickets kitchen) : INotificationHandler<OrderPlaced>
{
public Task Handle(OrderPlaced e, CancellationToken ct) => kitchen.Open(e.OrderId, ct);
}What it buys you in production: the side effects of an order decouple from the act of placing it. Notifying the kitchen, reserving stock, and sending the confirmation become three independent handlers, and you add a fourth (a courier pre-alert, an analytics event) by adding a handler, without editing the code that places the order. It also plants the seed for the messaging altitude: an in-process OrderPlaced is one small step from a published integration event once kitchen and courier-assignment genuinely live in other services.
The skip-if: in-process events are still synchronous code with a less obvious call graph. If a side effect always runs and always belongs to the operation, just call it directly; the indirection only pays off when the list of reactions is open-ended or genuinely independent. Save the cross-process version for Chapter 6, where the network and the OrderPlaced Pub/Sub fan-out make the decoupling real.
The problem: a business rule like "this restaurant is open and within delivery range" gets retyped wherever it is needed, in the search query, in the validation that rejects an out-of-range order, in the UI that greys a restaurant out, and the three copies drift until they disagree about what "deliverable" even means.
A Specification packages that rule as a small object with one method, IsSatisfiedBy, that you can name, test, and pass around (Evans, Domain-Driven Design, 2003; Evans & Fowler, "Specifications," 2002). The payoff is that specifications combine: And, Or, and Not are themselves specifications that hold other specifications, which is the Composite from Chapter 3 doing the same job one altitude up, a tree of rules handled through one interface.
public interface ISpecification<T>
{
bool IsSatisfiedBy(T candidate);
}
public sealed class IsOpen : ISpecification<Restaurant>
{
public bool IsSatisfiedBy(Restaurant r) => r.OpenNow();
}
public sealed class WithinRange(Location to) : ISpecification<Restaurant>
{
public bool IsSatisfiedBy(Restaurant r) => r.DistanceTo(to) <= r.MaxDeliveryKm;
}
public sealed class And<T>(ISpecification<T> left, ISpecification<T> right) : ISpecification<T>
{
public bool IsSatisfiedBy(T c) => left.IsSatisfiedBy(c) && right.IsSatisfiedBy(c);
}One rule, composed once and reused: var deliverable = new And<Restaurant>(new IsOpen(), new WithinRange(here)); now drives the search filter and the order validation from the same object.
What it buys you in production: the definition of "deliverable" lives in one place, tested on its own, and the search, the validation, and the UI all ask the same object instead of three predicates that quietly diverge. A new rule is a new small class; a new combination is an And of two you already have, with no edit to either. Because the combinators are a Composite, the rule tree reads the same whether it is one clause or six.
The skip-if: for a SQL-first service, a rule that only ever runs as a database query is usually better written as the predicate in the stored procedure, where the query planner can use it. Specification earns its keep when the same rule has to run in more than one place, an in-memory check and a query and a validation, or when rules combine at runtime. When a rule has a single home and runs once, skip the object and write the if.
Two patterns just missed the cut, useful when you want to go further.
Result / ErrorOr error handling. Returning an explicit Result<T> or ErrorOr<T> instead of throwing makes the failure path a value you have to handle, which reads well in a PlaceOrder handler and keeps control flow honest. It's a strong default; it missed the cut only because exceptions plus a pipeline error-handler cover most small services.
Plain Layered architecture. The oldest structure there is, presentation over application over domain over data. It's the honest default when Ports and Adapters would be ceremony. Plenty of solid services are just three well-named folders, and there's no shame in that.
A single service is the easy case. Production stores a great deal of state and has to move it without losing or corrupting it, which is the next altitude: data and persistence.
Download the full PDF for free?
Free download — no account required