Software

SOLID Principles with Examples: The Art of Writing Clean Code

Talha AslanTalha Aslan 18 min read

SOLID principles are five design rules that keep object-oriented code easy to change. I have worked on web projects, e-commerce platforms and internal dashboards since 2012. In my experience, how comfortable a project feels in its second year depends heavily on how seriously the team took these five rules on day one. In this guide I walk through each principle with short Python examples, first broken and then fixed.

I assume you already know the basics of object-oriented programming: classes, inheritance, encapsulation and polymorphism. I will not repeat them here. Instead, I focus on which design decisions lock your code in place later and which ones keep it free.

What are SOLID principles, and why do they still matter for clean code?

SOLID principles are five object-oriented design guidelines, collected by Robert C. Martin, whose initials form the acronym: single responsibility, open/closed, Liskov substitution, interface segregation and dependency inversion. Their shared goal is simple. You should be able to add a feature without breaking code that already works.

So why do they still matter? Because the real cost of software shows up in maintenance, not in the first draft. You write a function once, but you read it, change it and test it for years. SOLID principles aim to make that second phase cheaper.

The ideas are also language independent, so they travel well. I wrote the examples in Python. However, the same logic applies to Java, C#, TypeScript, PHP or Kotlin. Only the syntax changes.

Where did the SOLID principles come from?

Most of the ideas predate Robert C. Martin. Bertrand Meyer described the open/closed principle in his 1988 book "Object-Oriented Software Construction". The Liskov substitution principle goes back to a 1987 keynote by Barbara Liskov and to the behavioral subtyping paper she published with Jeannette Wing in 1994.

Martin pulled these ideas together in his 2000 paper "Design Principles and Design Patterns". A few years later, Michael Feathers suggested the SOLID acronym. In other words, SOLID is not one person's invention. It is a memorable summary of decades of practice.

  • S: Single Responsibility Principle.
  • O: Open/Closed Principle.
  • L: Liskov Substitution Principle.
  • I: Interface Segregation Principle.
  • D: Dependency Inversion Principle.

How do clean code and SOLID fit together?

Clean code is code that a reader understands without much effort and changes with confidence. SOLID is a way to reach that goal at the class and module level. Naming, short functions and meaningful tests keep individual lines clean. SOLID, on the other hand, governs how the pieces connect.

Think of it this way. Code with beautiful variable names is still not clean if every class depends on every other class. Likewise, a well structured system with 200 line functions still wears its reader out. You need both.

The most common problem I see in the field is this: a team cares about naming but ignores dependencies. As a result, writing tests gets harder, every small change touches five files, and the team starts to fear change.

What does the single responsibility principle (SRP) actually say?

The classic definition says a class should have only one reason to change. In 2014 Martin clarified this in a post on the single responsibility principle. There he explained that a "responsibility" really means a stakeholder: a person or team that asks for changes.

That distinction matters, because it changes the question. People often misread SRP as "a class should do one thing". The better question is this: who would ask me to change this class? If accounting, reporting and the database administrator all request changes in the same class, that class has three responsibilities.

For example, imagine an invoice class that calculates totals, renders a PDF and saves itself to the database. Three different stakeholders now edit the same file. Consequently, one person's change can break another person's feature.

How do you apply SRP in a real code example?

First, look at a class that violates the rule. It mixes calculation, formatting and persistence in one place:

class Invoice:
    def __init__(self, items, tax_rate):
        self.items = items
        self.tax_rate = tax_rate

    def total(self):
        subtotal = sum(i.price * i.qty for i in self.items)
        return subtotal * (1 + self.tax_rate)

    def render_pdf(self):
        ...  # layout, fonts, logo

    def save(self, conn):
        conn.execute("INSERT INTO invoices ...")

Next, you split it into three parts. The calculation stays as a business rule. Formatting and storage move into their own classes:

class Invoice:
    def __init__(self, items, tax_rate):
        self.items = items
        self.tax_rate = tax_rate

    def total(self):
        subtotal = sum(i.price * i.qty for i in self.items)
        return subtotal * (1 + self.tax_rate)

class InvoicePdfRenderer:
    def render(self, invoice): ...

class InvoiceRepository:
    def __init__(self, conn):
        self.conn = conn

    def save(self, invoice): ...

Now a PDF redesign touches only the renderer. In addition, you can test the calculation in milliseconds, with no database at all. In short, the first reward of SRP is testability.

Why does the open/closed principle say "extend, don't modify"?

The open/closed principle says a software unit should be open for extension but closed for modification. When you want new behavior, you add a new piece instead of opening working, tested code and rewriting its insides.

At first this sounds impossible. In practice, though, the meaning is simple. You identify the spots that change often and put them behind an abstraction. Then, when a change arrives, you write a new class and leave the old one alone.

Still, one warning. Trying to make everything extensible is also a mistake. Apply OCP only where things really change, or where you have concrete signs that they will. Otherwise you end up with layers nobody needs.

What changes when you add a new payment method under OCP?

The OCP violation I meet most often in e-commerce projects is a function that picks the payment method through an if chain:

def take_payment(method, amount):
    if method == "card":
        return charge_card(amount)
    elif method == "bank_transfer":
        return record_transfer(amount)
    elif method == "cash_on_delivery":
        return record_cod(amount)
    raise ValueError("Unknown method")

Every new method forces you to open this function. Therefore, you move the methods behind a shared interface:

from abc import ABC, abstractmethod

class PaymentMethod(ABC):
    @abstractmethod
    def pay(self, amount): ...

class CardPayment(PaymentMethod):
    def pay(self, amount): ...

class BankTransfer(PaymentMethod):
    def pay(self, amount): ...

def take_payment(method: PaymentMethod, amount):
    return method.pay(amount)

Adding a digital wallet now means writing one new class. Moreover, the existing card and transfer tests stay untouched. For details on abstract base classes, see the official abc module documentation. I cover the wider checkout setup on my e-commerce consulting page.

What does the Liskov substitution principle (LSP) protect?

The Liskov substitution principle says that an object of a subclass must work anywhere the parent class is expected, without breaking the program's correctness. Put simply, a subclass has to keep the promises its parent makes.

In practice, those promises fall into a few groups. A subclass cannot demand more from its inputs, cannot deliver less in its outputs, and cannot break the rules the parent guarantees. For instance, if the parent says "the balance never goes negative", the subclass cannot relax that.

  • Preconditions: the subclass accepts every input the parent accepts.
  • Postconditions: it guarantees at least what the parent guarantees.
  • Invariants: the state that must always hold stays intact.
  • Exceptions: no new error types that the parent never raises.

An LSP violation usually passes the compiler and often slips through tests. The problem appears in production, on the day someone uses the subclass in an unexpected place. That is why LSP violations are the sneakiest of the five.

How does the square and rectangle example break LSP?

In geometry, every square is a rectangle. So this inheritance looks reasonable:

class Rectangle:
    def set_width(self, w): self.w = w
    def set_height(self, h): self.h = h
    def area(self): return self.w * self.h

class Square(Rectangle):
    def set_width(self, w): self.w = self.h = w
    def set_height(self, h): self.w = self.h = h

def test_area(r: Rectangle):
    r.set_width(5)
    r.set_height(4)
    assert r.area() == 20

This test passes with a rectangle and fails with a square, because the square changes its width when you set its height. The rectangle's promise, that width and height are independent, no longer holds.

The fix is to model inheritance on behavior. You make square and rectangle siblings under a shared "Shape" abstraction. Both compute an area, but neither claims to replace the other. In other words, you build "is a" relationships on how the code behaves, not on how the real world looks.

What is the interface segregation principle (ISP)?

The interface segregation principle says no client should be forced to depend on methods it does not use. Instead of one large interface that covers everything, you design small interfaces focused on specific roles.

However, a fat interface has a cost. Every class that implements it writes empty bodies or "not supported" errors for methods it cannot use. Also, when an unused part of the interface changes, clients that never touch that part still need rebuilding and retesting.

ISP has a close link with LSP. If a class implements a method by raising "not supported", you are probably looking at both a fat interface and a substitution problem.

How do you apply ISP in Python with Protocol?

Consider an office device system. One big interface asks for printing, scanning and faxing together:

class OfficeDevice(ABC):
    @abstractmethod
    def print_doc(self, doc): ...
    @abstractmethod
    def scan(self, doc): ...
    @abstractmethod
    def fax(self, doc): ...

class BasicPrinter(OfficeDevice):
    def print_doc(self, doc): ...
    def scan(self, doc): raise NotImplementedError
    def fax(self, doc): raise NotImplementedError

The basic printer cannot implement two of the methods. Instead, you separate the roles. In Python, typing.Protocol is handy here, because it supports structural subtyping:

from typing import Protocol

class Printable(Protocol):
    def print_doc(self, doc) -> None: ...

class Scannable(Protocol):
    def scan(self, doc) -> bytes: ...

class BasicPrinter:
    def print_doc(self, doc) -> None: ...

def print_report(device: Printable, report):
    device.print_doc(report)

The report function now depends only on the ability to print. As a result, a multifunction device and a basic printer both work with it, without any dead methods.

What does the dependency inversion principle (DIP) mean?

The dependency inversion principle has two parts. First, high level modules should not depend on low level modules; both should depend on abstractions. Second, abstractions should not depend on details; details should depend on abstractions.

Here is a concrete case. An order service holds business rules, so it is high level. A MySQL connection or an SMS provider is a detail. If the order service creates the MySQL class directly, then switching databases means opening your business logic too.

class OrderRepository(Protocol):
    def save(self, order) -> None: ...

class OrderService:
    def __init__(self, repo: OrderRepository):
        self.repo = repo

    def place_order(self, order):
        # business rules live here
        self.repo.save(order)

class MySQLOrderRepository:
    def save(self, order) -> None: ...

The dependency arrow has flipped. The business rule defines the interface, and the database class conforms to it. Consequently, in tests you can pass an in memory fake instead of a real database.

Are DIP and dependency injection the same thing?

No, they are not the same, although they complement each other. DIP is a design principle about which way dependencies should point. Dependency injection is a technique: an object receives the parts it needs from outside instead of creating them itself.

Passing the repository through the constructor in the example above is injection. However, injecting something does not automatically mean you follow DIP. If the injected type is still the concrete MySQL class, the direction of the dependency has not changed.

That said, I also want to correct a common belief. You do not need a heavy injection framework for DIP. In small and medium projects, wiring objects together by hand at the application's entry point is usually enough, and easier to read.

How can you summarize SOLID principles in one table?

Seeing all five side by side makes it easier to remember which one solves which problem. You can also use this table as a quick checklist during code review:

PrincipleOne line summaryTypical warning signCommon fix
SRPOne reason to change per classDifferent teams collide in the same fileSplit the class by stakeholder
OCPOpen for extension, closed for modificationAn if/elif chain grows with every new typeAbstraction and polymorphism
LSPSubclasses can replace their parentisinstance checks, NotImplementedErrorModel inheritance on behavior
ISPNo dependency on unused methodsEmpty method bodiesSmall, role based interfaces
DIPBusiness rules depend on abstractions, not detailsServices create database objects directlyInterfaces plus dependency injection

That said, the warning signs are not proof. They only show you where to look. An if chain, for example, is sometimes perfectly innocent, especially with two or three fixed options.

When do SOLID principles go too far?

The first trap after learning the principles is applying them everywhere. Interfaces with a single implementation, five classes for a three line job and layers of indirection that are hard to follow are typical results. You try to make code more flexible and make it harder to read instead.

My simple rule: I add an abstraction when the second concrete need shows up, not the first. Developers often call this the "rule of three" or YAGNI (You Aren't Gonna Need It). So you build flexibility around real change, not speculation.

  • Do not force SOLID onto small scripts and one off tools.
  • In the prototype stage, speed comes first; add structure once the product settles.
  • If you see an interface with only one implementation, ask whether it is really needed.
  • The more abstractions you add, the more care your naming needs.

Martin himself stresses in his 2020 post "Solid Relevance" that the principles are guidance, not laws. I agree. SOLID is not a goal in itself; it is a tool for lowering maintenance cost.

How do you apply SOLID principles to an existing codebase step by step?

Rewriting a working project from scratch is almost never the right call. Instead, you move in small, safe steps. This is the order I follow on client projects:

  1. Pin down current behavior with characterization tests around the area you will change.
  2. Use version control history to find the files that change most, and start there.
  3. Split large classes by stakeholder; in other words, begin with SRP.
  4. Put external dependencies (database, email, payments) behind interfaces.
  5. Turn repeated if chains into polymorphism as new types appear.
  6. Run the tests after every step and commit in small increments.

The reason for this order is simple: SRP and DIP improve testability right away. If you start fixing OCP or LSP without a safety net of tests, your risk of introducing new bugs during the cleanup goes up.

How do you spot SOLID violations during code review?

Code review is the most effective place to turn the principles into team habits. During a review I ask a few questions in order, and they catch most violations early.

  • How many different people or teams would want to change this class?
  • Does adding a new type require opening an existing function?
  • Is there branching on isinstance or other type checks?
  • Does a class implement some interface methods as empty or throwing?
  • Also, does a business rule create its own database or HTTP client?

These questions start a conversation, not an accusation. I also prefer to describe the concrete effect rather than name the principle. Saying "this class violates SRP" persuades fewer people than saying "when the report format changes, we will have to retest the invoice math too". That keeps the discussion on real maintenance cost instead of textbook terms.

Where do SOLID principles show up in web projects?

Company websites and admin panels are where SOLID gets tested every day. A contact form, for example, sends an email today, opens a CRM record tomorrow and needs a WhatsApp alert the day after. If notification channels sit behind one interface, each new channel is just one new class.

The front end is no different. I covered architectures in which large teams split the front end into pieces in my micro frontends article. The boundary drawing there is essentially SRP at system scale.

Clean architecture also affects performance indirectly. Bottlenecks are easier to find in code where responsibilities are clear. For the measurement side, my posts on Lighthouse performance testing and how site speed affects SEO may help.

Why does SOLID make testing easier?

The biggest obstacle to unit testing is code that builds its own dependencies inside. If an order service connects to the database, sends email and calls an API, then testing one business rule means spinning up three external systems.

DIP and SRP remove that obstacle. Because dependencies come from outside, you pass fakes in tests. Because classes are small, each test needs only a few lines of setup. Therefore, tests run fast, and the team actually runs them.

class InMemoryRepository:
    def __init__(self):
        self.records = []
    def save(self, order):
        self.records.append(order)

def test_order_is_saved():
    repo = InMemoryRepository()
    OrderService(repo).place_order({"id": 1})
    assert len(repo.records) == 1

This test finishes in milliseconds and needs no external system. The same repository interface can point to MySQL in production, to memory in tests and perhaps to a cloud database later. Meanwhile the business rule never knows about any of these swaps. In my experience, fast tests like this are the main reason teams really run their suite before each commit. Slow tests, by contrast, get skipped, and the safety net disappears.

How do SOLID principles relate to design patterns?

Design patterns are ready made applications of SOLID ideas to recurring problems. The strategy pattern is exactly the payment example from the OCP section: one shared interface and swappable implementations. The adapter pattern supports DIP by fitting an external library to your own interface.

Similarly, the decorator pattern lets you add behavior to a class without opening it, which is another form of the open/closed principle. The factory pattern gathers object creation in one place and keeps business rules away from concrete classes.

  • Strategy: hides a changing algorithm behind an interface; fits OCP.
  • Adapter: binds an external library to your abstraction; supports DIP.
  • Decorator: adds behavior without changing the class; fits OCP.
  • Factory: centralizes object creation; helps SRP and DIP.

Still, memorizing patterns and dropping them everywhere is just another way to overdo the principles. I suggest describing the problem first and then picking the pattern that fits. If there is no problem, you need no pattern.

Do SOLID principles apply to functional programming?

The principles were born in the object-oriented world, but their core is paradigm neutral. In a functional codebase you talk about modules and functions instead of classes. Even so, single responsibility, passing dependencies as parameters and small contracts deliver the same value.

For example, writing a function that accepts another function as a parameter is the functional version of DIP. Likewise, expecting every function that matches a type signature to behave as promised carries the same idea as LSP. The concepts stay; only the mechanics change.

In a multi paradigm language like Python I use both. I write classes for stateful concepts with a clear owner, and plain functions for pure transformations. That balance keeps the code flexible and simple at once.

How can you check a development team's grasp of SOLID?

If you plan to hire an outside development team, being able to recite SOLID is not a strong signal. The real signal is whether the team can explain how it applies the principles in its own code, and where it deliberately bends them.

In an interview you might ask: "If we wanted a new payment method, how many files would you touch?" If the answer is "one new class and one registration line", the architecture is probably healthy. An answer like "it depends" usually means a longer conversation is coming.

On web projects I like to settle these technical decisions at kickoff; code structure is part of the deliverable in my web design service. For the part of the technical side that search engines see, I collected ten technical SEO tips in a separate post.

Final thoughts: is clean code a rulebook or a habit?

In short, for me clean code is a habit, and SOLID is its skeleton. Memorizing the principles takes a few hours. Learning to apply them in the right place, and to leave them out in the wrong place, takes years. So try each principle on a small example first, then test it on one module of your real project.

Finally, remember that the goal is not a perfect architecture. The goal is keeping change cheap. If your team can say "sure" to a new request without fear, you are on the right track. You can find more of my writing on the blog.

Frequently Asked Questions

Do SOLID principles only apply to object-oriented languages?
No, their core applies to any paradigm. The principles grew out of object-oriented design, but single responsibility, passing dependencies from outside and small contracts bring the same benefits in functional code. In functional languages you think in modules and functions rather than classes, yet the aim of keeping change cheap stays exactly the same.
Which SOLID principle should I start with?
In most projects I recommend starting with the single responsibility principle. Splitting large classes by stakeholder makes code more readable and testable right away. Then you can move external dependencies behind interfaces and apply dependency inversion. Together, these two build the test safety net you need to apply the other principles with confidence.
Does applying SOLID slow a project down?
It can slow you down a little at first, but it speeds you up over time. You write a few extra classes and interfaces during setup. However, in the second and third year of a project every change touches fewer files. For prototypes and throwaway scripts, skip the ceremony and add structure once the product settles.
How can I tell if code violates the Liskov principle?
The clearest sign is isinstance checks that branch on the subclass type. A subclass that raises NotImplementedError, or rejects input the parent accepts, is another strong hint. A practical way to catch violations is to run the tests you wrote for the parent class against every subclass as well.
Do I need a framework for dependency injection?
No. In small and medium projects, creating objects by hand at the application entry point and wiring them together is usually enough, and it reads more clearly. Frameworks start to pay off in large applications with hundreds of components and complex lifecycles. What matters is the direction of dependencies, not the tool.
#SOLID#Clean Code#Software Architecture#Python#Object-Oriented Design#Refactoring
Share:
Talha Aslan
Talha Aslan

Google Partner digital marketing expert. Hands-on with SEO, Google Ads, web design and e-commerce projects since 2012; every post here comes from that experience.

Next project

Let's talk about your project.

No middlemen, no layers: you talk directly to the expert doing the work. The first consultation is free, I listen to your goal and come back with a clear roadmap.

WhatsApp Call Now