How to choose a pattern: real-world lessons and examples. A quick decision guide.
After 98 days of exploring design patterns, you've probably asked yourself: which pattern actually fits my problem? The answer isn't always obvious. In this final stretch toward mastery, we'll walk through a practical decision framework that teams use in production to pick the right pattern—or decide that no pattern is needed at all.
If you need to... — comparing NEED vs CONSIDER
The fastest way to choose a pattern is to match your problem statement to a concrete need. We've distilled the most common scenarios below. Each has a "NEED" (the pattern that solves it most directly) and a "CONSIDER" (patterns that sometimes overlap but shouldn't be your first choice).
| Problem | NEED | CONSIDER |
|---|---|---|
| Different algorithms for the same task, swappable at runtime | Strategy | State (if the algorithm changes based on internal state) |
| Work with third-party APIs or legacy code that doesn't match your interface | Adapter | Decorator (if you're adding behavior, not translating) |
| Add behavior to objects without modifying their class | Decorator | Strategy (if behavior swaps, not stacks) |
| Model object behavior that changes based on internal state | State | Strategy (if selection is external, not driven by state) |
| Coordinate multiple steps across distributed services | Saga | Command (if you're undoing a single action locally) |
This table is your quick reference. But choosing a pattern isn't a lookup—it requires understanding the shape of your problem first.
Start with the problem — patterns are answers, not questions
This is the cardinal rule: patterns solve problems; they don't create them. Before you pick a pattern, name the problem in plain language.
For example, don't ask "should we use Strategy?" Ask instead: "Do we have multiple ways to calculate shipping costs, and do we need to swap between them at runtime without changing client code?" If yes, Strategy is a strong fit. If you only have one algorithm today and no foreseeable need for swapping, save yourself the abstraction.
Let's walk through a real scenario. You're building a payment processing system. You accept credit cards, PayPal, and now you need to add crypto support. Here's the wrong approach:
"We should implement Strategy because we have multiple payment methods."
Here's the right approach:
"Clients need to process payments through different providers. Each provider has different APIs, error handling, and validation rules. At checkout, the user picks a provider, and we need to route to the right logic without the checkout controller knowing about each provider's specifics."
The second framing makes it clear: Strategy is the answer. The client decision drives which algorithm runs. Payment logic is swappable. The system is agnostic.
Here's a sketch of what that looks like:
interface PaymentProcessor {
charge(amount: number, token: string): Promise<TransactionResult>;
refund(transactionId: string): Promise<void>;
}
class CreditCardProcessor implements PaymentProcessor {
async charge(amount: number, token: string): Promise<TransactionResult> {
const result = await stripeClient.charges.create({
amount,
source: token,
});
return { id: result.id, success: true };
}
async refund(transactionId: string): Promise<void> {
await stripeClient.refunds.create({ charge: transactionId });
}
}
class PayPalProcessor implements PaymentProcessor {
async charge(amount: number, token: string): Promise<TransactionResult> {
const result = await paypalClient.sale.create({
amount,
paymentToken: token,
});
return { id: result.saleId, success: true };
}
async refund(transactionId: string): Promise<void> {
await paypalClient.sale.refund(transactionId);
}
}
class CheckoutService {
constructor(private processor: PaymentProcessor) {}
async processPayment(amount: number, token: string): Promise<void> {
const result = await this.processor.charge(amount, token);
if (result.success) {
await this.recordTransaction(result.id);
}
}
}
// Usage
const processor =
paymentMethod === "credit-card"
? new CreditCardProcessor()
: new PayPalProcessor();
const checkout = new CheckoutService(processor);
The problem statement made the pattern obvious. You didn't force a pattern; you recognized it because the need matched the solution.
Prefer the simplest fit — fewer moving parts usually wins
Given a choice between multiple patterns that could work, pick the one with the fewest abstractions. Complexity has a cost: harder to test, harder to reason about, harder to debug.
Let's say you have a reporting system. Reports can be filtered, sorted, and formatted in different ways. You might think:
"We could use Decorator to stack filters and formatters, or Builder to construct reports step-by-step, or Strategy to swap filtering logic. Which one?"
Start simple. If your reports are built from a single template with a few configuration options, a simple configuration object might suffice:
interface ReportConfig {
format: "csv" | "json" | "pdf";
filters?: { field: string; value: unknown }[];
sortBy?: string;
}
class ReportGenerator {
generate(data: any[], config: ReportConfig): string {
let filtered = data;
if (config.filters) {
filtered = this.applyFilters(filtered, config.filters);
}
if (config.sortBy) {
filtered = this.sort(filtered, config.sortBy);
}
return this.format(filtered, config.format);
}
private applyFilters(
data: any[],
filters: { field: string; value: unknown }[]
): any[] {
return data.filter((row) =>
filters.every((f) => row[f.field] === f.value)
);
}
private sort(data: any[], field: string): any[] {
return data.sort((a, b) => (a[field] > b[field] ? 1 : -1));
}
private format(data: any[], format: string): string {
if (format === "csv") return this.toCsv(data);
if (format === "json") return JSON.stringify(data);
// ... pdf logic
return "";
}
}
This is straightforward. No deep inheritance. No complex composition. But what if reports become more complex—custom computed fields, chained transformations, plugins for third-party formatting? Then you reconsider. You might introduce Decorator:
abstract class ReportDecorator {
constructor(protected wrapped: Report) {}
abstract generate(): string;
}
class FilterDecorator extends ReportDecorator {
constructor(
wrapped: Report,
private filter: (row: any) => boolean
) {
super(wrapped);
}
generate(): string {
const data = this.wrapped.generate();
return JSON.stringify(
JSON.parse(data).filter(this.filter)
);
}
}
class SortDecorator extends ReportDecorator {
constructor(
wrapped: Report,
private sortKey: string
) {
super(wrapped);
}
generate(): string {
const data = JSON.parse(this.wrapped.generate());
return JSON.stringify(data.sort((a, b) => (a[this.sortKey] > b[this.sortKey] ? 1 : -1)));
}
}
// Build from the inside out
let report: Report = new BaseReport(data);
report = new FilterDecorator(report, (row) => row.status === "active");
report = new SortDecorator(report, "date");
const output = report.generate();
But notice: you only reach for Decorator when the config-based approach becomes unmaintainable. The simpler solution served you until it didn't.
Common mistakes
- Pattern-first thinking: "Let's use Factory here" before asking what problem you're solving. Reverse the order. Problem first, pattern second.
- Over-engineering for hypothetical scenarios: "We might need to support five different database backends someday." You might not. Build for today's needs; refactor when requirements actually change.
- Confusing similar patterns: State and Strategy look alike. State changes behavior based on internal state transitions. Strategy changes behavior based on client choice. Know the difference before you pick.
- Adding a pattern layer when composition or inheritance would work: If a class can extend another or use a helper, don't immediately jump to Decorator or Adapter. Complexity should be proportional to the problem.
- Ignoring your team's experience: Some teams are Factory-fluent; others live in Adapter. If your team knows and trusts a pattern, and it fits the problem reasonably well, there's value in that familiarity. Don't force a "better" pattern at the cost of team confusion.
Key takeaways
- Patterns are solutions to recurring problems. Define your problem before reaching for a pattern.
- Use the "If you need to…" table as a quick reference, but don't treat it as law. Context matters.
- Prefer simplicity. A simple solution today beats an over-engineered one that you'll regret tomorrow.
- Refactor to a pattern when the absence of one causes pain: duplication, tight coupling, hard-to-test code. That's the signal.
- Know your team. Familiar patterns win when they fit reasonably well; they reduce cognitive load and ship faster.
- After 98 days, you've seen dozens of patterns. The real skill is knowing when not to use one.






Comments
Loading comments…