SOLID Principles in Python with Before-and-After Examples

By Engr. Henry Akpan

Writing Code That Is Easy to Change

The first version of any program is the easy part. The hard part comes later: adding a new payment method, changing how reports are sent, fixing one bug without causing three more. Code that is tangled together makes every change risky. The SOLID principles are five guidelines for object-oriented design, collected by Robert C. Martin, that keep classes small, focused and loosely connected so that programs stay easy to extend and test. This tutorial explains each principle (Single responsibility, Open–closed, Liskov substitution, Interface segregation and Dependency inversion) with a "before" and "after" Python example from a student-records system, and discusses when to apply them and when not to. All outputs come from running the code.

S: The Single Responsibility Principle

A class should have only one reason to change.

Here one class calculates a GPA, formats a report and saves it. A change to the grading rules, the report layout or the storage format would all mean editing the same class:

class StudentReport:
    def __init__(self, name, results):
        self.name, self.results = name, results   # results: list of (units, points)

    def gpa(self):
        units = sum(u for u, _ in self.results)
        return sum(u * p for u, p in self.results) / units

    def as_text(self):
        return f"{self.name}: GPA {self.gpa():.2f}"

    def save(self, path):                          # file handling mixed in too
        with open(path, "w", encoding="utf-8") as f:
            f.write(self.as_text())

Split it so each class has one job:

class GpaCalculator:
    def gpa(self, results):
        units = sum(u for u, _ in results)
        return sum(u * p for u, p in results) / units

class TextReport:
    def render(self, name, gpa):
        return f"{name}: GPA {gpa:.2f}"

class FileSaver:
    def save(self, path, content):
        with open(path, "w", encoding="utf-8") as f:
            f.write(content)

results = [(3, 5), (2, 4), (3, 3)]
gpa = GpaCalculator().gpa(results)
text = TextReport().render("Ada", gpa)
FileSaver().save("ada.txt", text)
print(text)

Output:

Ada: GPA 4.00

Now the grading rules can be tested without touching files, and a PDF report can be added without risking the calculation.

O: The Open–Closed Principle

Software should be open for extension but closed for modification. You should be able to add new behaviour without editing code that already works.

A long if/elif chain breaks this rule: every new notification channel means editing the function again:

def notify_old(channel, student, message):
    if channel == "email":
        return f"Email to {student}: {message}"
    elif channel == "sms":
        return f"SMS to {student}: {message[:20]}"
    # adding WhatsApp means editing this function again...

Instead, define a common interface and add new classes:

from abc import ABC, abstractmethod

class Notifier(ABC):
    @abstractmethod
    def send(self, student, message): ...

class EmailNotifier(Notifier):
    def send(self, student, message):
        return f"Email to {student}: {message}"

class SmsNotifier(Notifier):
    def send(self, student, message):
        return f"SMS to {student}: {message[:20]}"

class WhatsAppNotifier(Notifier):          # new feature: no existing code edited
    def send(self, student, message):
        return f"WhatsApp to {student}: {message}"

def notify_all(notifiers, student, message):
    for n in notifiers:
        print(n.send(student, message))

notify_all([EmailNotifier(), SmsNotifier(), WhatsAppNotifier()], "Musa", "Results are now available")

Output:

Email to Musa: Results are now available
SMS to Musa: Results are now avai
WhatsApp to Musa: Results are now available

L: The Liskov Substitution Principle

Objects of a subclass must be usable anywhere the parent class is expected, without breaking the program. A subclass should keep the promises its parent makes.

A classic violation: a Square that inherits from Rectangle. Code that sets a rectangle's width and height independently gets a surprise:

class Rectangle:
    def __init__(self, w, h):
        self.w, self.h = w, h
    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 __init__(self, side):
        super().__init__(side, side)
    def set_width(self, w):
        self.w = self.h = w
    def set_height(self, h):
        self.w = self.h = h

def stretch(rect):
    rect.set_width(5)
    rect.set_height(4)
    return rect.area()          # the caller expects 5 x 4 = 20

print(stretch(Rectangle(1, 1)), stretch(Square(1)))

Output:

20 16

The square silently broke the rectangle's promise that width and height change independently. The fix is not to force the inheritance: make Square and Rectangle separate classes that share a Shape interface with an area() method, and don't give shapes setters that only make sense for some of them. A good test: if a subclass needs to override a method to throw "not supported" or to behave differently in a way callers would notice, the inheritance is probably wrong.

I: The Interface Segregation Principle

Clients should not be forced to depend on methods they don't use. Prefer several small, focused interfaces over one large one.

If every device in a school must implement one big Machine interface with print, scan and fax, a simple printer has to provide fake scan and fax methods. Split the interface, and let each class combine only what it really does:

class Printer(ABC):
    @abstractmethod
    def print_doc(self, doc): ...

class Scanner(ABC):
    @abstractmethod
    def scan(self): ...

class SimplePrinter(Printer):
    def print_doc(self, doc):
        return f"Printing {doc}"

class OfficeMachine(Printer, Scanner):
    def print_doc(self, doc):
        return f"Printing {doc} in colour"
    def scan(self):
        return "Scanned page 1"

def print_all(printers, doc):
    return [p.print_doc(doc) for p in printers]

print(print_all([SimplePrinter(), OfficeMachine()], "timetable.pdf"))
print(isinstance(SimplePrinter(), Scanner), isinstance(OfficeMachine(), Scanner))

Output:

['Printing timetable.pdf', 'Printing timetable.pdf in colour']
False True

In Python, typing.Protocol offers a lighter way to describe small interfaces: any class with the right methods qualifies, without inheriting from anything ("duck typing" with type-checker support).

D: The Dependency Inversion Principle

High-level code should depend on abstractions, not on concrete details. Instead of a class creating its own database connection or email client, pass it in from outside ("dependency injection").

class ResultRepository(ABC):
    @abstractmethod
    def results_for(self, student): ...

class InMemoryResults(ResultRepository):     # used in tests or demos
    def __init__(self, data):
        self.data = data
    def results_for(self, student):
        return self.data.get(student, [])

class TranscriptService:
    def __init__(self, repo: ResultRepository, calc: GpaCalculator):
        self.repo, self.calc = repo, calc     # injected, not created here

    def summary(self, student):
        results = self.repo.results_for(student)
        if not results:
            return f"{student}: no results"
        return f"{student}: GPA {self.calc.gpa(results):.2f} from {len(results)} courses"

repo = InMemoryResults({"Ada": [(3, 5), (2, 4), (3, 3)], "Musa": [(3, 4), (3, 4)]})
service = TranscriptService(repo, GpaCalculator())
for s in ["Ada", "Musa", "Chidi"]:
    print(service.summary(s))

Output:

Ada: GPA 4.00 from 3 courses
Musa: GPA 4.00 from 2 courses
Chidi: no results

TranscriptService does not know or care whether results come from memory, a SQLite file or a web API. In production you pass a database-backed repository; in tests you pass the in-memory one, so tests run instantly without a real database. This is the principle behind most testable code.

Applying the SOLID Principles Sensibly

  • They are guidelines, not laws. A 50-line script does not need five abstract classes. Apply them when code is growing, changing often or being shared by a team.
  • Watch for warning signs that the SOLID principles would help with: classes that keep growing, long if/elif chains on a "type" field, changes in one place breaking something unrelated, and code that is hard to test without a real database or network.
  • Python's features (first-class functions, duck typing, dataclasses, protocols) often give lighter solutions than the class-heavy versions common in Java or C#. A dictionary of functions can satisfy the open–closed principle just as well as a class hierarchy.
  • Combined with automated tests, these principles let you change code with confidence.

Summary

  • The SOLID principles keep object-oriented code easy to change.
  • S: one reason to change per class. O: extend by adding code, not editing working code.
  • L: subclasses must keep the parent's promises (Square is not a safe Rectangle).
  • I: small, focused interfaces. D: depend on abstractions and inject dependencies.
  • Apply them where complexity and change justify it, not everywhere by default.

Related Tutorials