A pattern should absorb change
The quickest way to make a codebase feel "architected" is to add interfaces everywhere. The quickest way to make it hard to maintain is to add interfaces before anyone knows what varies.
A useful design pattern protects a stable business rule from a changing detail. If no such boundary exists, an extra layer is just another file to open during an incident.
Start with the change you expect
Consider sending account emails. The business rule is stable: when a reset is requested for an eligible account, issue a time-limited link. The delivery mechanism can change: one SMTP provider today, an API-based provider later, or a fake sender in tests.
That is a reasonable place for an Adapter. The domain operation depends on a small capability, not on a particular mail SDK.
type EmailSender = {
sendResetLink(input: { to: string; url: string }): Promise<void>;
};
async function requestPasswordReset(user: User, sender: EmailSender) {
const url = await issueResetToken(user.id);
await sender.sendResetLink({ to: user.email, url });
}An SMTP adapter can implement EmailSender. A test double can record the intended message without connecting to a real server. The interface has one purpose: isolate a detail that is likely to vary.
This example is illustrative. A real reset flow must also handle enumeration risk, token storage, expiry, and what happens if delivery fails after a token is issued.
Strategy is for meaningful alternatives
Suppose different service requests have different qualification rules. If the rules share the same input and output but vary by service type, a Strategy can be useful:
type Qualification = (request: IntakeRequest) => Decision;
const qualifyByService: Record<ServiceType, Qualification> = {
consulting: qualifyConsulting,
implementation: qualifyImplementation,
};
const decision = qualifyByService[request.service](request);This is just a map of functions. It becomes valuable when each policy is substantial, independently tested, and selected at runtime. If there are only two short branches, a switch may be clearer.
Avoid "just in case" abstractions
The warning signs are familiar:
- An interface has exactly one implementation and no credible second one.
- A wrapper method only forwards arguments.
- Reading one business action requires jumping through five generic classes.
- A pattern name appears in a PR description, but the change it protects is unnamed.
One implementation is not automatically wrong. Test isolation or a critical external boundary can justify it. The question is whether the added indirection reduces future work enough to pay for today's complexity.
A small decision test
Before introducing a pattern, write down:
- What is stable?
- What changes independently?
- What is the simplest boundary that keeps those apart?
- How will a future change prove this boundary useful?
If the answers are concrete, the pattern usually feels obvious. If they are hypothetical, keep the code direct and refactor when the second real case arrives.
The goal is not to show that you know patterns. It is to make the next change cheaper and the current behavior easier to understand.