Tutorial for understanding and applying SOLID principles in Python

  • The SOLID principles provide five key rules for designing classes in Python with clear responsibilities, low coupling, and high cohesion.
  • Applying SRP, OCP, LSP, ISP, and DIP with classes, ABC, and composition allows you to extend the system without breaking existing code.
  • The use of abstractions and small interfaces facilitates testing, reuse, and safe long-term software evolution.

SOLID principles in Python

If you program in Python and want to take your code organization to the next level, SOLID principles are a game-changer. They're not a fad or an academic whim: they are five practical rules that help you write clearer software that is easier to test and much simpler to maintain over time.

Although they originated in the world of classic object-oriented programming (Java, C++, C#…), they can be applied without problem to Python and its object modelIn this tutorial, we'll take a step-by-step look, using examples, at how to understand and apply SOLID in Python: what each principle means, where it comes from, what real-world problems it solves, and how it translates into everyday classes, methods, and interfaces.

What are the SOLID principles and where did they come from?

The term SOLID is a well-known acronym in software design. It was popularized by Michael Feathers based on a set of principles defined by Robert C. Martin (Uncle Bob), Bertrand Meyer, and Barbara Liskov, among others. The idea was to condense into five letters a minimal guide for creating object-oriented designs that wouldn't become a nightmare as they grew.

These principles became famous as a result of Uncle Bob's essay “Design Principles and Design Patterns” and from books like CleanCode o Clean ArchitectureFurthermore, they fit very well with other clean coding rules such as DRY (Don't Repeat Yourself) o KISS (Keep It Simple, Stupid)all of them aimed at reducing complexity and unnecessary dependencies.

Jotta-cli on Linux
Related article:
How to schedule automatic tasks with Crontab in Linux

The name SOLID comes from the first letter of each principle in English, which you should memorize because you'll see them in any remotely serious conversation about object-oriented architecture:

  • S – Single Responsibility Principle (SRP): principle of sole responsibility.
  • O – Open/Closed Principle (OCP): open/closed principle.
  • L – Liskov Substitution Principle (LSP)Liskov substitution principle.
  • I – Interface Segregation Principle (ISP): principle of interface segregation.
  • D – Dependency Inversion Principle (DIP): principle of dependency reversal.

Their common goal is to make your classes coherent, decoupled, and predictableFollowing them not only helps you write better new code, but also makes maintaining a project that grows over the years or that involves several teams working simultaneously much easier.

Why is it worthwhile to apply SOLID in Python?

Although Python is a flexible language that allows for very different styles, the SOLID principles provide a robust design base for any medium or large project. Adopting them usually translates into more agile deployments, fewer regressions when touching old code, and less "code smell" (that strange smell that tells you something is wrong even though it works).

Among the clearest benefits of applying SOLID in your Python developments are a greater clarity and legibility, and the use of Essential IDEs and editors This helps maintain it, since classes have well-defined responsibilities, interfaces are small, and modules don't know each other too well. This makes opening someone else's file not like reading hieroglyphics, but something reasonably understandable.

It's also very noticeable in the maintenance and evolution of the systemA solid design (literally) allows you to add new features by extending existing behaviors, not by breaking what's already there. This reduces the number of traumatic refactorings and the dreaded "spaghetti code" that no one dares touch without breaking three things, and allows you to work with best IDEs for programming It facilitates those refactorings.

Another key point is the reduction of errors and vulnerabilitiesFewer cross-dependencies mean fewer places where a silly change in one class impacts a completely different one. And when you have to fix bugs, finding the source of a problem in a module with a single responsibility is much easier.

Finally, following SOLID helps keep phenomena like code smell (bad designer smells), code rot (code that “rots” over time) or the appearance of “spaghetti code” full of hidden dependencies. In agile contexts, where many people work on the same code, these rules make the difference between a living system and one that ends up being almost unmodifiable.

S – Single Responsibility Principle (SRP) in Python

SOLID principles in Python

The Single Responsibility Principle states that Each class should have only one reason to change.This does not mean that a class can only have one method, nor that it only performs "one microtask," but rather that it must be responsible for a single, well-defined axis of change.

In practice, this means that a class should represent a clear entity or type of service: a system user, an order, a data repository, a report generator, a validator, etc. When you start mixing business logic, database access, report formatting, and email sending in the same class, you've crossed the line.

For example, imagine a Python class that represents a user and does a little bit of everything:

class User:
    def __init__(self, name: str):
        self.name = name

    def get_user_from_database(self, user_id: int) -> dict:
        # Lógica de acceso a base de datos
        pass

    def save_user_to_database(self) -> None:
        # Lógica de persistencia
        pass

    def generate_user_report(self) -> str:
        # Lógica de generación de informes
        pass

Here the class is mixed domain model, persistence, and reportingChanges in any of these areas require modifying the same class, multiplying the risk of side effects and making isolated unit tests more difficult.

A more sensible application of SRP involves clearly separating responsibilities into several coordinated classes:

class User:
    def __init__(self, name: str):
        self.name = name


class UserDB:
    @staticmethod
    def get_user(user_id: int) -> User:
        # Obtiene usuarios de la BBDD
        return User("John Doe")

    @staticmethod
    def save_user(user: User) -> None:
        # Guarda un usuario
        pass


class UserReportGenerator:
    @staticmethod
    def generate_report(user: User) -> str:
        return f"Report for user: {user.name}"

Now each class has one clear responsibilityIf the way to save users changes, you'll just need to tap UserDBIf the report format changes, you will adjust UserReportGenerator without risk of breaking persistence.

Another common example in Python is when a domain class is overloaded with object interaction. Consider this duck class with several capabilities:

class Duck:
    def __init__(self, name: str):
        self.name = name

    def fly(self):
        print(f"{self.name} is flying not very high")

    def swim(self):
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

    def greet(self, duck2: "Duck"):
        print(f"{self.name}: {self.do_sound()}, hello {duck2.name}")

Here the class deals with both define the duck as well as the logic of communication between ducks. If you change the way they greet each other, you'll have to touch the very definition of Duck, which introduces a second reason to switch.

An SRP-compliant solution involves moving the conversational logic to a separate, dedicated class:

class Duck:
    def __init__(self, name: str):
        self.name = name

    def fly(self):
        print(f"{self.name} is flying not very high")

    def swim(self):
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"


class Communicator:
    def __init__(self, channel: str):
        self.channel = channel

    def communicate(self, duck1: Duck, duck2: Duck):
        sentence1 = f"{duck1.name}: {duck1.do_sound()}, hello {duck2.name}"
        sentence2 = f"{duck2.name}: {duck2.do_sound()}, hello {duck1.name}"
        conversation = 
        print(*conversation, f"(via {self.channel})", sep="\n")

Thanks to this separation, you can change the communication protocol creating other communicative classes or specific conversations, without having to rewrite the duck domain class itself.

O – Open/Closed Principle (OCP) in extensible designs

The Open/Closed Principle states that a software entity must be Open to expansion, but closed to modificationIn other words, you should be able to add new features without touching code that is already tested and in production.

In Python, this is usually achieved by combining Inheritance, abstract classes, and compositionThe trick is to design your classes so that new features arrive in the form of extensions (new subclasses, new injected objects), not as a cascade of if/elif about specific types that force you to edit the same module every time you add something.

A classic example is a poorly designed area calculator that inspects specific types:

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


class Circle:
    def __init__(self, radius: float):
        self.radius = radius


class AreaCalculator:
    def calculate_area(self, shape) -> float:
        if isinstance(shape, Rectangle):
            return shape.width * shape.height
        elif isinstance(shape, Circle):
            return 3.14159 * shape.radius * shape.radius
        else:
            raise ValueError("Forma no soportada")

This design violates OCP because each new geometric figure involves touching AreaCalculator and put another one in elifThe module is no longer "closed" to modifications.

By applying OCP, we move the area logic to each shape and work with a common abstraction:

from abc import ABC, abstractmethod


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


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

    def area(self) -> float:
        return self.width * self.height


class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return 3.14159 * self.radius * self.radius


class AreaCalculator:
    def calculate_area(self, shape: Shape) -> float:
        return shape.area()

If you need triangles tomorrow, you'll just need to create another subclass of Shape that implements area(), without touching the calculator:

class Triangle(Shape):
    def __init__(self, base: float, height: float):
        self.base = base
        self.height = height

    def area(self) -> float:
        return 0.5 * self.base * self.height

This idea can also be applied to the example of ducks and communication. Imagine you want different types of conversation without changing the basic communicative class. You can introduce a conversation abstraction and have the communicator execute it automatically:

from typing import final


class AbstractConversation:
    def do_conversation(self) -> list:
        pass


class SimpleConversation(AbstractConversation):
    def __init__(self, duck1: Duck, duck2: Duck):
        self.duck1 = duck1
        self.duck2 = duck2

    def do_conversation(self) -> list:
        s1 = f"{self.duck1.name}: {self.duck1.do_sound()}, hello {self.duck2.name}"
        s2 = f"{self.duck2.name}: {self.duck2.do_sound()}, hello {self.duck1.name}"
        return 


class Communicator:
    def __init__(self, channel: str):
        self.channel = channel

    @final
    def communicate(self, conversation: AbstractConversation):
        print(*conversation.do_conversation(), f"(via {self.channel})", sep="\n")

The method communicate is Closed to changes and new forms of conversation are added by creating more subclasses of AbstractConversationYou don't need to revisit the communication class every time you want a different dialogue.

L – Liskov Substitution Principle (LSP) and well-understood inheritance

The Liskov Substitution Principle states that a subclass must be able to seamlessly replace your superclass anywhere the latter is used, without changing the expected behavior of the program.

Formally, the base class contract (return types, preconditions, postconditions, invariants) must continue to be fulfilled in derived classes. If a subclass breaks that contract—for example, by throwing unexpected exceptions or excessively restricting what can be done—the code that depends on the base class will begin to fail in subtle ways.

A typical example of LSP violation is naively modeling flying and non-flying birds:

class Bird:
    def fly(self) -> None:
        pass


class Duck(Bird):
    def fly(self) -> None:
        print("¡El pato está volando!")


class Ostrich(Bird):
    def fly(self) -> None:
        # Las avestruces no pueden volar
        raise NotImplementedError("Las avestruces no pueden volar")

Any function that receives a Bird and call fly() hoping it works, it will fail if something happens to it. OstrichThe subclass is no longer a valid substitute from your father, so you've broken LSP.

A correct way to model it is to introduce a intermediate abstraction For birds that do fly:

class Bird:
    pass


class FlyingBird(Bird):
    def fly(self) -> None:
        pass


class Duck(FlyingBird):
    def fly(self) -> None:
        print("¡El pato está volando!")


class Ostrich(Bird):
    # No implementa fly() porque no vuela
    pass

Now any function that works with FlyingBird knows he can call fly() without surprises, while a function that accepts a Bird generic should not assume that everyone flies.

How to use Python to create a Telegram bot
Related article:
Excel with Python: A complete guide to mastering both

Something similar happens with classic examples like rectangle and squareAt a mathematical level, a square is a rectangle, but at an object-oriented design level, this inheritance often causes problems because the restrictions of a square (all sides equal) make the height and width setters behave differently than expected by code that only knows rectangles.

A common strategy in these cases is Avoid forcing inheritance relationships just because they “seem” natural In the real world, in many domains it is better to have separate classes without direct inheritance and, if necessary, a more generic common abstraction.

I – Interface Segregation Principle (ISP) and small interfaces

SOLID principles in Python

The Interface Segregation Principle states that A customer should not be forced to rely on methods they do not use.In other words: it's better to have several small, specific interfaces than one large, generic interface full of methods that some classes don't need.

In Python, although you don't have interfaces like in Java, you can use abstract classes (ABC) or protocols (with typing.Protocol) to achieve the same effect: describe minimum capabilities that each implementation will actually use.

Let's first look at an example that violates ISP with an overly broad interface:

from abc import ABC, abstractmethod


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

    @abstractmethod
    def eat(self) -> None:
        pass


class Human(Worker):
    def work(self) -> None:
        print("El humano está trabajando")

    def eat(self) -> None:
        print("El humano está comiendo")


class Robot(Worker):
    def work(self) -> None:
        print("El robot está trabajando")

    def eat(self) -> None:
        # Los robots no comen, pero se ven obligados a implementar este método
        pass

Class Robot It depends on a method irrelevant to its natureAlthough we've left it blank here, in more complex designs this leads to exceptions, inconsistent states, or false logic just to comply with the interface.

The ISP-friendly version separates capabilities into smaller interfaces, which are then combined as needed:

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


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


class Human(Workable, Eatable):
    def work(self) -> None:
        print("El humano está trabajando")

    def eat(self) -> None:
        print("El humano está comiendo")


class Robot(Workable):
    def work(self) -> None:
        print("El robot está trabajando")

The same pattern can be applied to the case of birds that fly and swim. Instead of an interface Bird which forces all subclasses to implement fly() y swim()You can segregate the hierarchy:

from abc import ABC, abstractmethod


class Bird(ABC):
    def __init__(self, name: str):
        self.name = name

    @abstractmethod
    def do_sound(self) -> str:
        pass


class FlyingBird(Bird):
    @abstractmethod
    def fly(self):
        pass


class SwimmingBird(Bird):
    @abstractmethod
    def swim(self):
        pass


class Crow(FlyingBird):
    def fly(self):
        print(f"{self.name} is flying high and fast!")

    def do_sound(self) -> str:
        return "Caw"


class Duck(SwimmingBird, FlyingBird):
    def fly(self):
        print(f"{self.name} is flying not very high")

    def swim(self):
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

If you wanted to model a penguin, you would simply extend SwimmingBird without implementing fly()Because That interface is no longer imposed artificially..

D – Dependency Inversion Principle (DIP) and dependence of abstractions

The Dependency Inversion Principle can be summarized in two sentences: high-level modules should not depend on low-level modules; both must depend on abstractionsAnd abstractions should not depend on details, but rather details should depend on abstractions.

In practical terms, this means that your (high-level) business logic shouldn't be directly coupled to concrete implementations of databases, external APIs, file systems, etc. Instead, define interfaces (or abstract classes) that describe what you need and make the concrete implementations dependent on those abstractions.

A typical example of a DIP violation is a repository that directly instantiates its concrete database:

class MySQLDatabase:
    def connect(self) -> None:
        # Conectar a MySQL
        pass

    def query(self, sql: str) -> list:
        # Ejecutar consulta
        return []


class UserRepository:
    def __init__(self):
        self.database = MySQLDatabase()  # Dependencia directa

    def get_users(self) -> list:
        return self.database.query("SELECT * FROM users")

Here's the repository. tied to MySQLIf you want to switch to PostgreSQL or use an in-memory database for testing, you'll be modifying the production code in the repository, which goes against OCP and makes the system less flexible.

By applying DIP, we introduce an abstraction Database and we make both MySQL and PostgreSQL implement it. The repository will then depend solely on that abstraction:

from abc import ABC, abstractmethod


class Database(ABC):
    @abstractmethod
    def connect(self) -> None:
        pass

    @abstractmethod
    def query(self, sql: str) -> list:
        pass


class MySQLDatabase(Database):
    def connect(self) -> None:
        # Conectar a MySQL
        pass

    def query(self, sql: str) -> list:
        return []


class PostgreSQLDatabase(Database):
    def connect(self) -> None:
        # Conectar a PostgreSQL
        pass

    def query(self, sql: str) -> list:
        return []


class UserRepository:
    def __init__(self, database: Database):
        self.database = database  # Depende de una abstracción

    def get_users(self) -> list:
        return self.database.query("SELECT * FROM users")

This way of constructing objects, where You inject the dependencies from outside (constructor, setter or method parameter), is known as Dependency Injection and is the usual way to implement DIP in clean architectures.

The same approach can be applied to other contexts, such as the communication channel of our birds. Instead of a specific communicator creating its own message internally, SMSChannelYou can make any communicator depend on a channel abstraction:

from abc import ABC
from typing import final


class AbstractChannel(ABC):
    def get_channel_message(self) -> str:
        pass


class AbstractCommunicator(ABC):
    def get_channel(self) -> AbstractChannel:
        pass

    @final
    def communicate(self, conversation: AbstractConversation):
        print(*conversation.do_conversation(),
              self.get_channel().get_channel_message(),
              sep="\n")

If in a specific implementation you do this:

class SMSChannel(AbstractChannel):
    def get_channel_message(self) -> str:
        return "(via SMS)"


class SMSCommunicator(AbstractCommunicator):
    def __init__(self):
        self._channel = SMSChannel()

    def get_channel(self) -> AbstractChannel:
        return self._channel

You still have a direct coupling between SMSCommunicator y SMSChannelTo fully respect DIP, you can create a simple communicator that receives the channel as an external dependency:

class SimpleCommunicator(AbstractCommunicator):
    def __init__(self, channel: AbstractChannel):
        self._channel = channel

    def get_channel(self) -> AbstractChannel:
        return self._channel

In this way, any new implementation of AbstractChannel (email, push, internal notification…) can be used without changing the communicator. Both high-level and low-level modules depend on abstractions., as the principle states.

When does it make sense to break or relax SOLID

Although we have portrayed the SOLID principles here as a kind of compass for object-oriented design, it is important to understand that they are guides, not immutable dogmasIn small scripts, quick prototypes, or simple automation tasks in Python, applying all these layers can be like using a cannon to kill flies.

Where they truly shine is in large, long-lived, or highly collaborative projectsMicroservices with multiple teams, complex data platforms, backends that will evolve over years, or software where quality and security are critical—these are the situations where the initial effort to carefully consider abstractions, separate responsibilities, design for extensibility, and invert dependencies truly pays off.

Ultimately, internalizing SOLID gives you a very useful vocabulary for reasoning about design and for detecting early code smells: classes that do everything, bloated interfaces, poorly planned inheritance hierarchies, or high-level modules glued to infrastructure details.

Excel with Python
Related article:
Excel with Python: Integrating scripts and automating analysis

If you use these principles intelligently and with some common sense, your Python code will tend to be cleaner, more professional, and easier to maintain, even as the project grows and the people working on it change. Share this Python programming guide so other users can learn about SOLID principles.