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.
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
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.
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.
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
- SOLID Principles in Practice — use design principles as heuristics tied to concrete change pressure.
- Behavioral Patterns: Organize Collaboration — compare Strategy with related GoF patterns and their trade-offs.
- Strategy pattern overview — a concise description of the pattern’s intent and structure.
- Martin Fowler on refactoring — background on changing code structure while preserving behavior.
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.
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.
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.