You Wouldn't Renovate a House You Just Moved Into
Imagine you just bought a house. On day one, do you knock out the kitchen wall, add a second bathroom, and rewire the entire basement? Of course not. You live in it first. You notice the kitchen feels cramped when two people cook. You realize the single bathroom is a bottleneck on weekday mornings. You discover the basement outlet trips every time you plug in a space heater.
Then you renovate — with purpose.
Refactoring to design patterns works the same way. You don't reach for the Strategy pattern on line one of a new project. You write simple, direct code. You live with it. You feel the pain. And then — only when the pain is real and recurring — you recognize the shape of a pattern that fits.
This article is your diagnostic guide: how to spot the structural pain (code smells), decide whether a pattern is the right remedy or overkill, and walk through the transformation step by step.
Code Smells: Your Nose Knows
A code smell is not a bug. Your code works. But something feels off — like a weird smell in your house. It might be nothing. It might be a dead mouse in the wall. You investigate.
Code smells fall into a few broad families:
- Bloaters — things that have grown too big (long methods, giant classes)
- Change Preventers — things that make every small change ripple across the codebase
- Couplers — things glued together that shouldn't be
- Object-Orientation Abusers — things that misuse (or ignore) OO principles
The key insight from Joshua Kerievsky's foundational book Refactoring to Patterns is this: specific smells map to specific patterns. Not every smell needs a pattern — but when one does, there's usually a clear match.
Let's walk through four common scenarios.
Scenario 1: The Switch Statement That Won't Stop Growing
The Smell: Conditional Complexity
You've got a function that handles different types of something — and every time you add a new type, you add another branch:
// Pseudo code — the growing conditional
FUNCTION calculate_shipping(order):
IF order.type IS "standard":
cost = order.weight * 0.5
delivery_days = 7
ELSE IF order.type IS "express":
cost = order.weight * 1.5
delivery_days = 2
ELSE IF order.type IS "overnight":
cost = order.weight * 3.0
delivery_days = 1
ELSE IF order.type IS "international":
// Added last month...
cost = order.weight * 4.0 + customs_fee
delivery_days = 14
ELSE IF order.type IS "drone":
// Added this week...
cost = flat_rate(15.00)
delivery_days = 0
// ... more coming next sprint?
RETURN { cost, delivery_days }
Every new shipping method means touching this function. Every branch makes testing harder. This smell is called conditional complexity, and it's one of the most common triggers for pattern-directed refactoring.
The Pattern: Strategy
The Strategy pattern says: extract each branch into its own object that shares a common interface.
// Pseudo code — after refactoring to Strategy
INTERFACE ShippingStrategy:
METHOD calculate(order) -> { cost, delivery_days }
CLASS StandardShipping IMPLEMENTS ShippingStrategy:
METHOD calculate(order):
RETURN { cost: order.weight * 0.5, days: 7 }
CLASS ExpressShipping IMPLEMENTS ShippingStrategy:
METHOD calculate(order):
RETURN { cost: order.weight * 1.5, days: 2 }
// Adding a new type = adding a new class. Nothing else changes.
CLASS DroneShipping IMPLEMENTS ShippingStrategy:
METHOD calculate(order):
RETURN { cost: 15.00, days: 0 }
FUNCTION calculate_shipping(order, strategy):
RETURN strategy.calculate(order) // One line. Done.
New type? New class. The existing code never changes.
But Wait — DON'T Apply Strategy When...
You have two or three branches and they rarely change:
// Pseudo code — this is FINE as-is
FUNCTION format_name(user, style):
IF style IS "formal":
RETURN user.title + " " + user.last_name
ELSE:
RETURN user.first_name
Two branches. Stable for years. Introducing a NameFormattingStrategy interface, two implementing classes, and a registry would triple the code for zero benefit. The simpler fix is better: leave it alone.
The heuristic: If the conditional has grown past 3-4 branches and you're actively adding more, reach for Strategy. If it's stable and small, a simple conditional is fine.
Scenario 2: "Notify Everyone" Spaghetti
Photo by Logan Voss on Unsplash
The Smell: Tight Coupling / Shotgun Surgery
Your Order class needs to tell a bunch of other systems when something happens:
// Pseudo code — tight coupling
CLASS Order:
METHOD complete():
this.status = "completed"
emailService.sendConfirmation(this) // Notify email
inventoryService.decrementStock(this) // Notify inventory
analyticsService.trackPurchase(this) // Notify analytics
loyaltyService.addPoints(this.customer) // Notify loyalty
// Added last week:
slackService.notifyChannel(this) // Notify Slack
Every new notification = modifying Order. The Order class now knows about email, inventory, analytics, loyalty, and Slack. This is shotgun surgery — one conceptual change ("add a new notification") forces you to edit a class that shouldn't care about notifications at all.
The Pattern: Observer
The Observer pattern says: let interested parties subscribe to events, rather than the source knowing about each listener.
// Pseudo code — after refactoring to Observer
CLASS Order:
PRIVATE listeners = []
METHOD on(event_name, callback):
ADD callback TO listeners[event_name]
METHOD complete():
this.status = "completed"
EMIT "completed" WITH this // That's it. Order doesn't know who's listening.
// Somewhere during setup:
order.on("completed", emailService.sendConfirmation)
order.on("completed", inventoryService.decrementStock)
order.on("completed", analyticsService.trackPurchase)
// Adding Slack? One line. Order class never changes.
order.on("completed", slackService.notifyChannel)
But Wait — DON'T Apply Observer When...
You only have one or two listeners and they're core business logic, not optional side-effects:
// Pseudo code — Observer would be overkill here
CLASS Order:
METHOD complete():
this.status = "completed"
inventoryService.decrementStock(this) // This MUST happen. It's not optional.
If decrementing inventory is a required step of order completion (not an optional notification), making it an observer hides a critical dependency. The direct call is clearer, easier to debug, and makes the business requirement explicit.
The heuristic: Observer shines when you have optional, growing side-effects. If the dependency is mandatory and stable, a direct call is simpler and safer.
Scenario 3: Copy-Paste Object Construction
The Smell: Duplicated Creation Logic
You're creating similar objects in multiple places, with slight variations:
// Pseudo code — duplicated creation logic scattered everywhere
// In the API handler:
logger = NEW Logger(level: "info", output: console, format: "json", timestamp: true)
// In the background worker:
logger = NEW Logger(level: "debug", output: file("/var/log/worker.log"), format: "json", timestamp: true)
// In the test suite:
logger = NEW Logger(level: "debug", output: null_output, format: "plain", timestamp: false)
The construction details are smeared across the codebase. Change the Logger constructor? Find and update every call site.
The Pattern: Factory Method
// Pseudo code — after refactoring to Factory
CLASS LoggerFactory:
STATIC METHOD createForAPI():
RETURN NEW Logger(level: "info", output: console, format: "json", timestamp: true)
STATIC METHOD createForWorker(log_path):
RETURN NEW Logger(level: "debug", output: file(log_path), format: "json", timestamp: true)
STATIC METHOD createForTests():
RETURN NEW Logger(level: "debug", output: null_output, format: "plain", timestamp: false)
// Now everywhere:
logger = LoggerFactory.createForAPI() // One place knows the details
Photo by Roman Budnikov on Unsplash
But Wait — DON'T Apply Factory When...
Construction is simple and happens in one place:
// Pseudo code — no factory needed
logger = NEW Logger("info") // Simple. One arg. One call site.
A factory for a one-liner constructor used once is pure ceremony.
The Decision Flowchart
Here's how to think through whether a pattern is warranted: