← Computer Programming II

SOLID? Sure, my code is solid.

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:

  • Student handles student data and grade calculations.
  • StudentFileWriter handles file persistence.
  • StudentEmailer handles 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 B is a subclass of A, then objects of type B should be usable anywhere objects of type A are 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.
  • LJsonStorage and TxtStorage are interchangeable wherever Storage is expected.
  • IStorage and Reporter are separate, focused interfaces.
  • DLibrary does 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 violationReadOnlyDocument claims to be a Document, but calling edit() or save() raises an exception.
  • ISP violationScannedImage is forced to implement edit() 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 Document interface became four small, focused interfaces.
  • LSP — Every class is now fully usable wherever its parent interface appears — no surprises, no exceptions.