You Don't Have a Pattern Problem — You Have a Diagnosis Problem
Picture this: you're at the pharmacy, staring at an aisle of 23 different medications. You know one of them could help, but you didn't go to a doctor first. You don't have a diagnosis. So you grab the one with the nicest box.
That's how most developers pick design patterns.
The Gang of Four cataloged 23 patterns in 1994 — and they explicitly warned that each pattern includes "when to use" and "when not to use" guidance. But most tutorials skip the diagnostic step entirely and jump straight to memorizing the catalog. The result? Developers reach for patterns at the wrong moment, for the wrong reason, or as a substitute for naming the real problem they're actually facing.
This article flips the script. Instead of "here are 23 patterns, go memorize them," we'll take the doctor's approach: symptoms first, then prescription.
Step 1: Do You Actually Need a Pattern?
Before you reach for any pattern, run through this checklist. If you can't check at least two of these boxes, a simple refactor or no change at all is probably the right move.
The "Do I Need a Pattern?" Checklist:
- [ ] You feel real friction right now. Not hypothetical future friction. You're changing the same code repeatedly, fighting your own structure, or writing the same if/else ladder for the third time.
- [ ] The problem is structural, not incidental. A typo, a missing null check, or a slow query isn't a pattern problem. A class with 14 responsibilities is.
- [ ] You've seen the problem recur. The Rule of Three: if the same structural awkwardness appears three times, a pattern is likely justified. Twice could be coincidence.
- [ ] A simpler fix doesn't exist. Extract a method. Rename a variable. Inline a class. These basic refactors solve 70% of design problems without introducing pattern complexity.
- [ ] The code will change again. Patterns manage change over time. If this is a one-off script or a stable module that hasn't been touched in a year, a pattern is solving a problem you don't have.
The pragmatic rule: The best number of patterns in any codebase is the minimum needed to manage actual complexity. Zero is a valid number.
Step 2: Identify Your Symptom
Design problems fall into three families, and recognizing which family your pain belongs to is the single most reliable first step toward the right pattern. Think of it like a hospital with three departments: how things get created, how things are composed, and how things communicate.
Let's walk through each symptom family with concrete examples.
The Creational Symptoms: "Creating Objects Is Painful"
Symptom: Your Constructor Has 10+ Parameters
The analogy: Ordering a custom sandwich by shouting all 12 ingredients at once — "turkey, swiss, lettuce, tomato, mayo, no onions, toasted, wheat bread, extra pickles, salt, pepper, cut in half!" — versus filling out an order form one line at a time.
The diagnosis: You need the Builder pattern.
// BEFORE: Constructor chaos
report = NEW Report("Q4 Sales", "pdf", true, false, "landscape",
300, "en", true, "header.png", "footer.png",
"2024-01-01", "2024-12-31")
// What does that 'true' mean? What does 'false' mean? Good luck.
// AFTER: Builder — each step is self-documenting
report = ReportBuilder
.new("Q4 Sales")
.format("pdf")
.orientation("landscape")
.dpi(300)
.language("en")
.include_charts(true)
.date_range("2024-01-01", "2024-12-31")
.build()
// Every parameter has a name. Optional ones can be skipped.
When NOT to use Builder: If your object has 3-4 parameters and they're all required, a regular constructor is clearer. Builder shines when there are many optional parameters or when the construction process has steps that depend on each other.
Symptom: You Keep Writing IF type IS "X" THEN NEW XThing()
The analogy: Imagine a restaurant where the waiter doesn't take your order to the kitchen — instead, the waiter personally walks into the kitchen, grabs specific ingredients, and cooks your meal. The waiter shouldn't know or care how the food is made. They just relay what you want.
The diagnosis: You need a Factory Method (or Abstract Factory if there are families of related objects).
// BEFORE: Caller knows too much about construction
IF file_type IS "csv":
parser = NEW CsvParser(delimiter=",", has_header=true)
ELSE IF file_type IS "json":
parser = NEW JsonParser(strict_mode=true)
ELSE IF file_type IS "xml":
parser = NEW XmlParser(namespace_aware=false)
// This if/else grows every time you add a file type.
// AFTER: Factory hides the construction details
parser = ParserFactory.create(file_type)
// The factory knows the details. The caller just gets a parser.
// Adding a new type? Update the factory. Caller never changes.
When NOT to use Factory: If you only have one or two types and that's unlikely to change, a factory is overkill. Direct construction with NEW is perfectly fine. Factories pay off when the number of types grows or when construction logic is complex.
The Structural Symptoms: "Connecting Objects Is Painful"
Photo by Bernard Hermant on Unsplash
Symptom: Two Things Should Work Together, But Their Interfaces Don't Match
The analogy: You just landed in Europe with an American laptop charger. The outlet is different. You don't rewire your laptop or the wall — you use a travel adapter that bridges the gap.
The diagnosis: You need the Adapter pattern.
// BEFORE: Your code expects one interface
FUNCTION analyze(data_source):
rows = data_source.get_rows() // Expects get_rows()
FOR EACH row IN rows:
process(row)
// But the third-party library gives you something different:
third_party_api.fetch_records() // Returns records, not rows!
// AFTER: Adapter translates the interface
CLASS ThirdPartyAdapter:
PRIVATE api
CONSTRUCTOR(api):
this.api = api
METHOD get_rows(): // Speaks YOUR language
records = this.api.fetch_records() // Translates behind the scenes
RETURN convert_records_to_rows(records)
// Now it plugs in seamlessly:
analyze(NEW ThirdPartyAdapter(third_party_api))
When NOT to use Adapter: If you control both sides of the interface, change one of them instead. Adapters are for bridging things you can't (or shouldn't) modify — third-party libraries, legacy code, or external APIs.
Symptom: You Need to Add Behavior Without Touching Existing Code
The analogy: You don't tear down your house to add a porch. You wrap new structure around the existing one. The house still works exactly as before — it just has something extra on the outside.
The diagnosis: You need the Decorator pattern.
// BEFORE: Adding features by editing the original class
CLASS DataService:
METHOD fetch(query):
// ... fetch from database
// Now the boss wants logging. Edit this method.
// Now they want caching. Edit this method again.
// Now they want retry logic. This method is a mess.
// AFTER: Decorator layers wrap behavior around the original
base_service = NEW DataService()
logged_service = NEW LoggingDecorator(base_service)
cached_service = NEW CachingDecorator(logged_service)
retrying_service = NEW RetryDecorator(cached_service)
// Each decorator adds ONE behavior. Stack them like Lego.
// Remove caching? Just remove that layer. Others unaffected.
When NOT to use Decorator: If you're only adding one behavior and it's unlikely to change, just put it in the class. Decorators shine when you have multiple optional behaviors that can be mixed and matched. If you'll never need a different combination, the indirection isn't worth it.
Symptom: Clients Are Overwhelmed by a Complex Subsystem
The analogy: A hotel concierge. You don't call the restaurant, the theater, and the taxi company separately — you tell the concierge "dinner and a show tonight" and they handle the coordination.
The diagnosis: You need the Facade pattern.
// BEFORE: Client must orchestrate 5 subsystems
video = VideoDecoder.decode(file)
audio = AudioDecoder.decode(file)
subtitles = SubtitleParser.parse(file)
sync = SyncEngine.align(video, audio, subtitles)
player = Renderer.create(sync)
player.play()
// AFTER: Facade handles the orchestration
MediaPlayer.play(file) // One call. Facade coordinates everything.
When NOT to use Facade: If clients genuinely need fine-grained control over the subsystems, a facade that hides everything can be restrictive. Facades work best as a convenient default while still allowing power users to reach the subsystems directly.
The Behavioral Symptoms: "Object Communication Is Painful"
Symptom: A Growing if/else Chain That Picks Different Behavior
The analogy: A GPS app doesn't have a giant if/else inside to handle every possible routing mode. It has swappable routing strategies — fastest, shortest, no tolls, avoid highways — and you pick one. The navigation engine stays the same.
The diagnosis: You need the Strategy pattern.
// BEFORE: Growing conditional selects behavior
FUNCTION calculate_shipping(order):
IF order.method IS "standard":
cost = order.weight * 0.5
days = 7
ELSE IF order.method IS "express":
cost = order.weight * 1.5
days = 2
ELSE IF order.method IS "overnight":
cost = order.weight * 3.0 + 15
days = 1
ELSE IF order.method IS "drone": // New! Edit the function again...
cost = order.weight * 5.0 + 25
days = 0
RETURN { cost, days }
// AFTER: Strategy — each behavior is its own object
INTERFACE ShippingStrategy:
METHOD calculate(order) -> { cost, 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 drone shipping? Create a new class. Nothing else changes.
FUNCTION calculate_shipping(order, strategy):
RETURN strategy.calculate(order)
When NOT to use Strategy: If you have 2-3 branches and the logic is simple and stable, an if/else is fine. Strategy adds value when the branching logic is complex, growing, or needs to be testable in isolation.
Symptom: One Object Changes and Others Need to React
The analogy: A newspaper subscription. You don't call the printing press every morning to ask if there's a new edition. You subscribe, and the paper shows up when it's ready. If you don't want it anymore, you unsubscribe. The press doesn't care about individual readers.
The diagnosis: You need the Observer pattern.
// BEFORE: Tight coupling — the source knows all its dependents
CLASS ShoppingCart:
METHOD add_item(item):
items.add(item)
ui.update_badge(items.count) // Knows about UI
analytics.track("item_added", item) // Knows about analytics
inventory.reserve(item) // Knows about inventory
// Every new dependent = edit this method
// AFTER: Observer decouples the source from its dependents
CLASS ShoppingCart:
PRIVATE listeners = []
METHOD subscribe(listener):
ADD listener TO listeners
METHOD add_item(item):
items.add(item)
FOR EACH listener IN listeners:
listener.notify("item_added", item) // Doesn't know WHO is listening
// Setup:
cart.subscribe(NEW UIBadgeUpdater())
cart.subscribe(NEW AnalyticsTracker())
cart.subscribe(NEW InventoryReserver())
// Adding email notification? Subscribe a new listener. Cart never changes.
When NOT to use Observer: If there's only one dependent and it's unlikely to change, direct calls are simpler and easier to debug. Observer adds complexity because the execution path becomes less visible — events can fire in surprising orders. Use it when the set of dependents genuinely varies or grows over time.
Photo by Logan Voss on Unsplash
Symptom: An Object's Behavior Changes Based on Its Internal State
The analogy: A traffic light doesn't use if/else to decide what to do. It is a state machine: green → yellow → red → green. Each state knows its own behavior ("cars go" vs. "cars stop") and which state comes next.
The diagnosis: You need the State pattern.
// BEFORE: State tracked as a string, behavior scattered in conditionals
CLASS Order:
state = "pending"
METHOD ship():
IF state IS "pending":
// not paid yet — can't ship!
ELSE IF state IS "paid":
// actually ship it
state = "shipped"
ELSE IF state IS "shipped":
// already shipped — error
ELSE IF state IS "delivered":
// already delivered — error
// AFTER: Each state is its own object with clear behavior
INTERFACE OrderState:
METHOD ship(order)
CLASS PaidState IMPLEMENTS OrderState:
METHOD ship(order):
start_shipping(order)
order.set_state(NEW ShippedState()) // Transitions to next state
CLASS ShippedState IMPLEMENTS OrderState:
METHOD ship(order):
THROW "Already shipped" // Clear, no ambiguity
CLASS Order:
PRIVATE state = NEW PendingState()
METHOD ship():
state.ship(this) // Delegates to current state
When NOT to use State: If your object has only 2-3 states with simple transitions, a string or enum with a switch statement is perfectly readable. State pattern pays off when the number of states grows, the transitions are complex, or different states have substantially different behavior.
The Golden Hammer Trap
The most dangerous moment in a developer's pattern education is right after they learn their first few patterns. Everything starts looking like a nail.
This has a name: the Golden Hammer anti-pattern. You become so comfortable with a particular solution that you apply it everywhere, whether it fits or not.
Warning signs you're wielding a Golden Hammer:
- You're designing a class and your first thought is "which pattern should I use?" instead of "what does this class need to do?"
- You introduce a Factory for a class that's only constructed in one place.
- You wrap everything in a Decorator because you used one successfully last week.
- You create an Observer system for communication between two objects that sit right next to each other.
- Your teammate asks "why is this a Strategy?" and you can't name the second strategy that would justify the abstraction.
The antidote: Always start from the problem. Ask "what friction am I feeling?" If the answer is "none," the correct number of patterns to apply is zero.
The Decision Shortcut: Symptom → Pattern Quick Reference
| The Pain You Feel | Category | Pattern to Consider |
|---|
| Constructor with too many parameters | Creational | Builder |
| Need to choose between multiple related types at runtime | Creational | Factory Method |
| Families of related objects that must be used together | Creational | Abstract Factory |
| Must guarantee exactly one instance | Creational | Singleton (sparingly) |
| Two interfaces that should work together but don't match | Structural | Adapter |
| Need to add behavior without modifying existing code | Structural | Decorator |
| Complex subsystem that clients find intimidating | Structural | Facade |
| Growing if/else or switch for selecting behavior | Behavioral | Strategy |
| One object changes and others need to react | Behavioral | Observer |
| Object behavior depends on its current state | Behavioral | State |
| Need to queue, undo, or log operations | Behavioral | Command |
| Defining a skeleton algorithm where subclasses fill in steps | Behavioral | Template Method |
Patterns and SOLID: The Bridge
If you've studied SOLID principles, here's the connection that ties everything together: SOLID tells you WHAT good design looks like. Patterns show you HOW to get there.
When Strategy helps you add new behavior without editing existing code, it's implementing the Open/Closed Principle. When Observer decouples a notification sender from its listeners, it's implementing the Single Responsibility Principle. When Factory lets high-level code depend on abstractions rather than concrete constructors, it's implementing Dependency Inversion.
Patterns aren't random — they're vehicles for SOLID goals. Understanding this connection transforms pattern selection from "which of these 23 should I memorize?" into "which SOLID principle am I trying to honor, and which pattern delivers it?"
The Practitioner's Workflow
- Write simple, direct code. No patterns. No abstractions.
- Feel friction. If changes are hard, tests are brittle, or a class is doing too much — notice it.
- Diagnose the symptom. Is the pain about creation, composition, or communication?
- Check the checklist. Is this friction real, structural, and recurring? Would a simpler fix work?
- Apply the minimum pattern. Solve the specific problem. Don't add patterns "while you're at it."
- Stop when the pain is gone. A partially-applied pattern that solves your actual problem beats a textbook-perfect implementation that solves a hypothetical one.
Remember: the Gang of Four themselves said their patterns are "half-baked" — you must always finish and adapt them to your context. The pattern is a starting point, not a destination.