Choosing, Combining, and Removing Patterns

Choose GoF patterns by tracing change pressure, comparing direct code with pattern roles, and removing abstractions when maintenance costs exceed their value.

published: reading time: 15 min read author: GeekWorkBench
Quick Summary

This guide shows how to evaluate a design pattern against direct code, combine patterns when they address separate forces, and remove abstractions that no longer help. An order-pricing example walks through Strategy selection, while production scenarios cover configuration, retries, ordering, and shared state. Use the decision process to keep code understandable as requirements change.

Choosing, Combining, and Removing Patterns

Introduction

Knowing the names of the Gang of Four patterns is the easy part. The harder part is deciding whether a design problem needs one. A pattern can make variation explicit and give a team a shared vocabulary. It can also add interfaces, files, indirection, and control flow that future maintainers must keep in their heads.

Start with a change that has happened or is likely enough to shape the design. Compare a direct implementation with one or more patterns that address that exact pressure. Then keep the design that makes the next change easier to understand without making today’s behavior harder to follow.

This post uses a small order-pricing service to make the choice concrete. For the pattern families and their roles, see Creational Patterns: Control Object Creation, Structural Patterns: Compose Objects and Interfaces, and Behavioral Patterns: Organize Collaboration.

A Decision Process

Use this sequence during design review or refactoring. It is a decision aid, not a recipe that requires a pattern at the end.

flowchart TD
  A[Name the change or constraint] --> B[Write a direct solution]
  B --> C{Does a specific force make the direct design costly?}
  C -->|No| D[Keep the direct design]
  C -->|Yes| E[Match the force to a pattern role]
  E --> F[Compare roles, indirection, and test seams]
  F --> G{Does the pattern reduce expected change cost?}
  G -->|No| D
  G -->|Yes| H[Introduce one pattern and verify behavior]
  H --> I{Is another independent force still exposed?}
  I -->|No| J[Keep the smallest useful design]
  I -->|Yes| K[Add a second pattern only at a clear boundary]
  K --> L{Did the abstraction stop paying for itself?}
  L -->|Yes| M[Refactor toward direct code]
  L -->|No| J

1. Name the force before the pattern

Describe what changes and what must remain stable. “We need Strategy” is a proposed answer. “The pricing algorithm varies by customer contract while the order workflow stays the same” is a problem statement.

Useful forces include:

  • A set of algorithms changes independently from the caller: consider Strategy.
  • An object changes behavior as its lifecycle changes: consider State.
  • A client contract differs from a dependency’s API: consider Adapter.
  • New behavior must wrap an object without changing its callers: consider Decorator.
  • Construction varies by product family or runtime choice: consider a factory pattern.
  • Several operations must happen in a sequence or chain: consider Chain of Responsibility only if handlers can be added, reordered, or bypassed independently.

Also write down the constraints: latency, transaction boundaries, ownership, failure behavior, team familiarity, and expected extension points. A pattern is not a substitute for those requirements.

2. Establish the direct baseline

Suppose an order can receive a standard discount or a contract discount. With two stable cases, a direct branch is easy to read:

Money discountFor(Order order, Customer customer) {
  if (customer.hasContract()) {
    return order.subtotal().multiply(customer.contractRate());
  }
  return order.subtotal().multiply(STANDARD_RATE);
}

This is not automatically “bad conditional code.” It is a compact expression of two known rules. Keep it while the branch remains local, the cases are few, and changing one rule does not disturb the others.

Now imagine that new pricing rules arrive monthly, each with its own eligibility, calculation, and tests. The branch may become a list of unrelated conditions. That is evidence that the algorithm varies independently, so Strategy is a candidate.

3. Compare the candidate with the baseline

Strategy moves each algorithm behind one contract. The caller chooses a policy and invokes it uniformly:

interface PricingPolicy {
  Money discountFor(Order order, Customer customer);
}

final class ContractPricing implements PricingPolicy {
  public Money discountFor(Order order, Customer customer) {
    return order.subtotal().multiply(customer.contractRate());
  }
}

final class StandardPricing implements PricingPolicy {
  public Money discountFor(Order order, Customer customer) {
    return order.subtotal().multiply(STANDARD_RATE);
  }
}

The strategy objects are useful if policies are selected at runtime, tested in isolation, or changed without modifying the order workflow. If the application still has only two tiny rules and selection stays in one place, this design may cost more than the branch. The pattern adds a contract, implementations, and a selection point; count all of them.

Before adopting a pattern, ask:

  1. What exact change becomes easier?
  2. Which class or caller stops knowing a detail?
  3. Does the new role have a coherent name and responsibility?
  4. Can the important behavior be tested without elaborate setup?
  5. Is the team likely to understand this design without a diagram?

If those answers are vague, keep the baseline and revisit after the next real change.

Combining Patterns Carefully

Patterns can address separate forces in one design. They become troublesome when the implementation accumulates pattern-shaped layers without clear ownership.

Consider the order service after pricing rules have multiplied. Strategy isolates the calculation that varies by customer contract. A Decorator might then add a tax calculation around a pricing result if tax treatment is optional and composed at runtime. The two roles answer different questions: which discount algorithm applies, and what additional calculation wraps its result.

By contrast, making a PricingStrategyFactoryProvider, a PricingContext, and a PricingDecoratorChainBuilder for two fixed strategies is unlikely to help. Every extra role needs a reason a maintainer can state in a sentence.

Give each pattern a separate force

When combining patterns, draw the call path and label the responsibility at each boundary. For example:

OrderService
  -> PricingPolicy selected for this order
  -> TaxCalculator for the order's jurisdiction
  -> PaymentGateway adapter for a vendor API

The Strategy selects a pricing algorithm. The tax component owns tax rules. The Adapter translates the payment provider’s API. They do not need to share a class hierarchy because all three happen to be objects.

Prefer an explicit composition root to hidden object construction. It should be possible to see which concrete pieces are assembled for a test, tenant, or deployment. When selection depends on configuration, validate that configuration at startup and fail clearly if a required policy is missing.

Add one layer at a time

Introduce the smallest pattern that handles the demonstrated force. Run the existing behavior checks, then inspect the new call path. Only add another pattern when a separate variation remains difficult to isolate. This gives the change a reviewable shape and makes it easier to identify whether the abstraction helped.

When to Use

Use a pattern when code shows a recurring design pressure and the pattern gives that pressure a clear owner. Typical signals include:

  • The same kind of conditional is duplicated across callers.
  • One class changes for unrelated reasons, such as vendor API changes and business-rule changes.
  • A stable workflow repeatedly gains new variants in one known dimension.
  • Tests need a seam to replace a slow, unreliable, or externally controlled collaborator.
  • Construction rules are complex enough that callers can create invalid combinations.
  • A team has agreed on a pattern vocabulary that shortens discussions and improves review.

For the order example, Strategy becomes reasonable once the set of pricing algorithms changes independently and tests can exercise each policy. It is not justified merely because an interview prompt asks for a design pattern.

When NOT to Use

Do not add a pattern because a class diagram looks empty without it. Avoid it when:

  • There is one stable behavior and no credible reason to vary it.
  • A local conditional states the rule more plainly than several tiny types.
  • The pattern moves complexity instead of containing it.
  • The intended extension point is speculative and has no owner or use case.
  • A framework already provides the same lifecycle or dispatch mechanism.
  • Debugging requires following a chain of wrappers to learn what a call does.

Patterns do not make code flexible for free. They move decisions to contracts and composition points. That is useful only when those decisions need to vary.

Production Failure Scenarios

A Strategy is selected from unvalidated request data

An API accepts a pricingMode string and maps it directly to a class name or reflection lookup. A malformed value falls through to a default discount, or an attacker chooses an internal strategy that was never intended for that endpoint.

Keep selection behind an allowlisted registry. Validate the requested mode, tenant authorization, and effective date before choosing a policy. Unknown values should produce a controlled client error or a safe, documented default, never arbitrary class loading.

Decorators change retry or transaction semantics

A retry decorator wraps a payment client and repeats a request after a timeout. The provider may have completed the charge even though the response was lost. Retrying without an idempotency key can charge the customer twice.

Make the decorator’s guarantees visible: bounded attempts, backoff, timeout, idempotency key propagation, and which failures are retryable. Test the composition with a simulated timeout after the provider accepts the charge.

A pattern chain hides order-dependent behavior

Discount handlers are added to a chain, but the order changes between environments. One handler assumes tax has already been calculated; another assumes it has not. The result may be correct in staging and wrong in production.

Represent ordering explicitly and test the whole assembled pipeline. If the handlers must always run in a fixed sequence, a simple named workflow may be clearer than a general Chain of Responsibility.

A Singleton leaks tenant state

A singleton pricing service caches the current tenant’s contract in a mutable field. Concurrent requests can observe the wrong customer’s rate. The pattern’s global lifetime has made a request-scoped value look process-scoped.

Keep per-request and per-tenant data in method inputs or correctly scoped collaborators. If a shared instance is used, keep it stateless or synchronize access with a design that preserves isolation.

Trade-Off Table

Option Good fit when Cost and risk Order-pricing example
Direct conditional Few stable cases; the rule is local Branch can spread as variants multiply Two known discount rules
Strategy Algorithms vary independently and share a useful contract More types and an explicit selection point Contract, seasonal, or loyalty pricing
State Valid behavior depends on an object’s lifecycle state State transitions and illegal transitions need modeling Draft, approved, and expired quote behavior
Decorator Optional behavior wraps a stable interface Wrapper order affects behavior; call stacks deepen Add jurisdictional tax or audit capture
Adapter A dependency’s API differs from the application’s port Translation can lose error or data semantics Convert a vendor payment API to PaymentGateway
Chain of Responsibility Handlers can be extended or skipped independently Ordering, short-circuiting, and missing handlers need rules Pluggable validation pipeline

The table narrows the candidates. The final choice still depends on expected change, team context, and production constraints.

Observability Checklist

Pattern-heavy designs can make behavior harder to trace unless the application records decisions at meaningful boundaries. For a pricing workflow, check that:

  • The selected policy has a stable, safe-to-log identifier, not a class name from user input.
  • Logs include an order or correlation ID and the reason a policy was selected.
  • Metrics distinguish policy outcomes and failures without using customer IDs as high-cardinality labels.
  • Traces show calls to external gateways and significant wrappers such as retries.
  • Errors preserve the original cause when adapters translate vendor exceptions.
  • Logs avoid payment data, credentials, personal data, and full request payloads.
  • Alerts cover unexpected fallback use, missing registrations, and unusual retry rates.

Do not log every method call just because the design has more objects. Observe decisions and boundaries that help an operator diagnose an outcome.

Security and Compliance Notes

Patterns do not enforce security by themselves. A Strategy interface can make policy substitution easy, which also means the selection point deserves scrutiny. Use an allowlist for configured implementations, authorize the caller before selecting a tenant-specific policy, and keep secrets outside strategy objects and logs.

For payment or personal data flows, document which collaborator receives sensitive fields and whether decorators, adapters, or retries copy them. Preserve provider idempotency keys and audit identifiers across wrappers. Ensure logs and telemetry follow data-retention and access rules. Review jurisdiction-specific tax and retention requirements with the responsible domain and compliance teams rather than embedding assumptions in a generic pattern.

Common Pitfalls and Anti-Patterns

  • Pattern-first design: choosing a class structure before naming the change pressure.
  • Pattern soup: combining Factory, Strategy, Decorator, and Facade where a function call would do.
  • Speculative extension points: designing for plugins nobody plans to build.
  • Leaky abstractions: a common interface that hides important differences in errors, latency, or transactional behavior.
  • Boolean strategy selection: passing flags into one class and calling it Strategy while all branches remain together.
  • Inappropriate Singleton scope: using process-wide state for data that belongs to a request or tenant.
  • Indirection without a seam: adding an interface when no caller, test, or deployment can substitute an implementation.
  • Pattern preservation: refusing to simplify a design after the original variation disappears.

One practical test: ask a teammate to trace a representative request from its entry point to the business decision. If the pattern names explain the path but the path itself is hard to follow, simplify or improve the boundary names.

Refactoring Patterns Back Out

Removing a pattern is ordinary design work. It may be the right move when there is only one implementation, the variation stopped, or every caller must understand the same wrapper chain.

Use a behavior-preserving sequence:

  1. Record the behavior with focused tests, especially edge cases and failure outcomes.
  2. Identify which roles exist only to support the abstraction being removed.
  3. Inline one delegate or wrapper at a time, keeping the public behavior stable.
  4. Replace the interface with a direct function, constructor, or conditional where that reads better.
  5. Remove unused registrations and tests that only verify forwarding.
  6. Run the relevant checks and compare logs, metrics, and transaction behavior.

For example, if a pricing Strategy has had one implementation for a year and the product roadmap no longer allows alternative policies, the order service can call that policy directly. Keep a policy boundary if it still isolates a meaningful external dependency or business rule. Do not collapse a useful seam merely to reduce the file count.

Refactoring back out is not evidence that the pattern was foolish. The system’s forces may have changed, or the expected variation may never have arrived. Preserve the history of why it was introduced when that history helps explain the decision.

Quick Recap Checklist

  • State the change or constraint in plain language.
  • Write down the direct implementation and its costs.
  • Match a named pattern only to a specific force it addresses.
  • Count new roles, selection logic, indirection, and testing cost.
  • Combine patterns only when they own separate responsibilities.
  • Validate configuration and preserve failure semantics at boundaries.
  • Observe important decisions without logging sensitive data.
  • Revisit abstractions when implementations or requirements disappear.

Interview Questions

1. How do you decide whether a conditional should become Strategy?

I look for independently changing algorithms with a useful common contract. If there are only a couple of stable cases in one place, a conditional may be clearer. Strategy earns its cost when selection is meaningful, implementations can be tested or changed independently, and the caller should not own the algorithm details.

2. When is combining patterns useful, and what is the main risk?

Combining patterns helps when each responds to a separate design force, such as Strategy for algorithm choice and Adapter for a vendor API boundary. The risk is layering abstractions without clear ownership, which makes control flow and failures harder to trace. I add one boundary at a time and explain what change it contains.

3. How would you remove a pattern from a mature codebase safely?

First capture behavior and failure cases with focused tests. Then inline or collapse one delegation layer at a time, keeping public behavior stable. I remove registrations and forwarding-only tests after their roles are gone, then check that transaction, logging, and error semantics still match production expectations.

Further Reading

Conclusion

Choose patterns by tracing a real change pressure, then compare the pattern with a direct implementation. Strategy, State, Adapter, Decorator, and Chain of Responsibility each describe a different design move; their names do not prove that a design needs them. Combining patterns can clarify independent boundaries, while removing one can make the current system easier to read. Keep the smallest design that handles the forces the software actually has.

Category

Related Posts

Refactoring Toward Patterns Safely

Refactor toward design patterns safely: follow evidence of change, preserve behavior with tests, and make small transformations before adding an abstraction.

#object-oriented-design #design-patterns #refactoring

Behavioral Patterns: Organize Collaboration

Compare all eleven GoF behavioral patterns by the change they isolate, the coupling they reduce, and the runtime costs they add to a system in real code.

#design-patterns #object-oriented-design #behavioral-patterns

Creational Patterns: Control Object Creation

Compare five creational patterns, see a compact TypeScript example, and choose the simplest fit for product families, construction, copying, or shared state.

#object-oriented-design #design-patterns #creational-patterns