Loading learning content...
We've established that patterns should not be applied prematurely. But this raises a practical question: When the genuine need arrives, how do we introduce patterns into existing code?
This question is more important than it might seem. The ability to refactor toward patterns confidently is what makes deferral a viable strategy. If refactoring were painful and risky, early pattern application would be the safer choice. But refactoring, done properly, is neither painful nor risky—it's a disciplined transformation that improves code structure while preserving behavior.
This page teaches the mechanics of pattern-targeted refactoring: recognizing the trigger, preparing the codebase, executing incrementally, and verifying correctness throughout.
Refactoring to patterns is not rewriting. You're not discarding working code—you're restructuring it to accommodate new requirements while preserving existing behavior. Every step should leave the code working. If tests pass before a step and fail after, something went wrong.
The trigger to refactor toward a pattern comes when code begins to resist change in ways a pattern would address. These trigger moments are specific and recognizable:
| Trigger Situation | What You're Experiencing | Pattern to Consider |
|---|---|---|
| Second implementation needed | You need to support another way of doing something that was previously hardcoded | Strategy, Factory, Bridge |
| Switch statement on type growing | Adding new behaviors means adding more cases to switches/if-else chains | Strategy, State, Command |
| Configuration-based behavior | Behavior should change based on configuration without code changes | Strategy, Abstract Factory |
| Subclass explosion | You're creating subclasses for every combination of features | Decorator, Strategy + Factory |
| Tightly coupled notifications | Adding a new notification recipient requires modifying the sender | Observer |
| Complex object construction | Constructors have too many parameters or complex initialization logic | Builder, Factory |
| Interface mismatch with third party | External code doesn't match your expected interface | Adapter |
| Conditional on object state | Methods have complex conditionals checking object state | State |
Triggers often feel like friction during development:
When you feel friction during development, pause and ask: "Is this friction because the code structure doesn't match the problem structure?" If yes, a pattern may help.
Development friction isn't just annoyance—it's signal. When code fights against changes, it's telling you about a structural mismatch. Patterns exist precisely to resolve these mismatches. Learn to read friction as a guide to appropriate patterns.
Before restructuring code, ensure you have the safety nets and understanding needed for confident transformation:
If the code to be refactored lacks tests, write characterization tests first. These tests don't verify intended behavior—they document current behavior, whatever it is:
// Characterization test: Capture current behavior
test('processOrder with standard shipping (characterization)', () => {
const order = createTestOrder({ shipping: 'standard' });
const result = processOrder(order);
// Whatever the current behavior is, lock it in
expect(result.shippingCost).toBe(5.99);
expect(result.estimatedDays).toBe(5);
expect(result.carrier).toBe('USPS');
});
Characterization tests become guardrails during refactoring. If behavior accidentally changes, tests fail immediately.
Refactoring without tests is not refactoring—it's restructuring and hoping. If you can't verify behavior preservation, you're accepting unquantified risk. Write the tests first, even if it delays the refactoring.
Pattern refactoring should happen in small, verified steps. Each step changes code structure while preserving behavior. This section demonstrates the incremental approach through a worked example:
Starting situation: A report generator that handles multiple formats through conditional logic.
1234567891011121314151617181920212223242526272829303132
// BEFORE: Conditionals for each formatclass ReportGenerator { generate(data: ReportData, format: 'pdf' | 'excel' | 'csv'): Document { if (format === 'pdf') { // PDF generation logic (~50 lines) const doc = new PDFDocument(); doc.addTitle(data.title); doc.addTable(data.rows); doc.setPageSize('A4'); return doc.finalize(); } else if (format === 'excel') { // Excel generation logic (~50 lines) const workbook = new ExcelWorkbook(); const sheet = workbook.addSheet(data.title); sheet.addRows(data.rows); sheet.formatAsTable(); return workbook.finalize(); } else if (format === 'csv') { // CSV generation logic (~30 lines) const lines = [data.headers.join(',')]; for (const row of data.rows) { lines.push(row.values.join(',')); } return new CSVDocument(lines.join('\n')); } throw new Error(`Unknown format: ${format}`); }} // TRIGGER: Business requests HTML format// Adding another if-else block is the wrong direction// Time to refactor to Strategy pattern12345678
// STEP 1: Define the strategy interface// This captures the common operation all formats perform interface ReportFormatter { format(data: ReportData): Document;} // Tests still pass - we've only added an interface, changed nothing12345678910111213141516171819202122232425262728
// STEP 2: Extract first strategy (PDF)// Move the PDF logic to its own class implementing the interface class PDFFormatter implements ReportFormatter { format(data: ReportData): Document { const doc = new PDFDocument(); doc.addTitle(data.title); doc.addTable(data.rows); doc.setPageSize('A4'); return doc.finalize(); }} // Original class still has conditionals but calls new strategy for PDFclass ReportGenerator { generate(data: ReportData, format: 'pdf' | 'excel' | 'csv'): Document { if (format === 'pdf') { return new PDFFormatter().format(data); // Delegated } else if (format === 'excel') { // Still inline } else if (format === 'csv') { // Still inline } throw new Error(`Unknown format: ${format}`); }} // Tests still pass - behavior identical, structure changing123456789101112131415161718192021222324252627282930313233343536
// STEP 3: Extract remaining strategiesclass ExcelFormatter implements ReportFormatter { format(data: ReportData): Document { const workbook = new ExcelWorkbook(); const sheet = workbook.addSheet(data.title); sheet.addRows(data.rows); sheet.formatAsTable(); return workbook.finalize(); }} class CSVFormatter implements ReportFormatter { format(data: ReportData): Document { const lines = [data.headers.join(',')]; for (const row of data.rows) { lines.push(row.values.join(',')); } return new CSVDocument(lines.join('\n')); }} // Generator now delegates to all strategiesclass ReportGenerator { generate(data: ReportData, format: 'pdf' | 'excel' | 'csv'): Document { if (format === 'pdf') { return new PDFFormatter().format(data); } else if (format === 'excel') { return new ExcelFormatter().format(data); } else if (format === 'csv') { return new CSVFormatter().format(data); } throw new Error(`Unknown format: ${format}`); }} // Tests still pass - all logic extracted to strategies1234567891011121314151617181920212223242526272829303132333435363738394041424344
// STEP 4: Replace conditionals with strategy lookup// This eliminates the if-else chain entirely type FormatType = 'pdf' | 'excel' | 'csv' | 'html'; class ReportGenerator { private readonly formatters: Map<FormatType, ReportFormatter>; constructor() { this.formatters = new Map([ ['pdf', new PDFFormatter()], ['excel', new ExcelFormatter()], ['csv', new CSVFormatter()], ]); } registerFormatter(type: FormatType, formatter: ReportFormatter): void { this.formatters.set(type, formatter); } generate(data: ReportData, format: FormatType): Document { const formatter = this.formatters.get(format); if (!formatter) { throw new Error(`Unknown format: ${format}`); } return formatter.format(data); }} // Tests still pass - same behavior, clean strategy pattern // NOW adding HTML is trivial:class HTMLFormatter implements ReportFormatter { format(data: ReportData): Document { // HTML generation logic }} // Just register the new formattergenerator.registerFormatter('html', new HTMLFormatter()); // Total refactoring: ~45 minutes// New format addition: ~15 minutes// No existing code modified for new formatsDifferent patterns have different refactoring paths. Here are recipes for the most common pattern introductions:
When: You have new ConcreteClass() scattered through code, and you need to create different types based on context.
new ConcreteClass() calls with factory method callsWhen: One object directly calls methods on multiple other objects to notify them of changes.
When: You have subclasses for every combination of features, or you need to add behavior to objects dynamically.
When: Object behavior is conditional on internal state, with complex if-else chains checking state.
Refactoring success isn't just about code structure—it's about verified behavior preservation and improved design quality. Here's how to verify you've succeeded:
After refactoring, perform the smoke test that triggered the refactoring in the first place:
Before refactoring: "Adding HTML format requires modifying the generate method and adding another if-else case."
After refactoring: "Adding HTML format requires creating HTMLFormatter and registering it. No existing code changes."
If the smoke test passes—if the new requirement is now easy—the refactoring succeeded.
When refactoring to a pattern, the moment you add the new feature through the clean extension point feels satisfying. You've transformed friction into flow. This is the reward for proper timing—the pattern now earns its keep.
Sometimes the cost of refactoring to a pattern exceeds the benefit. Recognizing these situations prevents wasted effort:
When refactoring is too costly now, document the design debt:
// TODO: Refactor to Strategy pattern when adding next format
// Current if-else chain is becoming unwieldy
// Deferred due to: [release deadline / insufficient tests / etc.]
if (format === 'pdf') {
// PDF logic
} else if (format === 'excel') {
// Excel logic
} else if (format === 'html') {
// HTML logic - added under time pressure
}
This acknowledges the debt and leaves a trail for future engineers. When circumstances change, the refactoring can happen.
Some teams maintain a technical debt registry—a tracked list of design debts with reasons for deferral. This prevents debts from being forgotten and allows periodic debt reduction sprints.
We can now summarize the complete lifecycle for responsible pattern application:
This lifecycle treats patterns as destinations you refactor toward, not starting points you design down from. It respects YAGNI while enabling appropriate abstraction when justified.
Refactoring to patterns completes the responsible pattern application lifecycle. With this skill, deferring patterns becomes safe—you can always introduce them when genuinely needed.
Module complete: You now have a complete framework for pattern application—from recognizing problems that merit patterns, through matching patterns to problems, to timing introduction correctly and executing the refactoring safely. This framework will serve you throughout your engineering career.
You've mastered the art of knowing when to use patterns. You understand that patterns are solutions to specific problems, can match problems to patterns systematically, know when to defer pattern application, and can refactor toward patterns safely when the need emerges. This is mature pattern usage—the mark of a senior engineer.