Design Patterns

Notes and exercises for learning design patterns

View the Project on GitHub Claptar/design-patterns

SOLID Principles Guide

SOLID is a set of five object-oriented design principles that help make code easier to change, test, and understand.

The five principles are:

Letter Principle Core idea
S Single Responsibility Principle One clear reason to change
O Open/Closed Principle Add new behavior without rewriting old code
L Liskov Substitution Principle Subclasses should safely replace their parents
I Interface Segregation Principle Prefer small, focused interfaces
D Dependency Inversion Principle Depend on abstractions, not concrete details

1. Single Responsibility Principle

Summary

The Single Responsibility Principle says:

A class, function, or module should have one main reason to change.

In simpler words:

Each piece of code should have one clear job.

This does not mean every class must have only one method. It means the class should have one coherent purpose.


Bad example

class Invoice:
    def calculate_total(self):
        ...

    def save_to_database(self):
        ...

    def print_invoice(self):
        ...

This class has multiple responsibilities:

  1. Calculating invoice totals
  2. Saving invoices
  3. Printing invoices

That means it has multiple reasons to change:

Those are different responsibilities mixed together.


Better example

class Invoice:
    def calculate_total(self):
        ...


class InvoiceRepository:
    def save(self, invoice):
        ...


class InvoicePrinter:
    def print(self, invoice):
        ...

Now each class has a clearer purpose:

Class Responsibility
Invoice Invoice business logic
InvoiceRepository Saving invoices
InvoicePrinter Printing invoices

If the database changes, update InvoiceRepository.

If the print layout changes, update InvoicePrinter.

If the invoice calculation changes, update Invoice.


When to use SRP

Use SRP when:


When SRP is too much

Do not split code into tiny pieces just for the sake of splitting.

This may be unnecessary:

class FirstNameValidator:
    ...

class LastNameValidator:
    ...

class EmailValidator:
    ...

class PasswordValidator:
    ...

A single validator may be clearer:

class UserValidator:
    def validate(self, user_data):
        ...

SRP is about cohesion, not making every class microscopic.


2. Open/Closed Principle

Summary

The Open/Closed Principle says:

Software should be open for extension, but closed for modification.

In simpler words:

You should be able to add new behavior without constantly editing old, working code.


Bad example

def calculate_discount(customer_type, price):
    if customer_type == "regular":
        return price * 0.95
    elif customer_type == "vip":
        return price * 0.80
    elif customer_type == "employee":
        return price * 0.50

Every time you add a new customer type, you have to modify this function:

elif customer_type == "student":
    return price * 0.90

That can become risky as the function grows.


Better example

class RegularDiscount:
    def apply(self, price):
        return price * 0.95


class VipDiscount:
    def apply(self, price):
        return price * 0.80


class EmployeeDiscount:
    def apply(self, price):
        return price * 0.50


def calculate_discount(discount_strategy, price):
    return discount_strategy.apply(price)

Now you can add a new discount without changing calculate_discount:

class StudentDiscount:
    def apply(self, price):
        return price * 0.90

The system is open to new discount types, but the existing calculator does not need to change.


When to use OCP

Use OCP when you have several versions of the same kind of behavior and expect more later.

Good examples:

Area Possible variations
Payments Card, PayPal, Apple Pay, bank transfer
Notifications Email, SMS, push, Slack
Exporting PDF, CSV, JSON, XML
Discounts Regular, VIP, student, seasonal
Authentication Google, GitHub, SAML, email/password

It is especially useful when you keep seeing code like this:

if type == "A":
    ...
elif type == "B":
    ...
elif type == "C":
    ...

and you know more types are likely to appear.


When OCP is too much

Do not create abstractions for every tiny condition.

This is probably fine:

if user.is_logged_in:
    show_account()
else:
    show_login()

This would probably be over-engineered:

class LoggedInPageRenderer:
    ...

class LoggedOutPageRenderer:
    ...

class PageRendererFactory:
    ...

OCP is useful when change is expected. It is not useful when you are protecting against imaginary future complexity.


3. Liskov Substitution Principle

Summary

The Liskov Substitution Principle says:

If a child class inherits from a parent class, the child should be usable anywhere the parent is expected.

In simpler words:

A subclass should not break the promises made by its parent class.


Bad example

class Bird:
    def fly(self):
        print("Flying")


class Sparrow(Bird):
    def fly(self):
        print("Sparrow flying")


class Penguin(Bird):
    def fly(self):
        raise Exception("Penguins cannot fly")

This breaks LSP because code that expects a Bird might reasonably call fly():

def make_bird_fly(bird):
    bird.fly()

This works:

make_bird_fly(Sparrow())

But this breaks:

make_bird_fly(Penguin())

The problem is not that penguins are not birds in real life. The problem is that this Bird class promises flying behavior.

A Penguin cannot safely substitute for that kind of Bird.


Better example

class Bird:
    pass


class FlyingBird(Bird):
    def fly(self):
        print("Flying")


class Sparrow(FlyingBird):
    def fly(self):
        print("Sparrow flying")


class Penguin(Bird):
    pass

Now only birds that can actually fly inherit from FlyingBird.

def make_bird_fly(bird: FlyingBird):
    bird.fly()

A penguin is still a bird, but it is not treated as a flying bird.


Classic rectangle and square example

Mathematically, a square is a rectangle. But in code, inheritance can still be wrong.

class Rectangle:
    def set_width(self, width):
        self.width = width

    def set_height(self, height):
        self.height = height

    def area(self):
        return self.width * self.height

Now imagine this:

class Square(Rectangle):
    def set_width(self, width):
        self.width = width
        self.height = width

    def set_height(self, height):
        self.width = height
        self.height = height

This can break code that expects a normal rectangle:

def resize_rectangle(rectangle):
    rectangle.set_width(10)
    rectangle.set_height(5)
    return rectangle.area()

For a rectangle, the result should be 50.

For a square, the result becomes 25 because setting the height also changes the width.

So Square is not safely substitutable for this mutable Rectangle class.


Better shape design

class Shape:
    def area(self):
        raise NotImplementedError


class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height


class Square(Shape):
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side * self.side

Both are shapes. One does not need to pretend to be the other.


When to use LSP

Think about LSP whenever you use inheritance.

Ask:

Can this child class be used anywhere the parent class is expected without surprising behavior?

Warning signs include:


When LSP is too much

LSP mostly matters when you are using inheritance or polymorphism.

If you are not creating parent-child relationships, you usually do not need to think deeply about it.

The practical advice is simple:

Do not use inheritance just because something sounds like an “is-a” relationship. Use inheritance only when the child honors the full behavior of the parent.


4. Interface Segregation Principle

Summary

The Interface Segregation Principle says:

Code should not be forced to depend on methods it does not use.

In simpler words:

Prefer small, focused interfaces over large “do everything” interfaces.


Bad example

class Machine:
    def print_document(self, document):
        ...

    def scan_document(self, document):
        ...

    def fax_document(self, document):
        ...

This works for an all-in-one office machine.

But a basic printer cannot scan or fax:

class BasicPrinter(Machine):
    def print_document(self, document):
        print("Printing")

    def scan_document(self, document):
        raise NotImplementedError()

    def fax_document(self, document):
        raise NotImplementedError()

This violates ISP because BasicPrinter is forced to implement methods it does not support.


Better example

class Printer:
    def print_document(self, document):
        ...


class Scanner:
    def scan_document(self, document):
        ...


class FaxMachine:
    def fax_document(self, document):
        ...

Now each device only implements the abilities it actually has:

class BasicPrinter(Printer):
    def print_document(self, document):
        print("Printing")


class AllInOneMachine(Printer, Scanner, FaxMachine):
    def print_document(self, document):
        print("Printing")

    def scan_document(self, document):
        print("Scanning")

    def fax_document(self, document):
        print("Faxing")

Client-focused example

Suppose this function only needs to print:

def print_report(printer):
    printer.print_document("report")

It should depend on a Printer, not a huge Machine that also scans and faxes.

The function does not care about scanning or faxing. It only needs printing.


When to use ISP

Use ISP when:


When ISP is too much

Do not split every method into its own interface.

This may be excessive:

class CanGetName:
    def get_name(self):
        ...


class CanGetEmail:
    def get_email(self):
        ...


class CanGetAge:
    def get_age(self):
        ...

A simple User interface may be clearer.

The goal is not “one method per interface.”

The goal is:

Keep interfaces grouped by what clients actually need.


5. Dependency Inversion Principle

Summary

The Dependency Inversion Principle says:

High-level code should not depend directly on low-level details. Both should depend on abstractions.

In simpler words:

Your important business logic should not be tightly glued to databases, APIs, email providers, file systems, or frameworks.


Bad example

class MySQLDatabase:
    def save_user(self, user):
        print("Saving user to MySQL")


class UserService:
    def __init__(self):
        self.database = MySQLDatabase()

    def register_user(self, user):
        self.database.save_user(user)

UserService is high-level business logic.

MySQLDatabase is a low-level technical detail.

The problem is that UserService directly creates and depends on MySQLDatabase.

If you switch databases, or want a fake database for tests, you have to modify UserService.


Better example

class UserRepository:
    def save_user(self, user):
        raise NotImplementedError


class MySQLUserRepository(UserRepository):
    def save_user(self, user):
        print("Saving user to MySQL")


class UserService:
    def __init__(self, repository: UserRepository):
        self.repository = repository

    def register_user(self, user):
        self.repository.save_user(user)

Usage:

repository = MySQLUserRepository()
service = UserService(repository)

service.register_user(user)

Now UserService does not know or care whether users are saved in MySQL, PostgreSQL, MongoDB, or a fake test repository.

It only knows:

I need something that can save a user.


Dependency inversion vs dependency injection

These are related, but not the same.

Concept Meaning
Dependency Inversion Principle Depend on abstractions, not concrete details
Dependency Injection Pass dependencies in from the outside

This is dependency injection:

class OrderService:
    def __init__(self, email_sender):
        self.email_sender = email_sender

This is the opposite:

class OrderService:
    def __init__(self):
        self.email_sender = SendGridEmailSender()

Dependency injection is one common way to follow the Dependency Inversion Principle.


Another example: email sender

Bad:

class SendGridEmailSender:
    def send(self, to, subject, body):
        print("Sending email with SendGrid")


class OrderService:
    def __init__(self):
        self.email_sender = SendGridEmailSender()

    def place_order(self, order):
        self.email_sender.send(
            order.customer_email,
            "Order confirmed",
            "Thanks for your order"
        )

Better:

class EmailSender:
    def send(self, to, subject, body):
        raise NotImplementedError


class SendGridEmailSender(EmailSender):
    def send(self, to, subject, body):
        print("Sending email with SendGrid")


class FakeEmailSender(EmailSender):
    def __init__(self):
        self.sent_messages = []

    def send(self, to, subject, body):
        self.sent_messages.append((to, subject, body))


class OrderService:
    def __init__(self, email_sender: EmailSender):
        self.email_sender = email_sender

    def place_order(self, order):
        self.email_sender.send(
            order.customer_email,
            "Order confirmed",
            "Thanks for your order"
        )

Now production code can use SendGridEmailSender, while tests can use FakeEmailSender.


When to use DIP

Use DIP when your core code talks to things that are:

Examples:

Dependency Why abstraction helps
Database Easier to swap and test
Email provider Easier to change vendors
Payment gateway Easier to support multiple providers
File system Easier to test without real files
External API Easier to mock failures
Message queue Easier to isolate business logic

When DIP is too much

Do not abstract every tiny operation.

This is overkill:

class AdditionProvider:
    def add(self, a, b):
        return a + b


class Calculator:
    def __init__(self, addition_provider):
        self.addition_provider = addition_provider

This is enough:

result = a + b

DIP is most useful for dependencies that are external, unstable, slow, or hard to test.


How the Principles Work Together

The SOLID principles often overlap.

Here is one combined example.

class EmailSender:
    def send(self, to, subject, body):
        raise NotImplementedError


class SendGridEmailSender(EmailSender):
    def send(self, to, subject, body):
        print("Sending email with SendGrid")


class MailgunEmailSender(EmailSender):
    def send(self, to, subject, body):
        print("Sending email with Mailgun")


class FakeEmailSender(EmailSender):
    def __init__(self):
        self.sent_messages = []

    def send(self, to, subject, body):
        self.sent_messages.append((to, subject, body))


class OrderService:
    def __init__(self, email_sender: EmailSender):
        self.email_sender = email_sender

    def place_order(self, order):
        # order business logic would go here
        self.email_sender.send(
            order.customer_email,
            "Order confirmed",
            "Thanks for your order"
        )

This uses multiple SOLID principles:

Principle How it appears here
SRP OrderService handles orders; email senders handle sending email
OCP Add a new email provider without changing OrderService
LSP SendGridEmailSender, MailgunEmailSender, and FakeEmailSender can all replace EmailSender
ISP EmailSender only exposes the send method that clients need
DIP OrderService depends on EmailSender, not directly on SendGrid or Mailgun

Quick Comparison

Principle Main question Common smell Typical fix
SRP Does this have one reason to change? Class does too many unrelated things Split responsibilities
OCP Can I add behavior without changing old code? Long if/elif or switch chains Use polymorphism, strategies, plugins
LSP Can the child replace the parent safely? Subclass breaks parent expectations Fix inheritance or use composition/interfaces
ISP Is this interface too large? Classes implement fake or unused methods Split into smaller interfaces
DIP Does core logic depend on details? Service creates concrete database/API/email objects Depend on abstractions and inject dependencies

Practical Checklist

Use this when reviewing code.

SRP checklist

Ask:

Does this class or function have one clear job?

Warning signs:


OCP checklist

Ask:

When I add a new case, do I keep editing the same old function?

Warning signs:


LSP checklist

Ask:

Can this subclass be used anywhere the parent is expected?

Warning signs:


ISP checklist

Ask:

Does every implementer genuinely need every method in this interface?

Warning signs:


DIP checklist

Ask:

Is my important business logic directly tied to a concrete tool?

Warning signs:


Final Mental Model

SRP

Do one job.

OCP

Add new behavior without rewriting old behavior.

LSP

Children should keep their parents’ promises.

ISP

Do not force code to depend on abilities it does not use.

DIP

Keep core logic independent from concrete tools.


Best Overall Rule

Do not apply SOLID mechanically.

Use these principles when they make code easier to change, test, and understand.

A simple if statement is not automatically bad.

A small class is not automatically good.

An abstraction is useful only when it reduces real complexity instead of adding imaginary complexity.

The goal is not to make code look “architectural.”

The goal is to make future changes safer and easier.


Exercise 1 · Exercise 2 · Exercise 3