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.

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

Safe refactoring toward design patterns starts with evidence of recurring change and a clear behavior contract. The checkout example follows a discount conditional through characterization tests, small transformations, and extraction to a Strategy design while preserving defaults and rounding. It also weighs the extra types and registration costs, flags failure modes, and explains when simplifying back to a direct implementation makes the system easier to maintain.

Refactoring Toward Patterns Safely

Introduction

A design pattern is useful when it names a structure that already answers a recurring problem. It is less useful when a team starts with the pattern name and reshapes stable code to fit it. The safe route is evidence first: notice where change keeps landing, write down the behavior callers depend on, then make small edits that preserve that behavior.

This guide follows a checkout discount example from a conditional to a Strategy-style design. The point is not that every switch should become a class hierarchy. The point is to make the decision reversible and to know when the new seam has earned its keep. For a broader view of object responsibilities, see SOLID Principles in Practice.

flowchart TD
    A[Notice repeated change pressure] --> B[Record current behavior]
    B --> C[Add or verify focused tests]
    C --> D[Make one small transformation]
    D --> E{Tests and callers still agree?}
    E -->|No| F[Undo or correct the transformation]
    F --> D
    E -->|Yes| G{Does the abstraction reduce a real change cost?}
    G -->|No| H[Keep the simpler design]
    G -->|Yes| I[Keep the pattern and observe its cost]

Find Evidence Before Naming a Pattern

One conditional is not evidence by itself. Look for a change history: new branches added for each policy, repeated edits across several callers, copy-pasted decisions, or tests that need to know unrelated details. Ask which axis varies and who owns that change. A Strategy may fit when algorithms vary independently while the surrounding workflow stays stable. A direct function parameter may be enough if there are only two small cases and no reason to model them as objects.

Keep the evidence concrete. For example, “three pricing requests in two months changed the checkout calculation, and each change touched the same method” is actionable. “This class violates Open/Closed” is only a label until you can name the changes the design must support. The behavioral patterns guide compares Strategy with other collaboration patterns and their costs.

Write Down the Behavior to Preserve

Refactoring changes structure while keeping observable behavior stable. Before moving code, identify the observations that matter: return values, exceptions, side effects, ordering, persistence, and public API shape. Add characterization tests where the behavior is undocumented. They record what the system does today, including odd edges that might be relied on.

For discount calculation, record exact results for each supported code, an unknown code, zero subtotal, and a subtotal that exercises rounding. If callers depend on an invalid code returning zero, changing it to throw is a feature change, even if the new design looks cleaner. Make that decision separately.

Make One Small Transformation at a Time

Suppose checkout currently calculates two discounts inline. Keep integer cents and the existing rounding behavior explicit:

function discountCents(code: string, subtotalCents: number): number {
  switch (code) {
    case "WELCOME":
      return Math.floor(subtotalCents * 0.1);
    case "LOYALTY":
      return Math.min(500, Math.floor(subtotalCents * 0.05));
    default:
      return 0;
  }
}

Before refactoring, capture representative behavior in tests. This small table is illustrative; a real suite should also cover boundary values and any callers that depend on the method:

const cases = [
  ["WELCOME", 0, 0],
  ["WELCOME", 1_000, 100],
  ["LOYALTY", 20_000, 500],
  ["LOYALTY", 1_999, 99],
  ["UNKNOWN", 1_000, 0],
] as const;

for (const [code, subtotal, expected] of cases) {
  if (discountCents(code, subtotal) !== expected) {
    throw new Error(`Unexpected discount for ${code}`);
  }
}

Now extract the changing calculation behind one small contract. The call site still asks for a discount by code, and an unknown code still returns zero:

interface DiscountStrategy {
  discountCents(subtotalCents: number): number;
}

class WelcomeDiscount implements DiscountStrategy {
  discountCents(subtotalCents: number): number {
    return Math.floor(subtotalCents * 0.1);
  }
}

class LoyaltyDiscount implements DiscountStrategy {
  discountCents(subtotalCents: number): number {
    return Math.min(500, Math.floor(subtotalCents * 0.05));
  }
}

const discounts = new Map<string, DiscountStrategy>([
  ["WELCOME", new WelcomeDiscount()],
  ["LOYALTY", new LoyaltyDiscount()],
]);

function discountCents(code: string, subtotalCents: number): number {
  return discounts.get(code)?.discountCents(subtotalCents) ?? 0;
}

Run the same cases against the extracted version. In practice, keep tests green after each mechanical move: extract a function, run checks, introduce the interface, run checks, then wire the implementations. If a failure appears, the last small step narrows the search. Do not combine extraction with a new discount rule, new error behavior, and a new persistence layer in one change; that makes a regression harder to locate.

When to Use

Refactor toward a pattern when the code shows a recurring variation and the pattern gives that variation a clear home. Strategy fits when a stable workflow needs interchangeable algorithms. State fits when legal actions change with an object’s lifecycle. Observer may fit when independent consumers react to the same event and direct calls are creating unwanted coupling.

The pattern should reduce the cost of a likely change enough to pay for its extra types, indirection, and tests. A second implementation can reveal whether a seam is real. It is not a strict prerequisite, though: a strong known requirement may justify a seam before the second variant ships. Make that assumption visible in review.

When NOT to Use

Keep the direct implementation when behavior is stable, the branches are short, and there is no credible second variation. Do not add interfaces just to make every class mockable. A small function is often easier to read and test than a registry, factory, base class, and two one-line implementations.

If you cannot name a plausible next change this seam would make cheaper, leave the conditional alone for now.

Also pause when the pattern would move complexity somewhere less visible. A Strategy map still needs a rule for unknown codes; a State model still needs clear transition ownership. If the new design hides those decisions, it has not simplified the system. Refactoring toward a pattern is optional; sometimes the right result is deleting duplication or naming a helper.

Production Failure Scenarios

Failure Why a refactor can cause it Guard
Unknown code gets rejected The old default returned zero; the registry now throws or returns undefined Keep a test for unknown input and match the existing contract
Discount changes by a cent Rounding moved from each policy to a shared total, or numeric representation changed Compare boundary and fractional cases before and after
Wrong policy is selected Registration keys differ in case or are missing from configuration Validate policy registration at startup and test each supported key
Checkout takes a new failure path A strategy now performs I/O or throws where calculation was pure Keep calculation side-effect free; handle operational failures at the workflow boundary
Duplicate registration hides a policy Map construction silently overwrites a repeated key Validate uniqueness when loading configuration

Trade-Off Table

Choice Benefit Cost Fits when
Keep conditional Direct, compact, easy to scan Each new variant edits the same decision point Cases are few and stable
Extract helper function Names a calculation and gives it a test seam Does not isolate policy ownership Logic is duplicated or hard to read, but variation is limited
Strategy objects Adds explicit interchangeable policies More types and a dispatch/registration path Policies change independently or need separate collaborators
Strategy registry with configuration Adds runtime selection Configuration validation and operational failure modes Deployment needs to select policies without code edits

Observability Checklist

Pure refactoring should not require new runtime telemetry, but the new structure can change operational failure modes. Before adding metrics, ask what an operator would need to diagnose a real issue. For configurable strategies, useful signals may include:

  • Which policy key was selected, using bounded labels rather than user-provided values.
  • Counts of missing or invalid policy configuration at startup.
  • Calculation or execution failures, separated from ordinary business outcomes.
  • A trace or structured log field that links the selected policy to the checkout request without recording sensitive customer data.

Avoid a metric label for every customer, coupon string, or order ID. High-cardinality labels make telemetry expensive and hard to query. Logging every successful discount calculation usually adds noise without helping diagnose failures.

Security and Compliance Notes

Treat externally supplied codes as untrusted input. Normalize them according to an explicit contract, allowlist supported identifiers, and choose a deliberate behavior for unknown values. Do not use raw user input as a class name, module path, or dependency key. A registry should map known identifiers to trusted implementations.

Keep policy calculation separate from authorization and payment execution. A discount strategy should not gain broader access to customer records just because it is easy to pass a large checkout object into it. If a policy depends on personal data, pass only required fields, restrict access, and avoid logging those fields. Preserve audit-relevant decisions when a compliance requirement calls for them, including which rule version produced the amount.

Common Pitfalls and Anti-Patterns

  • Pattern-first rewrites: selecting Strategy before identifying a real axis of change.
  • Changing behavior under a refactor label: altering rounding, exceptions, or defaults in the same patch.
  • One giant abstraction: a generic policy framework with hooks no current policy needs.
  • Fake polymorphism: classes with identical methods that only forward to one another.
  • Testing only the new classes: forgetting contract tests at the public call site.
  • Keeping dead layers: leaving the old conditional or adapter after the new path is established.
  • Assuming green tests prove equivalence: tests cover examples, not every possible input. Review the moved logic and add boundary cases that reflect the domain.

How to Remove an Unnecessary Abstraction

Patterns are not permanent commitments. If there is only one stable policy, the registry and interface make navigation slower, and no likely change benefits from substitution, simplify them. First add or keep tests for the behavior callers need. Then inline the single implementation at the call site or replace the registry with a direct function. Run the same checks, search for remaining implementations and references, and remove the now-unused types.

Do this as its own structural change. Keeping the behavior-preservation boundary lets reviewers distinguish simplification from a business-rule change. The goal is not to avoid patterns; it is to keep only the structure that still earns its cost.

Quick Recap Checklist

  • Point to concrete changes, duplicated decisions, or caller friction that motivated the refactor.
  • Record the existing outputs, side effects, errors, and edge cases that must remain stable.
  • Add characterization coverage where the contract is unclear.
  • Make one behavior-preserving transformation at a time.
  • Run the same checks after each move and compare boundary cases.
  • Confirm that the chosen pattern isolates a real change axis.
  • Keep unknown input, registration, and failure behavior explicit.
  • Remove interfaces, registries, or wrappers that no longer reduce change cost.

Interview Questions

1. How do you decide whether to refactor a conditional into Strategy?

Look for evidence that algorithms vary independently while the surrounding workflow remains stable. Repeated edits to the same branch, separate policy ownership, or a requirement to select behavior at runtime can justify Strategy. A short, stable conditional may be clearer and cheaper.

2. How do you show that the refactor preserved behavior?

Capture the old contract with tests before moving code, including defaults, exceptions, side effects, and boundary values. Run those tests after each small transformation. Tests provide feedback for covered cases; code review and domain-specific edge cases are still needed to assess behavior they do not exercise.

3. What would make you remove a pattern you added?

If the variation did not materialize, the abstraction has one stable implementation, and every change now requires extra navigation or registration, the pattern may cost more than it saves. Preserve the caller-visible contract, simplify the structure in a separate change, and remove unused layers.

Further Reading

Conclusion

Refactor toward a pattern when observed change gives the pattern a job. Define the behavior first, move one piece at a time, and use tests to catch accidental changes quickly. Keep the simplest design that gives likely changes a clear place to go, and be willing to remove that design when its reason disappears.

Category

Related Posts

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.

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

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

Testable Object Design and Collaborator Seams

Learn to design object-oriented code around behavior, collaborator boundaries, and stable dependency seams, with practical tests that resist refactoring churn.

#object-oriented-design #testing #dependency-injection