Patterns you will choose

Adapter, facade, composite, bridge, proxy, command, state, mediator, abstract factory and null object — each as a backend problem, its shape, and its cost.

9 min read📐 Low-Level Design

The last two lessons named the patterns already in your code and the ones that create objects. These are the patterns you will reach for on purpose — when a third-party client does not fit, when a rule tree needs to nest, when the switch on status has spread to eleven files. Each one is a shape, an example from a backend you could be working on, and the cost that comes with it.

Adapter — make a thing fit the interface you wanted

A vendor SDK arrives with its own types and its own idea of a method name. Your code should not learn them:

java
interface PaymentGateway { Receipt charge(Money amount, CardToken token); }
 
class StripeAdapter implements PaymentGateway {
    private final StripeClient stripe;
    public Receipt charge(Money amount, CardToken token) {
        PaymentIntent pi = stripe.paymentIntents().create(Map.of("amount", amount.minorUnits(), "source", token.value()));
        return new Receipt(pi.getId(), Instant.ofEpochSecond(pi.getCreated()));
    }
}

The adapter is the only class that imports com.stripe. It translates in both directions — your Money in, their PaymentIntent back to your Receipt — and it is where the vendor's quirks live. You have used it already: InputStreamReader adapts a byte stream to a Reader, Arrays.asList adapts an array to a List, Spring MVC's HandlerAdapter lets a dispatcher that expects one shape call controllers of several.

The cost: a second vocabulary to keep in sync, and a place where a mistranslation hides. Worth it at every vendor boundary, and at almost no internal one — adapting your own class to your own interface is a rename you did not do.

Facade — one door into a subsystem

Checkout touches inventory, pricing, payment, the ledger and email. Five services, called in an order that matters, by a controller that should not know the order:

java
@Service
class CheckoutFacade {
    Confirmation checkout(CartId cart, PaymentMethod method) {
        Reservation r = inventory.reserve(cart);
        Quote q = pricing.quote(cart);
        Receipt receipt = payments.charge(q.total(), method);
        ledger.record(receipt, q);
        notifier.confirm(cart, receipt);
        return new Confirmation(receipt.id());
    }
}

The facade offers the subsystem's use cases as methods and hides the choreography. Callers get one call; the subsystem keeps its five focused services. JdbcTemplate is a facade over Connection, Statement, ResultSet and exception translation; Spring Boot's auto-configuration is a facade over hundreds of beans; Files is a facade over channels and streams.

The cost: a class that knows about everything, which grows into the "service that does the whole application" if every use case lands in it. One facade per subsystem, each with a handful of methods, is the shape; a Facade with forty methods is the god class wearing a pattern's name.

Composite — treat one and many the same way

A pricing rule can be a single condition or an AND of others, which can themselves be ORs. If the tree and the leaf share an interface, callers never know which they hold:

java
interface Eligibility { boolean test(Order o); }
record MinTotal(Money m) implements Eligibility { public boolean test(Order o) { return o.total().gte(m); } }
record AllOf(List<Eligibility> rules) implements Eligibility {
    public boolean test(Order o) { return rules.stream().allMatch(r -> r.test(o)); }
}
Eligibility rule = new AllOf(List.of(new MinTotal(Money.of(500)), new AnyOf(List.of(new IsStudent(), new FirstOrder()))));

Spring Data's Specification.and(...) / .or(...) is composite; so is Actuator's CompositeHealthContributor, CompositeMeterRegistry in Micrometer, and every Predicate.and. The tell is a class that implements an interface and holds a collection of that interface.

The cost: operations that are not uniform — "give me the depth", "remove this leaf" — sit awkwardly on the leaf, and a deep tree is hard to print and harder to debug. Use it when the recursion is real, not to make a flat list look clever.

Bridge — two things vary, and they should vary separately

Notifications come in kinds (order confirmed, payment failed, weekly digest) and go out over channels (email, SMS, push). Subclassing gives you OrderConfirmedEmail, OrderConfirmedSms, PaymentFailedEmail… — every kind times every channel, and a new channel is a class per kind. Bridge splits the two hierarchies and joins them by composition:

java
interface Channel { void send(Recipient to, Rendered message); }
abstract class Notification {
    protected final Channel channel;                         // the bridge
    Notification(Channel channel) { this.channel = channel; }
    abstract Rendered render();
    void send(Recipient to) { channel.send(to, render()); }
}

Three channels plus three kinds is six classes instead of nine, and the seventh channel is one class. JDBC is a bridge: the java.sql API on one side, the drivers on the other, joined at run time. So is SLF4J over Logback, Log4j and the rest.

The cost: an extra level of indirection for a problem you might not have. If only one axis actually varies, this is strategy with a longer name; the bridge earns its keep when both axes have grown past two.

Proxy — the same interface, with something in between

The AOP lesson covers the mechanism in depth; this is the pattern behind it. A proxy implements the target's interface and stands in front of it, and what it does in between is one of four things: virtual (defer the expensive part — a Hibernate lazy-loading proxy is an entity that fetches on first get), protection (check access first — @PreAuthorize), remote (make a network call look local — a Feign client, an RMI stub), or smart (count, cache, retry — @Cacheable, @Retryable, a @Transactional boundary). The caller sees the interface and nothing else.

The cost: the one every Spring developer has paid — a proxy only intercepts calls that go through it, so a method calling another on this bypasses everything, and a final class cannot be proxied at all.

Command — an action as an object

Turn "do this" into a value you can queue, log, retry, and undo:

java
record RefundOrder(OrderId id, Money amount, String reason) implements Command {}

Once a request is an object it can be put on a queue and executed later (every job on a work queue), written to a table first and executed after commit (the transactional outbox from the distributed-data course is a table of commands), kept for an audit trail, and paired with its inverse for undo. Runnable and Callable are the JDK's commands, which is why a thread pool is a queue of them; Spring Batch steps and Kafka messages are the same idea with more ceremony.

The cost: one class per action, and control flow that moves from a method body into a dispatcher. Reach for it when an action needs to exist after the call that requested it — queued, retried, audited. For a synchronous "just do it", a method is a command with no overhead.

State — behaviour that depends on which state you are in

An order is NEW, PAID, SHIPPED, DELIVERED, CANCELLED, and what cancel() does depends entirely on which. The first version is a switch on status inside cancel(); the second is the same switch inside ship(), refund() and notify(); by the fourth the transitions are in eleven files and nobody can list them. State makes each state a class, and the object delegates to its current state:

java
interface OrderState {
    OrderState cancel(Order o);
    OrderState ship(Order o);
}
final class Paid implements OrderState {
    public OrderState cancel(Order o) { o.refund(); return new Cancelled(); }
    public OrderState ship(Order o)   { o.dispatch(); return new Shipped(); }
}
final class Shipped implements OrderState {
    public OrderState cancel(Order o) { throw new IllegalStateException("already shipped"); }
    public OrderState ship(Order o)   { return this; }                       // idempotent
}

Every legal transition is now a method on a state class, and every illegal one is an exception in exactly one place. In Java an enum with abstract methods (the enums lesson's constant-specific behaviour) is the compact form when states carry no data; classes when they do. A sealed interface plus switch pattern matching is the third form, and the compiler checks that every state handles every event.

The cost: the transition table is spread over the state classes — which is where you want it, but a reader wanting "all transitions on one page" now draws a diagram. Draw it; a state machine without its diagram is the switch problem in a different costume.

Mediator — many-to-many becomes many-to-one

When five components each talk to the other four, adding a sixth means twenty edges. A mediator is the hub they all talk to instead: OrderService publishing an event that inventory, email and analytics subscribe to is halfway there (that is observer); a CheckoutCoordinator that receives from all of them and decides what happens next is the whole thing. Spring's ApplicationEventPublisher, a message broker, an air-traffic controller: all mediators.

The cost: the mediator knows about everyone, so it is the class most likely to become the god object — which is exactly what it prevents everywhere else. Keep it a coordinator of who talks to whom; the moment it contains the business rules of its participants, split it.

Abstract factory — a family of objects that must agree

A payment provider is not one object but several that must match: a client, a request signer, a webhook parser, a settlement reader. Build them independently and one day the Stripe client is paired with the Adyen webhook parser. An abstract factory makes the family:

java
interface PaymentProviderFactory {
    PaymentGateway gateway();
    WebhookParser webhooks();
    SettlementReader settlements();
}
class StripeFactory implements PaymentProviderFactory { ... }      // every part is Stripe's

Choose the factory once — by configuration, by merchant, by region — and everything it produces is consistent. EntityManagerFactory is one (entity managers, queries and transactions that all belong to the same persistence unit); so is a JDBC DataSource in the sense that every Connection it hands out agrees on the database.

The cost: an interface per product plus the factory, and the creating-objects lesson's warning applies doubly — this is the pattern most often built for a family that never grows past one member. The trigger is the second provider, not the first.

Null object — an implementation that does nothing, on purpose

Instead of null and the if (metrics != null) at every call site, an implementation whose every method is a no-op:

java
MeterRegistry registry = metricsEnabled ? new PrometheusMeterRegistry(config) : new CompositeMeterRegistry();   // a composite with no members records nothing

An empty CompositeMeterRegistry in Micrometer, a NoOpCache in Spring's cache abstraction, Collections.emptyList(), a Logger with a level nothing passes: all null objects, and every one of them lets the caller stay ignorant of whether the feature is on. Optional took over the other half of the job — "there may be no value" — which is why the pattern is rarer in modern code than it was.

The cost: silence. A null object that stands in for a missing dependency by mistake hides the mistake until someone asks where the metrics went. Use it for a feature that is legitimately off, never as a way to make a NullPointerException stop.

Choosing

The problemThe pattern
a vendor's types leaking into your codeAdapter
callers choreographing five servicesFacade
rules that nest inside rulesComposite
two independent axes of variationBridge
something between the caller and the targetProxy
an action that must outlive the callCommand
a switch on status in many filesState
n components talking to each otherMediator
several objects that must matchAbstract factory
if (x != null) around an optional featureNull object

Every row is a cost as well as a name; the wrong-answer lesson is the other half of this table.

Progress is saved on this device and to your account when signed in.