Week 13 Lecture: SOLID Principles

SOLID is a set of five design principles collected by the renowned software engineer Robert C. Martin (also known as “Uncle Bob”). Each letter in the acronym stands for one principle. Together, they guide developers toward writing classes that are easy to understand, test, and change over time.
| Letter | Principle |
|---|---|
| S | Single Responsibility |
| O | Open/Closed |
| L | Liskov Substitution |
| I | Interface Segregation |
| D | Dependency Inversion |
S — Single Responsibility Principle (SRP)
A class should have only one reason to change.
A class should do one job and do it well. When a single class handles multiple unrelated tasks, any change to one task risks breaking the others.
The Problem
Consider the following Student class:
class Student:
def __init__(self, name, grades):
self.name = name
self.grades = grades
def average(self):
return sum(self.grades) / len(self.grades)
def save_to_file(self, filename):
with open(filename, "w") as f:
f.write(f"{self.name}: {self.average()}")
def send_email(self, address):
print(f"Emailing {address}: {self.name} got {self.average()}")
This class does three different things: it calculates a grade average, writes data to a file, and sends emails. If the file format needs to change (say, from plain text to JSON), you modify the Student class. If the email provider changes (say, from email to Telegram), you modify the Student class again. The class has too many reasons to change.
The Fix
Split each responsibility into its own class:
class Student:
def __init__(self, name, grades):
self.name = name
self.grades = grades
def average(self):
return sum(self.grades) / len(self.grades)
class StudentFileWriter:
def save(self, student, filename):
with open(filename, "w") as f:
f.write(f"{student.name}: {student.average()}")
class StudentEmailer:
def send(self, student, address):
print(f"Emailing {address}: {student.name} got {student.average()}")
Now each class has a single focus:
Studenthandles student data and grade calculations.StudentFileWriterhandles file persistence.StudentEmailerhandles email delivery.
Yes, there are now more classes — but small, focused classes are easier to test, read, and reuse.
Practice: Refactoring an Order Class
Here is a class that violates SRP. It handles order data, receipt printing, database storage, and payment processing — four separate responsibilities:
class Order:
def __init__(self, items):
self.items = items # list of (name, price)
def total(self):
return sum(price for _, price in self.items)
def print_receipt(self):
print("=== RECEIPT ===")
for name, price in self.items:
print(f"{name}: {price}")
print(f"TOTAL: {self.total()}")
def save_to_db(self):
# imagine real database code here
print(f"INSERT INTO orders VALUES ({self.items}, {self.total()})")
def charge_card(self, card_number):
print(f"Charging {card_number} for {self.total()} so'm")
After applying SRP, each responsibility gets its own class:
class Order:
def __init__(self, items):
self.items = items # list of (name, price)
def total(self):
return sum(price for _, price in self.items)
class ReceiptPrinter:
def print(self, order: Order):
print("=== RECEIPT ===")
for name, price in order.items:
print(f"{name}: {price}")
print(f"TOTAL: {order.total()}")
class OrderRepository:
def save(self, order: Order):
print(f"INSERT INTO orders VALUES ({order.items}, {order.total()})")
class CardPayment:
def charge(self, order: Order, card_number):
print(f"Charging {card_number} for {order.total()} so'm")
O — Open/Closed Principle (OCP)
A class should be open for extension but closed for modification.
You should be able to add new behavior without editing existing, working code. When new requirements force you to modify old code, you risk introducing bugs into features that already work.
The Problem
Consider a discount calculator that uses an if/elif chain:
class DiscountCalculator:
def calculate(self, customer_type, price):
if customer_type == "regular":
return price
elif customer_type == "student":
return price * 0.9
elif customer_type == "vip":
return price * 0.7
Every time a new customer type is added (e.g., "employee"), this method must be edited. Each edit risks breaking the existing discount logic.
The Fix
Use polymorphism to represent each customer type as its own class with a shared interface:
from abc import ABC, abstractmethod
class Customer(ABC):
@abstractmethod
def discount(self, price): ...
class Regular(Customer):
def discount(self, price):
return price
class Student(Customer):
def discount(self, price):
return price * 0.9
class VIP(Customer):
def discount(self, price):
return price * 0.7
Now adding a new customer type requires only a new class — no existing code is touched:
class Employee(Customer):
def discount(self, price):
return price * 0.5
Usage:
class DiscountCalculator:
def calculate(self, customer: Customer, price: float) -> float:
return customer.discount(price)
calc = DiscountCalculator()
regular = Regular()
student = Student()
vip = VIP()
employee = Employee()
print(calc.calculate(regular, 100)) # 100
print(calc.calculate(student, 100)) # 90
print(calc.calculate(vip, 100)) # 70
print(calc.calculate(employee, 100)) # 50
The Regular, Student, and VIP classes were never modified. New behavior was added on top — that is what “open for extension, closed for modification” means.
Why not use static methods? Instance methods allow you to later add state, caching, or logging to a class. Static methods cannot do this.
Practice: Refactoring a Shipping Calculator
Here is a shipping calculator that violates OCP with an if/elif chain:
class ShippingCalculator:
def cost(self, method, weight_kg):
if method == "standard":
return 5000 + weight_kg * 1000
elif method == "express":
return 10000 + weight_kg * 2000
elif method == "drone":
return 20000 + weight_kg * 500
else:
raise ValueError(f"unknown method: {method}")
After applying OCP, each shipping method becomes its own class:
from abc import ABC, abstractmethod
class ShippingMethod(ABC):
@abstractmethod
def cost(self, weight_kg): ...
class Standard(ShippingMethod):
def cost(self, weight_kg):
return 5000 + weight_kg * 1000
class Express(ShippingMethod):
def cost(self, weight_kg):
return 10000 + weight_kg * 2000
class Drone(ShippingMethod):
def cost(self, weight_kg):
return 20000 + weight_kg * 500
Adding new shipping methods (pickup and international) requires no changes to existing classes:
class Pickup(ShippingMethod):
def cost(self, weight_kg):
return 0
class International(ShippingMethod):
def cost(self, weight_kg):
return 50000 + weight_kg * 5000
The checkout function works with any shipping method through the shared interface:
def checkout(cart_total, weight_kg, shipping: ShippingMethod):
shipping_price = shipping.cost(weight_kg)
grand_total = cart_total + shipping_price
print(f"Items: {cart_total} so'm")
print(f"Shipping: {shipping_price} so'm")
print(f"TOTAL: {grand_total} so'm")
# customer chose express at the checkout page
checkout(cart_total=120_000, weight_kg=3.5, shipping=Express())
# next day, another customer picks the new option — checkout() is unchanged
checkout(cart_total=800_000, weight_kg=12, shipping=International())
The checkout function does not care which shipping method is used. It just calls cost() — new methods can be added, and old code stays untouched.
L — Liskov Substitution Principle (LSP)
If
Bis a subclass ofA, then objects of typeBshould be usable anywhere objects of typeAare expected — without breaking the program.
Named after computer scientist Barbara Liskov, this principle states that a child class must honor all the promises (contracts) of its parent. If substituting a child for a parent causes unexpected behavior, the inheritance relationship is wrong.
The Problem: Rectangle and Square
Consider a Rectangle class:
class Rectangle:
def __init__(self, w, h):
self.w = w
self.h = 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
Since “a square is a rectangle” in mathematics, someone might create:
class Square(Rectangle):
def set_width(self, w):
self.w = w
self.h = w # keep them equal
def set_height(self, h):
self.w = h
self.h = h
This looks reasonable, but it violates LSP. Any function written for Rectangle expects width and height to be independent:
def stretch(rect: Rectangle):
rect.set_width(10)
rect.set_height(5)
print(rect.area()) # we expect 50
stretch(Rectangle(1, 1)) # 50 ✓
stretch(Square(1, 1)) # 25 ✗ surprise!
The Square class breaks the contract of Rectangle because setting the height silently overwrites the width. While a square is a rectangle in geometry, it is not a valid subtype in OOP because it violates the parent’s behavioral expectations.
The Problem: Bird and Penguin
class Bird:
def __init__(self, name):
self.name = name
def fly(self):
print(f"{self.name} is flying")
class Sparrow(Bird):
pass # default fly() is fine
class Penguin(Bird):
def fly(self):
raise NotImplementedError("penguins can't fly!")
The Bird class promises that every bird can fly. Penguin inherits from Bird but breaks that promise:
def simulate_migration(birds):
for b in birds:
b.fly()
simulate_migration([Sparrow("jack"), Penguin("Pingu")]) # crash!
The Fix
Restructure the hierarchy so that flying is only promised by classes that can actually fly:
class Bird:
def __init__(self, name):
self.name = name
def eat(self):
print(f"{self.name} is eating")
class FlyingBird(Bird):
def fly(self):
print(f"{self.name} is flying")
class Sparrow(FlyingBird):
pass
class Eagle(FlyingBird):
pass
class Penguin(Bird):
def swim(self):
print(f"{self.name} is swimming")
Now functions that require flight can specify FlyingBird in the type hint, and penguins are never passed where flying is expected:
def simulate_migration(birds: list[FlyingBird]):
for b in birds:
b.fly()
simulate_migration([Sparrow("Chip"), Eagle("Lochin")])
I — Interface Segregation Principle (ISP)
Do not force a class to depend on methods it does not use.
Many small, focused interfaces are better than one large, monolithic interface. When a class is forced to implement methods it has no use for, the design is bloated and fragile.
The Problem
Consider a Worker interface with methods for working, eating, and sleeping:
class Worker(ABC):
@abstractmethod
def work(self): ...
@abstractmethod
def eat(self): ...
@abstractmethod
def sleep(self): ...
A human worker can do all three:
class HumanWorker(Worker):
def work(self):
print("working")
def eat(self):
print("eating plov")
def sleep(self):
print("sleeping")
But a robot worker is forced to implement eat() and sleep() even though they make no sense:
class RobotWorker(Worker):
def work(self):
print("working 24/7")
def eat(self):
raise NotImplementedError("robots don't eat")
def sleep(self):
raise NotImplementedError("robots don't sleep")
The Fix
Split the monolithic interface into small, focused ones:
class Workable(ABC):
@abstractmethod
def work(self): ...
class Eatable(ABC):
@abstractmethod
def eat(self): ...
class Sleepable(ABC):
@abstractmethod
def sleep(self): ...
class HumanWorker(Workable, Eatable, Sleepable):
def work(self): print("working")
def eat(self): print("eating plov")
def sleep(self): print("sleeping")
class RobotWorker(Workable):
def work(self): print("working 24/7")
Now each class only implements the interfaces it actually needs.
Practice: Refactoring a Smart Device Interface
Here is an overly broad SmartDevice interface:
class SmartDevice(ABC):
@abstractmethod
def turn_on(self): ...
@abstractmethod
def turn_off(self): ...
@abstractmethod
def play_music(self): ...
@abstractmethod
def record_video(self): ...
@abstractmethod
def print_document(self, text): ...
Concrete implementations are littered with NotImplementedError because no single device supports all features:
class SmartSpeaker(SmartDevice):
def turn_on(self): print("speaker on")
def turn_off(self): print("speaker off")
def play_music(self): print("playing music")
def record_video(self): raise NotImplementedError
def print_document(self, text): raise NotImplementedError
class SecurityCamera(SmartDevice):
def turn_on(self): print("camera on")
def turn_off(self): print("camera off")
def play_music(self): raise NotImplementedError
def record_video(self): print("recording")
def print_document(self, text): raise NotImplementedError
class SmartPrinter(SmartDevice):
def turn_on(self): print("printer on")
def turn_off(self): print("printer off")
def play_music(self): raise NotImplementedError
def record_video(self): raise NotImplementedError
def print_document(self, text): print(f"printing: {text}")
After splitting the interface, each device only implements what it can actually do:
from abc import ABC, abstractmethod
class Switchable(ABC):
@abstractmethod
def turn_on(self): ...
@abstractmethod
def turn_off(self): ...
class MusicPlayer(ABC):
@abstractmethod
def play_music(self): ...
class VideoRecorder(ABC):
@abstractmethod
def record_video(self): ...
class Printer(ABC):
@abstractmethod
def print_document(self, text): ...
class SmartSpeaker(Switchable, MusicPlayer):
def turn_on(self): print("speaker on")
def turn_off(self): print("speaker off")
def play_music(self): print("playing music")
class SecurityCamera(Switchable, VideoRecorder):
def turn_on(self): print("camera on")
def turn_off(self): print("camera off")
def record_video(self): print("recording")
class SmartPrinter(Switchable, Printer):
def turn_on(self): print("printer on")
def turn_off(self): print("printer off")
def print_document(self, text): print(f"printing: {text}")
Functions can now request exactly the capability they need through type hints:
def nightly_shutdown(devices: list[Switchable]):
for d in devices:
d.turn_off()
def play_party_playlist(player: MusicPlayer):
player.play_music()
speaker = SmartSpeaker()
camera = SecurityCamera()
printer = SmartPrinter()
play_party_playlist(speaker) # only speakers qualify
nightly_shutdown([speaker, camera, printer]) # all three are Switchable
Key indicator of an ISP violation: a method body that is just raise NotImplementedError or pass.
D — Dependency Inversion Principle (DIP)
Depend on abstractions, not on concrete classes.
High-level modules (your core business logic) should not depend on low-level modules (specific implementation details). Both should depend on abstractions. In practice: do not hard-code a specific helper class inside your class — instead, accept an abstract interface from outside.
The Problem
Consider an order processing system that is hard-coded to use email:
class EmailService:
def send(self, msg):
print(f"sending email: {msg}")
class OrderProcessor:
def __init__(self):
self.email = EmailService() # hard-coded!
def process(self, order):
# ... do stuff ...
self.email.send(f"Order {order} done")
OrderProcessor is glued to EmailService. Switching to SMS or Telegram requires editing OrderProcessor. Testing is also difficult because every test run attempts to send a real email.
The Fix
Define an abstract Notifier interface and inject the concrete implementation from outside:
class Notifier(ABC):
@abstractmethod
def send(self, msg): ...
class EmailService(Notifier):
def send(self, msg): print(f"email: {msg}")
class TelegramService(Notifier):
def send(self, msg): print(f"telegram: {msg}")
class OrderProcessor:
def __init__(self, notifier: Notifier): # injected
self.notifier = notifier
def process(self, order):
self.notifier.send(f"Order {order} done")
Now OrderProcessor works with any notifier:
OrderProcessor(EmailService()).process(101)
OrderProcessor(TelegramService()).process(102)
For testing, you can pass a fake notifier — no real messages are sent.
Practice: Refactoring a Weather App
Here is a weather report class that is hard-coded to a single API provider:
class OpenWeatherAPI:
def get_temp(self, city):
# imagine a real HTTP call to openweathermap.org
print(f"[HTTP] GET https://api.openweather.com/?city={city}")
return 27 # pretend the API returned 27°C
class WeatherReport:
def __init__(self):
self.api = OpenWeatherAPI() # hard-coded!
def show(self, city):
temp = self.api.get_temp(city)
print(f"It's {temp}°C in {city} today.")
This design has several problems: switching to a cheaper provider requires editing WeatherReport, every test run hits the real API, and there is no way to add an offline mode.
After applying DIP, the WeatherReport class depends on an abstraction and accepts any provider:
from abc import ABC, abstractmethod
class WeatherAPI(ABC):
@abstractmethod
def get_temp(self, city): ...
class OpenWeatherAPI(WeatherAPI):
def get_temp(self, city):
print(f"[HTTP] GET https://api.openweather.com/?city={city}")
return 27
class WeatherStackAPI(WeatherAPI):
def get_temp(self, city):
print(f"[HTTP] GET https://api.weatherstack.com/?q={city}")
return 25
class CachedWeatherAPI(WeatherAPI):
def __init__(self, cache):
self.cache = cache # {"Tashkent": 26, ...}
def get_temp(self, city):
return self.cache[city]
class WeatherReport:
def __init__(self, api: WeatherAPI): # injected
self.api = api
def show(self, city):
temp = self.api.get_temp(city)
print(f"It's {temp}°C in {city} today.")
The same WeatherReport class now works with any provider, and you can test without network access:
# production
WeatherReport(OpenWeatherAPI()).show("Tashkent")
# next month, switch providers — WeatherReport is unchanged
WeatherReport(WeatherStackAPI()).show("Samarkand")
# offline mode — still no edits to WeatherReport
WeatherReport(CachedWeatherAPI({"Bukhara": 30})).show("Bukhara")
# test — no network, no quota burned
class FakeWeatherAPI(WeatherAPI):
def get_temp(self, city): return 99
WeatherReport(FakeWeatherAPI()).show("Andijan") # always says 99°C
SOLID Summary
| Principle | Core Idea |
|---|---|
| S — Single Responsibility | One class, one job. If you need the word “and” to describe what your class does (“it stores books and sends emails”), it is doing too much. |
| O — Open/Closed | Add new behavior by writing new code, not by editing old code. |
| L — Liskov Substitution | A child class must be usable wherever the parent is expected, without surprises. If Penguin inherits from Bird but fly() crashes — that inheritance is wrong. |
| I — Interface Segregation | Small, focused interfaces are better than one giant interface. Do not force a RobotWorker to implement eat() just because HumanWorker does. |
| D — Dependency Inversion | Depend on abstractions, not concrete classes. Do not create instances of concrete classes inside your class — accept an abstract type from outside. |
The overarching goal of SOLID is simple: write classes so that they are easy to change tomorrow.
Comprehensive Example: Library System
The following example demonstrates how multiple SOLID principles work together to improve a single class.
The Problem
This Library class violates three SOLID principles:
class Library:
def __init__(self):
self.books = []
def add_book(self, title, author):
self.books.append({"title": title, "author": author})
def save_to_file(self):
with open("books.txt", "w") as f:
for b in self.books:
f.write(f"{b['title']},{b['author']}\n")
def send_report_email(self):
print(f"Sending report: {len(self.books)} books")
- SRP violation — It stores books, writes files, and sends emails.
- OCP violation — Switching to JSON output requires editing
save_to_file. - DIP violation — File and email logic are hard-coded inside the class.
The Fix
First, create a proper Book class instead of using dictionaries. Then give Library a single job: managing a list of books. Extract file storage behind a Storage abstraction and reporting behind a Reporter abstraction.
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
class Library:
def __init__(self):
self.books = []
def add(self, book: Book):
self.books.append(book)
class Storage(ABC):
@abstractmethod
def save(self, books): ...
class TxtStorage(Storage):
def save(self, books):
with open("books.txt", "w") as f:
for b in books:
f.write(f"{b.title},{b.author}\n")
class JsonStorage(Storage):
def save(self, books):
import json
with open("books.json", "w") as f:
json.dump([b.__dict__ for b in books], f)
class Reporter(ABC):
@abstractmethod
def report(self, books): ...
class EmailReporter(Reporter):
def report(self, books):
print(f"Email report: {len(books)} books")
Usage:
lib = Library()
lib.add(Book("Boburnoma", "Bobur"))
lib.add(Book("O'tkan kunlar", "Abdulla Qodiriy"))
TxtStorage().save(lib.books)
JsonStorage().save(lib.books)
EmailReporter().report(lib.books)
This refactored design satisfies all five SOLID principles:
- S — Each class has one job.
- O — New storage types or reporters can be added without editing existing classes.
- L —
JsonStorageandTxtStorageare interchangeable whereverStorageis expected. - I —
StorageandReporterare separate, focused interfaces. - D —
Librarydoes not depend on file I/O or email details.
Comprehensive Example: Document Hierarchy
This example shows how ISP and LSP violations arise in a document management system and how to fix them.
The Problem
A single Document interface forces every document type to implement open, edit, save, and print_doc:
from abc import ABC, abstractmethod
class Document(ABC):
@abstractmethod
def open(self): ...
@abstractmethod
def edit(self, text): ...
@abstractmethod
def save(self): ...
@abstractmethod
def print_doc(self): ...
class TextDocument(Document):
def __init__(self):
self.text = ""
def open(self): print("opening text document")
def edit(self, text): self.text = text
def save(self): print(f"saving text: {self.text!r}")
def print_doc(self): print(f"printing: {self.text}")
class ReadOnlyDocument(Document):
def __init__(self, text):
self.text = text
def open(self): print("opening read-only document")
def edit(self, text): raise PermissionError("this document is read-only")
def save(self): raise PermissionError("this document is read-only")
def print_doc(self): print(f"printing: {self.text}")
class ScannedImage(Document):
def __init__(self, path):
self.path = path
def open(self): print(f"opening scan {self.path}")
def edit(self, text): raise NotImplementedError("you can't type into an image")
def save(self): print(f"saving scan {self.path}")
def print_doc(self): print(f"printing scan {self.path}")
- LSP violation —
ReadOnlyDocumentclaims to be aDocument, but callingedit()orsave()raises an exception. - ISP violation —
ScannedImageis forced to implementedit()even though images cannot be edited as text.
The Fix
Replace the single fat interface with four small, focused interfaces. Each class only implements the interfaces that match its actual capabilities:
from abc import ABC, abstractmethod
class Openable(ABC):
@abstractmethod
def open(self): ...
class Printable(ABC):
@abstractmethod
def print_doc(self): ...
class Editable(ABC):
@abstractmethod
def edit(self, text): ...
class Savable(ABC):
@abstractmethod
def save(self): ...
class TextDocument(Openable, Printable, Editable, Savable):
def __init__(self):
self.text = ""
def open(self): print("opening text document")
def edit(self, text): self.text = text
def save(self): print(f"saving text: {self.text!r}")
def print_doc(self): print(f"printing: {self.text}")
class ReadOnlyDocument(Openable, Printable):
def __init__(self, text):
self.text = text
def open(self): print("opening read-only document")
def print_doc(self): print(f"printing: {self.text}")
class ScannedImage(Openable, Printable, Savable):
def __init__(self, path):
self.path = path
def open(self): print(f"opening scan {self.path}")
def save(self): print(f"saving scan {self.path}")
def print_doc(self): print(f"printing scan {self.path}")
Functions can now request only the capabilities they need, and the type system prevents invalid calls at design time rather than at runtime:
def edit_and_save_all(docs: list[Editable | Savable], new_text: str):
for d in docs:
if isinstance(d, Editable):
d.edit(new_text)
if isinstance(d, Savable):
d.save()
def print_all(docs: list[Printable]):
for d in docs:
d.print_doc()
text = TextDocument()
ronly = ReadOnlyDocument("ancient manuscript")
scan = ScannedImage("page1.jpg")
print_all([text, ronly, scan]) # all three are Printable — safe
edit_and_save_all([text], "new content") # only TextDocument is Editable — safe
# edit_and_save_all([ronly], "...") # ❌ type error, not a runtime crash
What changed:
- ISP — One fat
Documentinterface became four small, focused interfaces. - LSP — Every class is now fully usable wherever its parent interface appears — no surprises, no exceptions.