Notes and exercises for learning design patterns
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 |
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.
class Invoice:
def calculate_total(self):
...
def save_to_database(self):
...
def print_invoice(self):
...
This class has multiple responsibilities:
That means it has multiple reasons to change:
Those are different responsibilities mixed together.
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.
Use SRP when:
And, Manager, or Handler because it does too muchDo 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
NotImplementedErrorLSP 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.
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.
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.
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")
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.
Use ISP when:
NotImplementedErrorDo 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.
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.
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.
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.
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.
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.
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 |
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.
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 |
| 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 |
Use this when reviewing code.
Ask:
Does this class or function have one clear job?
Warning signs:
Ask:
When I add a new case, do I keep editing the same old function?
Warning signs:
if/elif chainsAsk:
Can this subclass be used anywhere the parent is expected?
Warning signs:
NotImplementedErrorAsk:
Does every implementer genuinely need every method in this interface?
Warning signs:
Ask:
Is my important business logic directly tied to a concrete tool?
Warning signs:
UserService creates MySQLDatabase() directlyOrderService creates SendGridEmailSender() directlyDo one job.
Add new behavior without rewriting old behavior.
Children should keep their parents’ promises.
Do not force code to depend on abilities it does not use.
Keep core logic independent from concrete tools.
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.