Patternsintermediateschedule10 min read

Refactoring to Design Patterns: Recognizing When and How to Apply Them

Learn to spot code smells that signal when a design pattern will genuinely help — and when a simpler fix is the better call. A practical smell-to-pattern diagnostic guide with before-and-after walkthroughs.

A scrabble block spelling out the word pattern

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

Abstract black and white wavy pattern

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
Abstract pattern of white cubes with shadows

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:

hourglass_empty

Rendering diagram...

Notice the emphasis on incrementally. Pattern-directed refactoring is not "delete everything and rewrite." It's a sequence of small, safe transformations — extract method, extract class, introduce parameter object — that gradually move code toward a pattern structure. Each step is independently testable.


The Rule of Three

This is the single most useful heuristic for avoiding over-engineering:

  1. First time you encounter a problem — just solve it directly.
  2. Second time — notice the similarity, but still solve it directly. Maybe add a comment.
  3. Third time — now you have a real pattern. Now refactor.

Why three? Because with one occurrence, you have no pattern. With two, you might have a coincidence. With three, you have evidence.

This aligns with the YAGNI principle (You Aren't Gonna Need It). A 2020 empirical study published in PLOS ONE found that classes participating in design patterns do have significantly fewer code smells — but only when the patterns are applied to solve actual problems. The same study found that misapplied patterns (like the Command pattern used where a simple function call would do) can introduce new smells like God Class.

Patterns are medicine, not vitamins. You take medicine for a diagnosed condition. You don't take every medicine in the cabinet "just in case."


Signals That You've Gone Too Far

Watch for these warning signs that you've over-applied patterns:

  • You can't explain what a class does in one sentence without referencing the pattern name ("It's the AbstractStrategyFactoryMediator"). If the pattern name is doing the explaining instead of the domain, something's off.
  • New team members take hours to trace a simple flow through your code. Patterns should reduce complexity, not relocate it.
  • You have interfaces with a single implementation and no concrete plan for a second. That's not abstraction — it's speculation.
  • Your "simple feature" required creating 4+ new files. If adding a discount code requires a DiscountStrategy, DiscountFactory, DiscountContext, and DiscountRegistry... you probably just need an if-statement.

A Practical Checklist

Before refactoring to a pattern, answer these five questions:

  1. What specific smell am I solving? (Name it.)
  2. Have I seen this structural problem three or more times?
  3. Is the problem actively growing, or is it stable?
  4. Would a simpler refactoring (extract method, rename, inline) solve it?
  5. Can I introduce the pattern incrementally, one step at a time?

If you can't clearly answer #1, stop. You might be pattern-hunting rather than problem-solving.


Key Takeaways

  • Code smells are your diagnostic tool. Learn to name them (conditional complexity, shotgun surgery, duplicated creation logic) and they'll naturally point you toward the right pattern.
  • Refactor TO patterns, don't design WITH them. Write simple code first. Let the code tell you what it needs.
  • The Rule of Three prevents over-engineering. One occurrence is a case. Two is a coincidence. Three is a pattern.
  • Every pattern has a "don't apply" zone. A two-branch conditional doesn't need Strategy. A single mandatory dependency doesn't need Observer. A one-liner constructor doesn't need a Factory.
  • Refactoring is incremental, not revolutionary. Small steps. Tests between each step. You can stop partway if the code is already better.

The best developers aren't the ones who know the most patterns. They're the ones who know when not to use them.

Frequently Asked Questions

helpHow do I know if my code needs a design pattern or just a simple refactor?

Use the Rule of Three: if you've seen the same structural problem (not just duplicated lines, but the same kind of structural awkwardness) appear three or more times, a pattern is likely justified. If it's happened once or twice, a simpler refactor like extracting a method or renaming a variable is usually enough. Patterns solve recurring structural problems — emphasis on recurring.

helpCan applying a design pattern make my code worse?

Absolutely. A 2020 peer-reviewed study (Sousa et al., PLOS ONE) found that misapplied patterns can introduce new code smells. For example, the Command pattern can lead to God Class smells, and the Factory pattern can create Long Parameter List problems. Patterns are tools — using a sledgehammer on a thumbtack makes things worse, not better.

helpShould I learn all 23 GoF patterns before I start refactoring?

No. Most real-world codebases lean heavily on just 5-7 patterns (Strategy, Observer, Factory Method, Decorator, Adapter, Template Method, and sometimes Command). Start by learning to recognize the code smells that signal these common patterns. You'll naturally pick up others as you encounter the problems they solve.

helpWhat's the difference between 'designing with patterns' and 'refactoring to patterns'?

Designing with patterns means choosing patterns upfront before writing code — predicting future needs. Refactoring to patterns means writing simple code first, then evolving toward a pattern when the code's actual behavior demands it. Joshua Kerievsky's foundational work argues that the second approach is almost always better because you're solving real problems, not imagined ones.

helpIs it ever okay to refactor AWAY from a design pattern?

Yes, and this is an underappreciated skill. If a pattern was introduced speculatively and the anticipated complexity never materialized, removing the pattern (simplifying back to direct code) is a legitimate and valuable refactoring. Kerievsky's catalog explicitly includes refactorings that move code away from patterns when they're no longer serving a purpose.