Your Codebase Has a Doctor's Appointment
Imagine you walk into a doctor's office feeling terrible. The doctor doesn't just guess a random medication. They run through a diagnostic checklist — temperature, blood pressure, symptoms — and then prescribe the right treatment.
SOLID principles and design patterns work exactly this way.
SOLID principles are the diagnostic checklist. They help you spot what's wrong with your code's structure. Design patterns are the prescriptions. They're proven treatments for the specific diseases that SOLID helps you diagnose.
Most tutorials teach these separately — here's SOLID, now forget that, here's patterns. But they were always meant to work together. This article shows you the direct wiring between them: when you detect this SOLID violation, apply that pattern to fix it.
The Five-Second Version of SOLID
Before we connect the dots, let's get everyone on the same page. Robert C. Martin introduced these principles in his 2000 paper Design Principles and Design Patterns, and Michael Feathers later arranged them into the SOLID acronym around 2004.
Here's the entire framework, with real-world analogies:
| Letter | Principle | The Analogy |
|---|
| S | Single Responsibility | A chef cooks. A waiter serves. The chef doesn't take orders tableside. |
| O | Open/Closed | A power strip lets you plug in new devices without rewiring your house. |
| L | Liskov Substitution | If your recipe says "any cheese," mozzarella and cheddar should both work. Substituting concrete for cheese would break the recipe. |
| I | Interface Segregation | A TV remote shouldn't have a "launch missiles" button just because the universal remote spec includes one. |
| D | Dependency Inversion | A lamp plugs into any wall outlet via a standard plug. It doesn't care whether the electricity comes from solar, wind, or coal. |
Now let's see how violations of each principle point you directly to the pattern that fixes them.
The Diagnostic Map
This is the core idea that most articles miss. Each SOLID principle, when violated, produces a recognizable symptom — and specific design patterns treat that symptom.
Let's walk through each connection with a running example.
The Running Example: A Notification System Gone Wrong
We'll start with a single, messy class that violates all five SOLID principles. Then we'll fix each violation with the appropriate pattern. By the end, you'll see how the principles and patterns form a complete toolkit.
// Pseudo code — a class that does EVERYTHING wrong
CLASS NotificationManager:
METHOD send(user, message, channel):
// Validates the message (responsibility #1)
IF message IS empty:
THROW "Message cannot be empty"
IF message.length > 500:
message = message.truncate(500)
// Formats for each channel (responsibility #2)
IF channel IS "email":
formatted = wrap_in_html(message)
subject = extract_subject(message)
ELSE IF channel IS "sms":
formatted = strip_to_plain_text(message)
ELSE IF channel IS "slack":
formatted = convert_to_slack_markdown(message)
ELSE IF channel IS "push":
formatted = truncate_for_push(message, 100)
// Adding "teams"? You have to edit THIS method.
// Sends via hardcoded service (responsibility #3)
IF channel IS "email":
smtp = NEW SmtpClient("mail.example.com", 587) // Hardcoded!
smtp.send(user.email, subject, formatted)
ELSE IF channel IS "sms":
twilio = NEW TwilioClient("HARDCODED_API_KEY") // Yikes
twilio.send(user.phone, formatted)
// ... you get the idea
// Logs everything (responsibility #4)
db = NEW MySQLConnection("localhost", 3306) // Hardcoded!
db.insert("logs", { user, channel, message, timestamp: now() })
This class is a five-alarm fire. Let's diagnose it.
Diagnosis #1: Single Responsibility Violation → Factory + Observer
The symptom: NotificationManager validates, formats, sends, AND logs. It has four reasons to change.
The analogy: Imagine a restaurant where one person takes orders, cooks the food, serves the table, AND does the dishes. They'll burn the steak while washing plates.
The prescription: Split responsibilities. Use Factory Method to extract object creation, and Observer to decouple the logging side-effect.
// Pseudo code — after applying SRP with Factory + Observer
// Responsibility 1: Validation (its own class now)
CLASS MessageValidator:
METHOD validate(message):
IF message IS empty: THROW "Message cannot be empty"
IF message.length > 500: RETURN message.truncate(500)
RETURN message
// Responsibility 2: Sending (separate — we'll fix this more below)
// Responsibility 3: Logging extracted via Observer
CLASS NotificationManager:
PRIVATE listeners = []
METHOD on_sent(callback):
ADD callback TO listeners
METHOD send(user, message, channel):
validated = validator.validate(message)
// ... format and send (we'll fix these next)
FOR EACH listener IN listeners:
listener.handle(user, channel, validated) // Logging is now a subscriber
// Setup:
manager.on_sent(NEW LoggingListener(logService))
manager.on_sent(NEW AnalyticsListener(analytics)) // Easy to add more!
The NotificationManager no longer knows or cares about logging. That's the Observer pattern doing its job — and it directly fixes the SRP violation.
Photo by Rick Rothenberg on Unsplash
Diagnosis #2: Open/Closed Violation → Strategy Pattern
The symptom: Adding a new channel (Teams, WhatsApp) forces you to edit the send method. The class is open for modification when it should be closed.
The analogy: Your power strip lets you plug in a new lamp without rewiring anything. But this code is like having to open the wall and splice new wires every time you buy an appliance.
The prescription: The Strategy pattern — extract each channel's formatting and sending logic into its own interchangeable object.
// Pseudo code — Strategy pattern fixes the OCP violation
INTERFACE ChannelStrategy:
METHOD format(message) -> formatted_message
METHOD deliver(user, formatted_message)
CLASS EmailStrategy IMPLEMENTS ChannelStrategy:
METHOD format(message):
RETURN wrap_in_html(message)
METHOD deliver(user, formatted):
smtp.send(user.email, formatted)
CLASS SmsStrategy IMPLEMENTS ChannelStrategy:
METHOD format(message):
RETURN strip_to_plain_text(message)
METHOD deliver(user, formatted):
twilioClient.send(user.phone, formatted)
CLASS SlackStrategy IMPLEMENTS ChannelStrategy:
METHOD format(message):
RETURN convert_to_slack_markdown(message)
METHOD deliver(user, formatted):
slackApi.post(user.slack_id, formatted)
// Now NotificationManager is CLOSED for modification:
CLASS NotificationManager:
METHOD send(user, message, strategy):
validated = validator.validate(message)
formatted = strategy.format(validated) // Delegate to strategy
strategy.deliver(user, formatted) // Delegate to strategy
EMIT "sent" WITH { user, formatted } // Observer from earlier
// Adding Teams? Create TeamsStrategy. NotificationManager never changes.
This is the pattern most directly tied to the Open/Closed Principle. The class is now open for extension (add new strategies) and closed for modification (the manager's code never changes).
Diagnosis #3: Liskov Substitution Violation → Adapter Pattern
The symptom: Imagine someone creates a PushNotificationStrategy that throws an exception on format() because push notifications "don't need formatting." That breaks the contract — any code expecting a ChannelStrategy would blow up when it gets this one.
The analogy: If your recipe says "add any cheese" and someone hands you a rubber cheese prop, the recipe breaks. A substitution must behave like the thing it replaces.
The prescription: The Adapter pattern — wrap the incompatible thing so it conforms to the expected interface without lying about its capabilities.
// Pseudo code — the problem
CLASS PushNotificationStrategy IMPLEMENTS ChannelStrategy:
METHOD format(message):
THROW "Push notifications don't use formatting!" // LSP violation!
METHOD deliver(user, message):
pushService.send(user.device_token, message)
// Pseudo code — Adapter fixes the LSP violation
CLASS PushNotificationAdapter IMPLEMENTS ChannelStrategy:
PRIVATE pushService
METHOD format(message):
RETURN truncate(message, 100) // Adapts: does something sensible
METHOD deliver(user, formatted):
pushService.send(user.device_token, formatted) // Delegates
The adapter translates between what the system expects and what the underlying service actually does — without breaking the contract that every ChannelStrategy must honor.
Diagnosis #4: Interface Segregation Violation → Focused Interfaces
The symptom: What if some channels don't support rich formatting? Or some don't need user authentication? Forcing every strategy to implement methods it doesn't use is an ISP violation.
The analogy: Your TV remote shouldn't force you to learn 47 buttons when you only need power, volume, and channel. That's a remote designed for the manufacturer, not the user.
The prescription: Split the fat interface into focused ones. Clients depend only on what they actually use.
// Pseudo code — fat interface (ISP violation)
INTERFACE ChannelStrategy:
METHOD format(message)
METHOD deliver(user, message)
METHOD authenticate(credentials) // Not all channels need this!
METHOD get_delivery_receipt() // Not all channels support this!
METHOD set_priority(level) // SMS doesn't have priority!
// Pseudo code — segregated interfaces (ISP fixed)
INTERFACE Formattable:
METHOD format(message)
INTERFACE Deliverable:
METHOD deliver(user, message)
INTERFACE Trackable: // Only for channels that support receipts
METHOD get_delivery_receipt()
// Email uses all three:
CLASS EmailStrategy IMPLEMENTS Formattable, Deliverable, Trackable:
// Implements all three — because it genuinely needs all three
// SMS uses only two:
CLASS SmsStrategy IMPLEMENTS Formattable, Deliverable:
// No tracking. No fake "not implemented" methods. Clean.
Now each strategy only promises what it can actually deliver. No dead methods. No thrown exceptions. No lies.
Diagnosis #5: Dependency Inversion Violation → Abstract Factory
The symptom: Back in our original code, NotificationManager directly created NEW SmtpClient(...) and NEW TwilioClient(...). The high-level policy ("send a notification") depends on low-level details ("use this specific SMTP server on port 587").
The analogy: Your lamp shouldn't care whether your house runs on solar or coal. It plugs into a standard outlet (the abstraction). The power source is someone else's problem.
The prescription: The Abstract Factory pattern — depend on abstractions for creating the things you need, not on concrete constructors.
// Pseudo code — DIP violation (from original)
CLASS NotificationManager:
METHOD send(...):
smtp = NEW SmtpClient("mail.example.com", 587) // Hardcoded concrete!
db = NEW MySQLConnection("localhost", 3306) // Hardcoded concrete!
// Pseudo code — Abstract Factory fixes DIP
INTERFACE ServiceFactory:
METHOD create_mail_client() -> MailClient
METHOD create_logger() -> Logger
CLASS ProductionServiceFactory IMPLEMENTS ServiceFactory:
METHOD create_mail_client():
RETURN NEW SmtpClient(config.smtp_host, config.smtp_port)
METHOD create_logger():
RETURN NEW DatabaseLogger(config.db_connection)
CLASS TestServiceFactory IMPLEMENTS ServiceFactory:
METHOD create_mail_client():
RETURN NEW FakeMailClient() // No real emails in tests!
METHOD create_logger():
RETURN NEW InMemoryLogger() // No real database in tests!
// NotificationManager depends on the ABSTRACTION:
CLASS NotificationManager:
CONSTRUCTOR(factory: ServiceFactory): // Injected!
this.mailClient = factory.create_mail_client()
this.logger = factory.create_logger()
Now the high-level module (NotificationManager) depends on an abstraction (ServiceFactory), and the low-level details (SMTP, MySQL) depend on that same abstraction. The dependency arrow has been inverted — which is exactly what the principle's name means.
Photo by Yawen liao on Unsplash
The Complete Diagnostic Toolkit
Here's the cheat sheet. When you spot a symptom, look up the prescription:
| SOLID Violation | The Symptom You'll Feel | Pattern Prescription |
|---|
| SRP — Class does too much | You change one feature and break something unrelated | Factory Method (extract creation), Observer (extract side-effects) |
| OCP — Must edit to extend | Every new type/variant means modifying existing code | Strategy (swap behavior), Decorator (layer behavior), Template Method (override steps) |
| LSP — Subclass breaks contract | Substituting a subtype causes crashes or wrong behavior | Adapter (translate interface), Decorator (extend without lying) |
| ISP — Fat interface | Classes implement methods they don't use, throwing "not supported" | Facade (simplify), split into focused interfaces |
| DIP — Hardcoded dependencies | Can't test without real databases, APIs, file systems | Abstract Factory (create via abstraction), Strategy (inject behavior), Observer (decouple subscribers) |
When NOT to Apply This Toolkit
Here's the section that most SOLID tutorials skip — and it might be the most important one.
Ted Kaminski's Deconstructing SOLID makes a sharp observation: SOLID principles describe properties of well-designed code that needs to change frequently. If your code is small, stable, and unlikely to change, aggressively applying SOLID can make it worse.
Signs you're over-engineering with SOLID:
- You have interfaces with one implementation and no plans for a second. That's not abstraction — that's ceremony. Write the concrete class. Extract the interface later if you actually need it.
- Your simple feature spawned 6 new files. If adding "send a welcome email" requires a
WelcomeEmailStrategy, WelcomeEmailFactory, WelcomeEmailValidator, and WelcomeEmailConfig... you probably just need one function. - You can't trace a request through your code without a debugger. SOLID can scatter logic across so many small classes that the "what happens when a user clicks Send" story becomes a scavenger hunt.
- You're applying DIP to internal code that you fully control. Dependency Inversion shines at system boundaries (databases, APIs, third-party services). Inverting dependencies between two classes in the same module that always change together is overhead with no payoff.
The pragmatic rule: Apply SOLID at the seams — where your code meets things that change independently (new requirements, external systems, different deployment environments). Leave the interior simple.
The Workflow: Principles First, Patterns Second
Here's the practical process that ties everything together:
- Write simple, direct code. No patterns. No premature abstractions.
- Feel the pain. Notice when changes are hard, tests are brittle, or classes are bloating.
- Diagnose with SOLID. Which principle is being violated? Name it.
- Prescribe the pattern. Look up which pattern addresses that specific violation.
- Refactor incrementally. Small steps. Tests between each step.
- Stop when the pain is gone. You don't need to apply every principle to every class.
This is why SOLID and patterns are inseparable. Principles without patterns give you a diagnosis but no treatment. Patterns without principles give you a pharmacy but no way to know which medication you need.
The best developers aren't the ones who memorize all 23 GoF patterns. They're the ones who can feel a SOLID violation in their code, name it, and reach for the right pattern — or recognize that the code is fine as it is and move on.