Ran Wei / Object-Oriented Design / Module 9
中文
Object-Oriented Design — Ran Wei

Module 9: SOLID Principles

Five design principles that make object-oriented systems flexible, maintainable, and resilient to change.

Prereq: OOD Modules 1–8 Python 3 SRP OCP LSP ISP DIP
1

Why SOLID?

Throughout Modules 1–8 you learned how to model objects, hide data behind clean interfaces, build inheritance hierarchies, leverage polymorphism, compose objects, define contracts, and apply design patterns. These are the mechanics of object-oriented design. But mechanics alone do not guarantee good design.

In the early 2000s, Robert C. Martin (widely known as Uncle Bob) collected five principles that had been discussed in the OO community for years and arranged them into the memorable acronym SOLID. These principles are not rules to follow blindly — they are guidelines that, when applied thoughtfully, lead to code that is easier to understand, extend, test, and maintain.

💡 The SOLID Acronym

SOLID stands for five principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. Each one addresses a specific kind of design fragility that appears in real-world codebases as they grow.

The table below gives a quick overview. We will explore each principle in depth in the sections that follow, using examples from our bookshop domain.

LetterPrincipleOne-Line Summary
SSingle ResponsibilityA class should have only one reason to change.
OOpen / ClosedOpen for extension, closed for modification.
LLiskov SubstitutionSubtypes must be substitutable for their base types.
IInterface SegregationClients should not depend on interfaces they don’t use.
DDependency InversionDepend on abstractions, not on concrete implementations.

Think of these principles as a checklist you can apply during design reviews. When a design feels brittle or hard to change, one or more SOLID principles can usually point you toward a better structure.

2

Single Responsibility Principle

The Single Responsibility Principle (SRP) states: a class should have only one reason to change. “Reason to change” maps to a responsibility — a distinct concern that could evolve independently. When a class mixes multiple responsibilities, a change in one area risks breaking another.

A Class Doing Too Much

Consider an Order class that handles business logic, formats output, and saves data to a file — three separate concerns rolled into one:

Python
# BAD: Order has three reasons to change
class Order:
    def __init__(self, customer, items):
        self.customer = customer
        self.items = items

    def total(self):
        return sum(item.price * item.qty for item in self.items)

    def to_invoice_string(self):
        # Responsibility 2: formatting
        lines = [f"Invoice for {self.customer.name}"]
        for item in self.items:
            lines.append(f"  {item.title} x{item.qty} = {item.price * item.qty}")
        lines.append(f"Total: {self.total()}")
        return "\n".join(lines)

    def save_to_file(self, path):
        # Responsibility 3: persistence
        with open(path, "w") as f:
            f.write(self.to_invoice_string())

If the invoice format changes, you edit Order. If the storage mechanism changes (e.g., from file to database), you edit Order again. Each change risks introducing bugs in unrelated behaviour.

Refactored: One Class, One Job

We extract each responsibility into its own class:

Python
# GOOD: each class has a single responsibility
class Order:
    """Pure domain logic: what is in the order and its total."""
    def __init__(self, customer, items):
        self.customer = customer
        self.items = items

    def total(self):
        return sum(item.price * item.qty for item in self.items)


class InvoiceFormatter:
    """Turns an Order into a human-readable invoice."""
    def format(self, order):
        lines = [f"Invoice for {order.customer.name}"]
        for item in order.items:
            lines.append(f"  {item.title} x{item.qty} = {item.price * item.qty}")
        lines.append(f"Total: {order.total()}")
        return "\n".join(lines)


class OrderRepository:
    """Handles persistence of orders."""
    def save(self, order, path):
        formatter = InvoiceFormatter()
        with open(path, "w") as f:
            f.write(formatter.format(order))
✅ How to Spot SRP Violations

Ask yourself: “If I describe what this class does, do I need the word ‘and’?” If you say “Order calculates totals and formats invoices and saves to disk,” you have at least two responsibilities too many.

Notice that SRP does not mean “a class should have only one method.” A class may have many methods as long as they all serve the same cohesive responsibility. The Order class above could have add_item, remove_item, total, and apply_discount — all are part of the single responsibility of managing order contents.

3

Open / Closed Principle

The Open/Closed Principle (OCP) states: software entities should be open for extension but closed for modification. In practice, this means you should be able to add new behaviour without editing existing, tested code.

You already used this idea in Module 4 (Polymorphism) and Module 7 (Design Patterns). OCP is the principle that explains why those techniques work so well.

The Problem: Hardcoded Discounts

Imagine our bookshop needs discount strategies. A naive approach uses conditionals:

Python
# BAD: adding a new discount means modifying this function
def apply_discount(order, discount_type):
    if discount_type == "percentage":
        return order.total() * 0.9
    elif discount_type == "fixed":
        return order.total() - 5.00
    elif discount_type == "buy2get1":
        # complex logic...
        pass
    # Every new discount type requires editing this function!

Every time the business invents a new promotion, a developer must open this function, add another branch, and risk breaking the existing ones. This violates OCP.

Refactored: Strategy Pattern

We define an abstract discount and let each strategy extend it:

Python
from abc import ABC, abstractmethod

class DiscountStrategy(ABC):
    """Abstract discount — closed for modification."""
    @abstractmethod
    def apply(self, total):
        ...

class PercentageDiscount(DiscountStrategy):
    def __init__(self, pct):
        self.pct = pct

    def apply(self, total):
        return total * (1 - self.pct)

class FixedDiscount(DiscountStrategy):
    def __init__(self, amount):
        self.amount = amount

    def apply(self, total):
        return max(0, total - self.amount)

# Usage — Order doesn't know which strategy is used
class Order:
    def __init__(self, items, discount: DiscountStrategy = None):
        self.items = items
        self._discount = discount

    def total(self):
        raw = sum(i.price * i.qty for i in self.items)
        if self._discount:
            return self._discount.apply(raw)
        return raw
✅ Adding New Discounts

To add a “buy 2 get 1 free” discount, you create a new class BulkDiscount(DiscountStrategy) and pass it into Order. You never touch Order, PercentageDiscount, or FixedDiscount. The system is open for extension (new subclasses) and closed for modification (existing classes stay untouched).

OCP relies heavily on abstraction and polymorphism — the very tools you learned in Modules 4 and 6. The principle simply formalises the motivation behind them.

4

Liskov Substitution Principle

The Liskov Substitution Principle (LSP), formulated by Barbara Liskov in 1987, states: if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering the desirable properties of the program. In simpler terms, a subclass must be usable anywhere its parent class is expected, without surprises.

The Classic Violation: Square and Rectangle

A Square is-a Rectangle in geometry, so it seems natural to make it a subclass. But watch what happens:

Python
class Rectangle:
    def __init__(self, w, h):
        self._w = w
        self._h = h

    @property
    def width(self):  return self._w

    @width.setter
    def width(self, v):  self._w = v

    @property
    def height(self):  return self._h

    @height.setter
    def height(self, v):  self._h = v

    def area(self):
        return self._w * self._h


class Square(Rectangle):
    # Override setters so width == height always
    @Rectangle.width.setter
    def width(self, v):
        self._w = v
        self._h = v   # surprise side effect!

    @Rectangle.height.setter
    def height(self, v):
        self._w = v   # surprise side effect!
        self._h = v

Any code that sets width and height independently on a Rectangle will break when given a Square, because setting one dimension silently changes the other. The subtype is not substitutable.

⚠️ Common LSP Violations

1. Overriding a method to throw NotImplementedError — the parent promises the method works; the child breaks that promise.
2. Strengthening preconditions — a child that accepts fewer inputs than the parent.
3. Weakening postconditions — a child that returns a less specific result than the parent guarantees.
4. Surprise side effects — the Square/Rectangle example above.

A Bookshop Example

In our bookshop, suppose we have a Product base class with a discount_price method that promises to return a value ≤ the original price. A PremiumEdition subclass that returns a higher price (because it adds packaging costs inside discount_price) violates LSP:

Python
class Product:
    def __init__(self, title, price):
        self.title = title
        self.price = price

    def discount_price(self, pct):
        """Return price reduced by pct. Always <= self.price."""
        return self.price * (1 - pct)


class PremiumEdition(Product):
    def discount_price(self, pct):
        # BAD: breaks the postcondition (<= self.price)
        return self.price * (1 - pct) + 15.00  # packaging surcharge

The fix is straightforward: model the surcharge separately (e.g., packaging_fee property) instead of smuggling it into a method whose contract says “returns a discounted price.”

5

Interface Segregation Principle

The Interface Segregation Principle (ISP) states: clients should not be forced to depend on interfaces they do not use. A “fat” interface that bundles many unrelated methods forces implementors to provide stubs for things they don’t care about.

A Fat Interface

Suppose we design a single BookService interface that every part of the system must use:

Python
from abc import ABC, abstractmethod

class BookService(ABC):
    """Fat interface — every implementor must provide ALL of these."""
    @abstractmethod
    def search(self, query): ...

    @abstractmethod
    def add_to_cart(self, book, cart): ...

    @abstractmethod
    def process_payment(self, order): ...

    @abstractmethod
    def generate_report(self): ...

    @abstractmethod
    def send_email(self, to, subject, body): ...

A search module only needs search, but it is forced to depend on (and potentially stub out) process_payment, generate_report, and send_email. This coupling is unnecessary and makes the codebase harder to evolve.

Refactored: Thin, Focused Interfaces

Python
from abc import ABC, abstractmethod

class Searchable(ABC):
    @abstractmethod
    def search(self, query): ...

class CartManager(ABC):
    @abstractmethod
    def add_to_cart(self, book, cart): ...

class PaymentProcessor(ABC):
    @abstractmethod
    def process_payment(self, order): ...

class ReportGenerator(ABC):
    @abstractmethod
    def generate_report(self): ...

class Notifier(ABC):
    @abstractmethod
    def send(self, to, subject, body): ...

Now each part of the system depends only on the interface it actually needs. The search module depends on Searchable; the checkout flow depends on PaymentProcessor. A concrete class can implement multiple interfaces if it genuinely provides all those capabilities:

Python
class BookstoreService(Searchable, CartManager):
    """Implements only the interfaces it genuinely supports."""
    def search(self, query):
        # full-text search over book catalogue
        return [b for b in self._books if query.lower() in b.title.lower()]

    def add_to_cart(self, book, cart):
        cart.add(book)
💡 Python and ISP

Python uses abstract base classes (abc.ABC) rather than language-level interfaces. The principle still applies: keep your ABCs small and focused. Python’s multiple inheritance makes it easy to combine several thin ABCs into one concrete class.

6

Dependency Inversion Principle

The Dependency Inversion Principle (DIP) states: high-level modules should not depend on low-level modules; both should depend on abstractions. Additionally, abstractions should not depend on details; details should depend on abstractions.

Without DIP, high-level business logic becomes tightly coupled to infrastructure details (databases, APIs, email providers). Change the infrastructure and you must rewrite the business logic.

The Problem: Direct Dependency

Python
# BAD: high-level OrderProcessor depends directly on low-level StripeGateway
class StripeGateway:
    def charge(self, amount, token):
        # calls Stripe API
        print(f"Charging {amount} via Stripe")
        return True

class OrderProcessor:
    def __init__(self):
        self.gateway = StripeGateway()  # hardcoded dependency!

    def checkout(self, order, token):
        if self.gateway.charge(order.total(), token):
            order.status = "paid"

Switching to PayPal means rewriting OrderProcessor. Testing requires a live Stripe connection (or complex mocking).

Refactored: Depend on Abstractions

Python
from abc import ABC, abstractmethod

class PaymentGateway(ABC):
    """Abstraction that both high-level and low-level code depend on."""
    @abstractmethod
    def charge(self, amount, token) -> bool: ...

class StripeGateway(PaymentGateway):
    def charge(self, amount, token):
        print(f"Charging {amount} via Stripe")
        return True

class PayPalGateway(PaymentGateway):
    def charge(self, amount, token):
        print(f"Charging {amount} via PayPal")
        return True

class OrderProcessor:
    """High-level module depends on the PaymentGateway abstraction."""
    def __init__(self, gateway: PaymentGateway):
        self._gateway = gateway  # injected, not created

    def checkout(self, order, token):
        if self._gateway.charge(order.total(), token):
            order.status = "paid"

# Usage — the caller decides which gateway to inject
processor = OrderProcessor(StripeGateway())
processor.checkout(my_order, "tok_abc123")

This is constructor injection — the dependency is passed in at construction time rather than created internally. The bridge below shows how the dependency arrows are inverted:

Without DIP
With DIP
OrderProcessor
OrderProcessor
↓ depends on
↓ depends on
StripeGateway
PaymentGateway (ABC)
↑ implements
StripeGateway
💡 Testing Benefits

With DIP you can inject a FakeGateway during tests — no network calls, no credentials, instant feedback. This is one of the most practical benefits of dependency inversion.

7

SOLID in Practice

Individual SOLID principles are useful on their own, but their real power emerges when they work together. Let us walk through a realistic bookshop checkout flow that applies all five principles.

A Checkout System

Python
from abc import ABC, abstractmethod

# ISP: small, focused interfaces
class PaymentGateway(ABC):
    @abstractmethod
    def charge(self, amount, token) -> bool: ...

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

# OCP: new discount strategies without modifying existing code
class DiscountStrategy(ABC):
    @abstractmethod
    def apply(self, total): ...

class NoDiscount(DiscountStrategy):
    def apply(self, total):
        return total

class MemberDiscount(DiscountStrategy):
    def apply(self, total):
        return total * 0.85  # 15% off for members

# SRP: Order only manages items and totals
class Order:
    def __init__(self, customer, items, discount: DiscountStrategy):
        self.customer = customer
        self.items = items
        self._discount = discount
        self.status = "pending"

    def total(self):
        raw = sum(i.price * i.qty for i in self.items)
        return self._discount.apply(raw)

# DIP: CheckoutService depends on abstractions, not concretes
class CheckoutService:
    def __init__(self, gateway: PaymentGateway, notifier: Notifier):
        self._gateway = gateway
        self._notifier = notifier

    def process(self, order, token):
        if self._gateway.charge(order.total(), token):
            order.status = "paid"
            self._notifier.send(
                order.customer.email,
                f"Order confirmed! Total: {order.total()}"
            )

In the design above: Order has a single responsibility (SRP); discounts are extensible without modification (OCP); every subclass honours its base-class contract (LSP); interfaces are small and focused (ISP); and high-level CheckoutService depends on abstractions (DIP).

💡 Don’t Over-Engineer

SOLID principles are guidelines, not laws. Applying every principle to every class in a small script would create unnecessary complexity. Use SOLID when your codebase is growing, when classes are hard to test in isolation, or when a change in one area keeps breaking another. Pragmatism beats purity.

What Each Principle Prevents

PrinciplePreventsSymptom of Violation
SRPTangled responsibilitiesChanging one feature breaks an unrelated feature
OCPFragile conditionalsEvery new variant requires editing existing code
LSPBroken substitutabilitySubclass behaves unexpectedly when used as parent type
ISPForced dependenciesImplementors must stub methods they do not need
DIPTight coupling to detailsCannot test or swap infrastructure without rewriting logic
8

Summary

The SOLID principles give you a vocabulary for evaluating and improving object-oriented designs. Here is what you should take away from this module:

Single Responsibility

Each class should have one reason to change. Separate domain logic from formatting, persistence, and notification.

Open / Closed

Design for extension through abstraction and polymorphism. New behaviour should not require modifying existing, tested code.

Liskov Substitution

Subclasses must honour the contracts of their parents. If substitution causes surprises, the hierarchy is wrong.

Interface Segregation

Keep interfaces small and focused. Clients should depend only on the methods they actually call.

Dependency Inversion

High-level policy should not depend on low-level detail. Inject abstractions to achieve loose coupling and easy testing.

Pragmatism

Apply SOLID where it reduces pain. Over-engineering a small script with five layers of abstraction defeats the purpose.

📚
Series Complete — What next?

Explore advanced patterns, domain-driven design, clean architecture, and test-driven development to take your OOD skills further.