Design patterns have a reputation problem. Many developers meet them as a catalogue to memorise, then see them applied everywhere: an interface with one implementation, a factory that builds one class, a repository that wraps an ORM which was already a repository. The code gets longer and harder to follow, and nothing gets easier to change.
A pattern earns its place when it answers a force that keeps coming back in your domain. In business software, a few forces come back again and again: workflows with rules about what can happen next, several providers that do the same job differently, third-party APIs you don't control, and side effects that must not be lost. These are the patterns I reach for when those forces show up, and the ones I avoid.
State machine: for anything with a lifecycle
Orders, payments, subscriptions, refunds, and support tickets all move through states, and the business has rules about which moves are allowed. Without an explicit model, those rules end up scattered in if statements across controllers, jobs, and webhooks, and sooner or later a refunded order gets shipped.
Make the lifecycle explicit: a list of states, the allowed transitions between them, a guard for each transition, and the side effects it triggers.
pending --pay--> paid guard: amount matches
paid --ship--> shipped guard: stock reserved
paid --refund--> refunded effect: release stock
shipped --deliver--> delivered
Every change of state goes through one method that checks the table. An invalid move, such as shipping a refunded order, fails in one place with a clear error, and the full history of transitions becomes an audit log for free.
Strategy: for interchangeable providers and rules
A checkout that supports several payment providers, a tax engine with different rules per country, a shipping module that talks to several delivery companies: each is the same job done in different ways.
Define the job as one interface, for example charge(order), refund(payment, amount), status(reference), and implement it once per provider. Checkout depends on the interface, and a configuration or a rule chooses the implementation. Adding a provider means adding a class, not editing the checkout flow and retesting everything.
The test is simple: if adding a new variant forces you to edit a long switch statement in three places, you want a strategy.
Adapter, or anti-corruption layer: for APIs you don't control
Third-party APIs each have their own vocabulary. One delivery company calls it a "consignment" with a status code of 4, another calls it a "shipment" that is "OUT_FOR_DELIVERY". If those words leak into your domain, your code is shaped by the vendors instead of by your business.
Put an adapter at the edge for each external system. It translates the vendor's requests, responses, statuses, and errors into your own model: Shipment, ShipmentStatus::OutForDelivery. The rest of the application never sees a vendor-specific field. When a vendor changes its API, or you replace the vendor, the change stays inside one adapter.
Outbox: for side effects that must not be lost
"Save the order, then publish an event" has a gap. If the process crashes between the two steps, the order exists but nobody hears about it: no email, no stock reservation, no webhook to the partner.
The outbox closes the gap. In the same database transaction that saves the order, insert the event into an outbox table. A separate worker reads the outbox and publishes, marking each entry as sent. Either both the order and the event are saved, or neither is. Consumers must treat events as possibly duplicated, which is why the outbox and idempotent handlers belong together.
Specification, or policy objects: for business rules that combine
"Free shipping for orders over 50, except bulky items, unless the customer is a premium member" is a rule that will change next month. Written inline, it becomes an unreadable condition copied into the cart, the checkout, and the order confirmation.
Give each rule a name and a single method that answers yes or no, and combine them: OrderTotalAtLeast(50) and not ContainsBulkyItems or CustomerIsPremium. Rules become testable on their own, reusable across screens, and readable by someone who isn't a developer.
Pipeline: for multi-step calculations
Pricing, tax, and import processing are sequences of steps where order matters: base price, line discounts, order discounts, tax, rounding. A pipeline makes each step a small unit that takes the current state and returns the next one. Steps can be added, reordered on purpose, and tested in isolation, and the full calculation stays in one readable list instead of one long method.
Patterns to use carefully
- A generic repository on top of an ORM. If your ORM already gives you queries, relations, and transactions, a
Repository<T>withfind,save, anddeleteusually adds a layer without adding meaning. Repositories that express domain queries, such asordersAwaitingShipment(warehouse), are a different and useful thing. - Interfaces with exactly one implementation "in case we need another one". Add the interface when the second implementation is real; extracting one later is a small change.
- Singletons and service locators. They hide dependencies and make testing harder. Pass dependencies in explicitly.
- Inheritance for sharing code. Deep class hierarchies make every change risky. Prefer composition: small objects that each do one thing.
The rule of three
When you see a pattern-shaped problem the first time, solve it directly. The second time, notice the repetition. The third time, extract the pattern, because by then you know which parts actually vary. Patterns extracted from real repetition fit the domain; patterns applied in advance fit the textbook.
The checklist
- Lifecycles become explicit state machines with guarded transitions.
- Interchangeable providers and rules become strategies behind one interface.
- Every third-party API sits behind an adapter that speaks your domain's language.
- Side effects that must not be lost go through an outbox, with idempotent handlers.
- Combinable business rules become named policy objects.
- Multi-step calculations become pipelines.
- No interface, repository, or factory without a concrete force behind it.
Good patterns make the next change smaller. That is the only test that matters.