DEV Community

Mahid Shamlou | SOLID Principles in Python (2026): The Foundation Behind Every Design Pattern

Mahdi Shamlou here.

Across this whole series — OOP, Design Patterns, Architecture — one set of ideas kept showing up without ever getting its own spotlight: SOLID.

Mahdi Shamlou

Every "bad code" example in my Design Patterns articles violated one of these five principles. Every pattern I showed you was a solution to one of these five principles. SOLID isn't a sixth thing to learn — it's the reasoning underneath everything you've already learned.

I've seen:

  • A UserService class that handles validation, database access, email sending, and logging — all in one class
  • A reports module where adding a new report format means editing five existing functions
  • A subclass that throws NotImplementedError on a method its parent promises to support
  • Interfaces so bloated that classes implement six methods just to use one
  • Business logic directly importing and calling a specific database driver

Each of these has a name. Let's go through them.


What Is SOLID?

SOLID is five principles for writing object-oriented code that's easy to maintain and extend, coined by Robert C. Martin ("Uncle Bob"):

Mahdi Shamlou

  1. S — Single Responsibility Principle
  2. O — Open/Closed Principle
  3. L — Liskov Substitution Principle
  4. I — Interface Segregation Principle
  5. D — Dependency Inversion Principle

None of these are Python-specific — they came from the Java/C++ world — but they apply just as much here, sometimes in a more relaxed form thanks to duck typing.


1. Single Responsibility Principle (SRP)

A class should have one, and only one, reason to change.

"Responsibility" here means "reason to change" — not "does one thing" in a trivial sense.

Bad code (multiple responsibilities crammed into one class)

class UserService:
    def __init__(self, db_connection):
        self.db = db_connection

    def create_user(self, name, email):
        # Validation
        if "@" not in email:
            raise ValueError("Invalid email")

        # Persistence
        self.db.execute("INSERT INTO users (name, email) VALUES (?, ?)", (name, email))

        # Notification
        print(f"Sending welcome email to {email}")

        # Logging
        with open("app.log", "a") as f:
            f.write(f"User created: {name}\n")

# This class changes if validation rules change, if the database changes,
# if the email provider changes, or if the logging format changes.
# Four unrelated reasons to touch the same class.
Enter fullscreen mode Exit fullscreen mode

Good code (one responsibility per class)

class EmailValidator:
    def validate(self, email):
        if "@" not in email:
            raise ValueError("Invalid email")

class UserRepository:
    def __init__(self, db_connection):
        self.db = db_connection

    def save(self, name, email):
        self.db.execute("INSERT INTO users (name, email) VALUES (?, ?)", (name, email))

class WelcomeEmailSender:
    def send(self, email):
        print(f"Sending welcome email to {email}")

class Logger:
    def log(self, message):
        with open("app.log", "a") as f:
            f.write(f"{message}\n")

class UserService:
    def __init__(self, repository, validator, mailer, logger):
        self.repository = repository
        self.validator = validator
        self.mailer = mailer
        self.logger = logger

    def create_user(self, name, email):
        self.validator.validate(email)
        self.repository.save(name, email)
        self.mailer.send(email)
        self.logger.log(f"User created: {name}")

# UserService now orchestrates — it doesn't implement everything itself.
# Change the email provider? Only WelcomeEmailSender changes.
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Each class is small, testable, and easy to understand in isolation
  • Changes are localized — you know exactly which class to touch
  • Classes can be reused independently (the Logger works for anything, not just users)

Cons:

  • More classes and files to navigate for simple features
  • Can be over-applied — splitting a genuinely cohesive two-line method into three classes is not SRP, it's noise

My Take:

SRP doesn't mean "one method per class." It means "one reason to change." If validation rules and database schema change independently of each other, they're two responsibilities, even if today the code for both fits in five lines.

The key insight: If you can't name the single responsibility of a class in one sentence without using the word "and," it has more than one.

One more thing worth knowing: a "reason to change" usually traces back to a different person or team who'd request it — the marketing team wants the welcome email reworded, the DBA wants the schema changed, the compliance team wants the log format changed. If two changes always come from the same stakeholder, they might genuinely be one responsibility. If they come from different stakeholders, split them — even if today the code for both fits in five lines.


2. Open/Closed Principle (OCP)

Software entities should be open for extension, but closed for modification. In other words, new requirements should usually mean writing new code—not rewriting old, working code.

You should be able to add new behavior without editing existing, working code.

Bad code (must modify existing code for every new type)

class DiscountCalculator:
    def calculate(self, customer_type, amount):
        if customer_type == "regular":
            return amount
        elif customer_type == "premium":
            return amount * 0.9
        elif customer_type == "vip":
            return amount * 0.8
        # Adding a new customer type means editing this method again
        # and risking breaking the existing branches.
Enter fullscreen mode Exit fullscreen mode

Good code (extend without touching existing code)

from abc import ABC, abstractmethod

class DiscountStrategy(ABC):
    @abstractmethod
    def apply(self, amount):
        pass

class RegularDiscount(DiscountStrategy):
    def apply(self, amount):
        return amount

class PremiumDiscount(DiscountStrategy):
    def apply(self, amount):
        return amount * 0.9

class VipDiscount(DiscountStrategy):
    def apply(self, amount):
        return amount * 0.8

class DiscountCalculator:
    def calculate(self, strategy: DiscountStrategy, amount):
        return strategy.apply(amount)

# New customer type? Add a new class. DiscountCalculator never changes.
class StudentDiscount(DiscountStrategy):
    def apply(self, amount):
        return amount * 0.85

calc = DiscountCalculator()
print(calc.calculate(StudentDiscount(), 100))  # 85.0
Enter fullscreen mode Exit fullscreen mode

This is the Strategy pattern in action — OCP is why Strategy exists.

Pros:

  • New functionality doesn't risk breaking existing, tested code
  • Encourages composition and polymorphism over long conditionals
  • Makes code more predictable to extend over time

Cons:

  • Requires anticipating that extension points are needed — premature abstraction is a real risk
  • Adds indirection (interfaces, abstract classes) that can be overkill for code that truly never changes

My Take:

OCP doesn't mean "abstract everything, just in case." It means: once you notice a piece of code keeps growing new elif branches, that's the signal to introduce an extension point — not before.

The key insight: Don't design for hypothetical future requirements. Refactor toward OCP the moment real requirements start repeating the same conditional shape.

One clarification, since this is the most misread principle on the list: OCP does not mean "never touch a file again." It means the abstraction (DiscountStrategy) stays closed once it's stable, while new implementations (StudentDiscount) extend it freely. DiscountCalculator itself is still allowed to change — just not every time a new customer type shows up.


3. Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types without breaking the program.

If Bird promises fly(), every subclass of Bird must be able to actually fly — or Bird shouldn't promise it.

Bad code (subclass breaks the parent's contract)

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def set_width(self, width):
        self.width = width

    def set_height(self, height):
        self.height = height

    def area(self):
        return self.width * self.height

class Square(Rectangle):
    """A square IS a rectangle... right?"""
    def set_width(self, width):
        self.width = width
        self.height = width  # forces height to match — breaks the parent's behavior

    def set_height(self, height):
        self.width = height
        self.height = height

def resize_and_check(rect: Rectangle):
    rect.set_width(5)
    rect.set_height(10)
    assert rect.area() == 50, "Area should be width * height"

resize_and_check(Rectangle(2, 2))  # passes
resize_and_check(Square(2, 2))     # AssertionError! area is 100, not 50

# Square LOOKS like a valid Rectangle subclass, but it silently changes behavior
# in a way that breaks code written against the Rectangle contract.
Enter fullscreen mode Exit fullscreen mode

Good code (don't force an "is-a" that isn't behaviorally true)

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

class Square(Shape):
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side ** 2

# Square is no longer pretending to be a mutable Rectangle.
# Both honor the same Shape contract without hidden surprises.
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Code that works with a base type can safely work with any subtype, no surprises
  • Prevents subtle bugs where a subclass "technically" fits but behaves inconsistently
  • Encourages thinking about behavior, not just structural similarity, when designing hierarchies

Cons:

  • Requires genuinely thinking through behavioral contracts, not just "is this conceptually a subtype?"
  • Sometimes means avoiding a tempting but flawed inheritance relationship (like Square/Rectangle)

My Take:

LSP is the principle that catches "well it's technically an is-a relationship" mistakes. The classic Square/Rectangle problem exists precisely because mathematical is-a and behavioral is-a aren't the same thing. Trust behavior over taxonomy.

The key insight: If using a subclass anywhere the parent is expected requires the caller to know which subclass they got, LSP is already broken.


4. Interface Segregation Principle (ISP)

Clients shouldn't be forced to depend on methods they don't use.

Many small, focused interfaces beat one large, general-purpose one.

Bad code (a bloated interface forces irrelevant methods on everyone)

from abc import ABC, abstractmethod

class Worker(ABC):
    @abstractmethod
    def work(self):
        pass

    @abstractmethod
    def eat(self):
        pass

    @abstractmethod
    def sleep(self):
        pass

class HumanWorker(Worker):
    def work(self):
        print("Human working")

    def eat(self):
        print("Human eating lunch")

    def sleep(self):
        print("Human sleeping")

class RobotWorker(Worker):
    def work(self):
        print("Robot working")

    def eat(self):
        raise NotImplementedError("Robots don't eat!")  # forced to implement anyway

    def sleep(self):
        raise NotImplementedError("Robots don't sleep!")

# RobotWorker is forced to implement methods that make no sense for it,
# just because they were bundled into one fat interface.
Enter fullscreen mode Exit fullscreen mode

Good code (small, role-specific interfaces)

from abc import ABC, abstractmethod

class Workable(ABC):
    @abstractmethod
    def work(self):
        pass

class Eatable(ABC):
    @abstractmethod
    def eat(self):
        pass

class Sleepable(ABC):
    @abstractmethod
    def sleep(self):
        pass

class HumanWorker(Workable, Eatable, Sleepable):
    def work(self):
        print("Human working")

    def eat(self):
        print("Human eating lunch")

    def sleep(self):
        print("Human sleeping")

class RobotWorker(Workable):
    def work(self):
        print("Robot working")

# RobotWorker only implements what actually applies to it.
Enter fullscreen mode Exit fullscreen mode

Pros:

  • No class is forced to implement irrelevant, meaningless methods
  • Interfaces stay focused and easier to understand
  • Changes to one capability (e.g., Sleepable) don't ripple to classes that don't use it

Cons:

  • More interfaces to define and track
  • Can fragment a genuinely cohesive interface if split too aggressively

My Take:

Python's duck typing makes ISP violations less common than in Java, since you're rarely forced to implement an entire interface just to use one method. But it still bites you with ABC hierarchies and Protocol definitions — keep them narrow.

The key insight: If implementing an interface means writing raise NotImplementedError for half its methods, the interface is too big.

A more modern-Python way to write this: instead of ABC, you can use typing.Protocol for structural typing — a class satisfies Workable just by having a matching work() method, with no explicit inheritance required:

from typing import Protocol

class Workable(Protocol):
    def work(self) -> None: ...

class RobotWorker:
    def work(self):
        print("Robot working")

# RobotWorker satisfies Workable automatically — no "class RobotWorker(Workable)" needed.
Enter fullscreen mode Exit fullscreen mode

This fits ISP even better than ABC in many cases, since nothing is forced onto a class it never explicitly agreed to.


5. Dependency Inversion Principle (DIP)

High-level modules shouldn't depend on low-level modules. Both should depend on abstractions.

This is the principle behind Hexagonal Architecture from the previous article — business logic shouldn't know about specific frameworks or databases.

Bad code (high-level logic tightly coupled to a low-level detail)

class MySQLDatabase:
    def save(self, data):
        print(f"Saving {data} to MySQL")

class OrderService:
    def __init__(self):
        self.db = MySQLDatabase()  # hardcoded dependency on a concrete class

    def place_order(self, order):
        self.db.save(order)

# OrderService can NEVER use a different database without editing this class.
# Testing OrderService also means testing against a real (or mocked) MySQLDatabase.
Enter fullscreen mode Exit fullscreen mode

Good code (depend on an abstraction, inject the concrete implementation)

from abc import ABC, abstractmethod

class Database(ABC):
    @abstractmethod
    def save(self, data):
        pass

class MySQLDatabase(Database):
    def save(self, data):
        print(f"Saving {data} to MySQL")

class PostgresDatabase(Database):
    def save(self, data):
        print(f"Saving {data} to Postgres")

class InMemoryDatabase(Database):
    """A fake database — perfect for tests, no real DB needed"""
    def __init__(self):
        self.storage = []

    def save(self, data):
        self.storage.append(data)

class OrderService:
    def __init__(self, db: Database):
        self.db = db  # depends on the abstraction, not a specific database

    def place_order(self, order):
        self.db.save(order)

# Production
service = OrderService(PostgresDatabase())
service.place_order("Order #123")

# Testing — no real database needed at all
test_service = OrderService(InMemoryDatabase())
test_service.place_order("Test Order")
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Swapping implementations (MySQL → Postgres → in-memory for tests) requires zero changes to business logic
  • Massively improves testability — inject fakes instead of hitting real infrastructure
  • Decouples the "what" (business rules) from the "how" (specific tools)

Cons:

  • Adds an abstraction layer even when you're confident you'll never swap the implementation
  • Can lead to "interface for everything" overkill in small codebases

My Take:

DIP is the principle behind dependency injection, and it's why your OrderService should never do from mysql_driver import connect directly. This is the exact idea Hexagonal Architecture builds an entire system-level structure around — DIP is just that idea applied one class at a time.

The key insight: Business logic should define the interface it needs; infrastructure should implement that interface — never the other way around.

Notice where Database is defined: conceptually, it belongs next to OrderService, not next to MySQLDatabase. The high-level module owns the contract; the low-level module just agrees to honor it. That's the "inversion" in Dependency Inversion — without it, you'd expect business logic to depend on infrastructure, but here infrastructure depends on a contract the business logic defines.


Comparison Table

Principle One-Line Rule What It Prevents
SRP One reason to change per class God classes doing five unrelated jobs
OCP Extend without modifying existing code Editing tested code every time requirements grow
LSP Subtypes must behave like their base type Subclasses that quietly break the parent's contract
ISP Don't force unused methods on implementers Bloated interfaces nobody fully implements honestly
DIP Depend on abstractions, not concrete implementations Business logic hardwired to specific infrastructure

Key Takeaways

  1. SRP — If you need "and" to describe a class's job, split it
  2. OCP — New requirements should mean new code, not edited code
  3. LSP — A subclass must be safe to use anywhere the parent is expected
  4. ISP — Small, role-specific interfaces beat one bloated one
  5. DIP — Business logic depends on abstractions; infrastructure depends on those same abstractions

The Golden Rule of SOLID:

Every design pattern you've learned is SOLID applied to a specific shape of problem. Learn the principles, and you stop needing to memorize the patterns — you start deriving them.

SOLID isn't a checklist to run through on every class. It's a set of smells to notice: giant classes, endless elif chains, subclasses that throw NotImplementedError, fat interfaces, and hardcoded infrastructure. Fix the smell, and you've applied the principle — whether or not you consciously named it.


Design Patterns & Architecture Series

  1. Creational Patterns — How to create objects properly
  2. Structural Patterns - Part 1 — Adapter, Bridge, Composite, Decorator
  3. Structural Patterns - Part 2 — Facade, Flyweight, Proxy
  4. Behavioral Patterns — How objects communicate
  5. OOP in Python — The foundations everything else builds on
  6. SOLID Principles (This Article) — The reasoning behind every pattern above

Want More Deep Dives?

Mahdi Shamlou

If you enjoyed this article, check out my other production-focused guides:


🔗 LinkedIn: https://www.linkedin.com/in/mahdi-shamlou-3b52b8278
📱 Telegram: https://telegram.me/mahdi0shamlou
📸 Instagram: https://www.instagram.com/mahdi0shamlou/

Author: Mahdi Shamlou | مهدی شاملو

Top comments (0)