Patternsintermediateschedule13 min read

SOLID Principles and Design Patterns: How They Work Together

Think of SOLID principles as the doctor's checklist and design patterns as the prescriptions. Learn the diagnostic link between the five SOLID principles and the GoF patterns that fix their violations — with a running codebase example that starts broken and progressively heals.

background pattern

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:

LetterPrincipleThe Analogy
SSingle ResponsibilityA chef cooks. A waiter serves. The chef doesn't take orders tableside.
OOpen/ClosedA power strip lets you plug in new devices without rewiring your house.
LLiskov SubstitutionIf your recipe says "any cheese," mozzarella and cheddar should both work. Substituting concrete for cheese would break the recipe.
IInterface SegregationA TV remote shouldn't have a "launch missiles" button just because the universal remote spec includes one.
DDependency InversionA 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.

hourglass_empty

Rendering diagram...

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.


a black and white photo of a pattern on a wall

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.


a close up of a white wall with black circles

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 ViolationThe Symptom You'll FeelPattern Prescription
SRP — Class does too muchYou change one feature and break something unrelatedFactory Method (extract creation), Observer (extract side-effects)
OCP — Must edit to extendEvery new type/variant means modifying existing codeStrategy (swap behavior), Decorator (layer behavior), Template Method (override steps)
LSP — Subclass breaks contractSubstituting a subtype causes crashes or wrong behaviorAdapter (translate interface), Decorator (extend without lying)
ISP — Fat interfaceClasses implement methods they don't use, throwing "not supported"Facade (simplify), split into focused interfaces
DIP — Hardcoded dependenciesCan't test without real databases, APIs, file systemsAbstract 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:

  1. Write simple, direct code. No patterns. No premature abstractions.
  2. Feel the pain. Notice when changes are hard, tests are brittle, or classes are bloating.
  3. Diagnose with SOLID. Which principle is being violated? Name it.
  4. Prescribe the pattern. Look up which pattern addresses that specific violation.
  5. Refactor incrementally. Small steps. Tests between each step.
  6. 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.

Frequently Asked Questions

helpDo I need to apply all five SOLID principles to every class I write?

No. SOLID principles are guidelines, not laws. Apply them where they reduce real pain — growing conditionals, rigid dependencies, hard-to-test code. A small utility class with one job and no expected changes doesn't need an interface, a factory, and a dependency injection framework. Robert C. Martin himself has said the principles are about managing dependency structures in code that is expected to change. If it's not changing, leave it alone.

helpWhich came first — SOLID or design patterns?

Design patterns came first. The Gang of Four published their 23 patterns in 1994. Robert C. Martin formalized the principles that became SOLID in his 2000 paper 'Design Principles and Design Patterns,' and Michael Feathers coined the SOLID acronym around 2004. The interesting thing is that the GoF patterns already followed these principles intuitively — SOLID just gave us the vocabulary to explain why the patterns work.

helpCan I use SOLID principles in functional programming, or are they only for OOP?

They absolutely apply beyond OOP. Robert C. Martin's 2023 book 'Functional Design: Principles, Patterns, and Practices' explicitly extends SOLID concepts to functional programming. Single Responsibility maps to pure functions that do one thing. Open/Closed maps to higher-order functions. Dependency Inversion maps to passing functions as arguments instead of hardcoding calls. The shapes change, but the ideas are universal.

helpWhat's the most common mistake developers make when learning SOLID?

Creating abstractions before they have a reason to. New SOLID learners often create an interface for every class 'just in case' they need a second implementation later. This produces code with dozens of single-implementation interfaces that add indirection without value. The better approach: write concrete code first, then extract an interface when you actually need a second implementation or when testing demands it.

helpHow do SOLID principles relate to code smells?

SOLID violations ARE code smells — they're just described from the principle side rather than the symptom side. A class doing too many things (SRP violation) IS the God Class smell. A function you can't extend without modifying (OCP violation) IS the Conditional Complexity smell. Learning both vocabularies helps you diagnose problems from either direction and converge on the same fix.