Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 098 / 100

How to choose a pattern: real-world lessons and examples

A quick decision guide.

Expona Admin7 min read

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:

typescript
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:

typescript
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:

typescript
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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles