๐งฑ SOLID Principles Deep Dive (S, O, L): Let's Learn from ZERO!
SOLID shows up in Python, FastAPI, Java, Spring Boot, React architecture and almost every real-world codebase. Interviewers and mentors rarely stop at "define SOLID". They ask why we use it, what problem it solves, and how you apply it in a project. ๐ฏ
In this post we'll cover the first three principles, S, O and L, step by step, with simple examples and practical code. ๐
1. ๐ค First, What Are SOLID Principles?
Imagine you're building an Instagram-like social media app with registration and login, posts, image uploads, likes, comments, notifications and follows. At first you write everything in one huge class:
class SocialMedia:
def register_user(self):
pass
def login_user(self):
pass
def create_post(self):
pass
def upload_image(self):
pass
def like_post(self):
pass
def comment_on_post(self):
pass
def send_notification(self):
pass
It works, right? But when the app grows to 100 features:
- ๐ต Changing login might accidentally affect registration
- ๐ต Changing image upload might affect post creation
- ๐งช Testing individual features becomes difficult
- โป๏ธ Reusing functionality becomes difficult
- ๐ Adding new features gets complicated
This is tightly coupled, poorly organized code. We need a better way to design software. That's where SOLID comes in!
๐ค What does SOLID stand for?
| Letter | Principle | One-line idea |
|---|---|---|
| S | Single Responsibility | One class should have one responsibility |
| O | Open/Closed | Open for extension, closed for modification |
| L | Liskov Substitution | Child classes should safely replace parent classes |
| I | Interface Segregation | Don't force classes to implement methods they don't need |
| D | Dependency Inversion | Depend on abstractions, not concrete implementations |
Today we'll go deep on S, O and L.
2. 1๏ธโฃ S: Single Responsibility Principle (SRP)
2.1 ๐ค What does it mean?
๐ A class should have only one reason to change.
- Single = one
- Responsibility = one specific job
- Principle = a rule to follow
In simple English: a class should focus on one particular job instead of handling several unrelated ones.
๐ฝ๏ธ Real-life example: a restaurant chef prepares food. The chef should not also manage customer payments, clean the whole restaurant, maintain the database or handle salaries. Those jobs belong to other people or departments. When one responsibility changes, it shouldn't unnecessarily affect everything else. The same logic applies to classes.
2.2 โ SRP with bad code
A user can register, be saved to a database, and receive a welcome email. A beginner might write:
class User:
def register(self, username, email):
# Validate user
print("User registered")
# Save user to database
print("Saving user to database")
# Send welcome email
print("Sending welcome email")
This class has three different responsibilities:
| Functionality | Responsibility |
|---|---|
| Validate user | Business validation |
| Save user | Database operations |
| Send email | Email communication |
Now imagine:
- ๐ง The email provider changes from Gmail SMTP to SendGrid โ modify
User - ๐๏ธ The database changes from PostgreSQL to MySQL โ modify
User - โ
Validation rules change โ modify
User
The class has multiple reasons to change. That's an SRP violation. ๐จ
2.3 โ Correct implementation using SRP
Separate responsibilities into different classes:
UserService โ handles user registration business logic
UserRepository โ database operations
EmailService โ email operations
Class 1: UserRepository (only database operations)
class UserRepository:
def save_user(self, username, email):
print("Saving user to database")
Class 2: EmailService (only sending emails)
class EmailService:
def send_welcome_email(self, email):
print("Sending welcome email")
Class 3: UserService (coordinates the registration process)
class UserService:
def __init__(self, repository, email_service):
self.repository = repository
self.email_service = email_service
def register_user(self, username, email):
print("Validating user")
self.repository.save_user(username, email)
self.email_service.send_welcome_email(email)
print("Registration completed")
Run it:
repository = UserRepository()
email_service = EmailService()
user_service = UserService(
repository,
email_service,
)
user_service.register_user(
"Deepika",
"deepika@gmail.com",
)
Validating user
Saving user to database
Sending welcome email
Registration completed
Each class now has a clear responsibility.
๐
UserServicestill coordinates several steps, but its responsibility is the registration use case. Having multiple method calls doesn't automatically violate SRP.
2.4 โ Why is SRP important?
| Benefit | Explanation |
|---|---|
| ๐ ๏ธ Maintainability | Easier to modify individual functionality |
| โป๏ธ Reusability | Reuse EmailService in other modules |
| ๐งช Testability | Test each class independently |
| ๐ Readability | Code becomes easier to understand |
| ๐ก๏ธ Reduced side effects | Changes are less likely to break unrelated features |
2.5 โก SRP in a FastAPI backend
backend/
โโโ app/
โโโ api/
โ โโโ routes/
โ โโโ auth.py
โ โโโ posts.py
โ
โโโ services/
โ โโโ auth_service.py
โ โโโ post_service.py
โ
โโโ repositories/
โ โโโ user_repository.py
โ โโโ post_repository.py
โ
โโโ models/
โโโ user.py
โโโ post.py
| Layer | Responsibility |
|---|---|
| ๐ฃ๏ธ API routes | Receive HTTP requests and return responses |
| ๐ง Services | Handle business logic |
| ๐ Repositories | Communicate with the database |
| ๐๏ธ Models | Represent database entities |
For example: auth.py handles login and registration endpoints, auth_service.py handles authentication business logic, user_repository.py handles user database operations, and user.py represents the User database model. This separation supports SRP.
๐ค Interview question: What happens if we put database queries, business logic and HTTP request handling inside one route function?
Answer: The route becomes responsible for too many things. It becomes harder to test, maintain, reuse and modify independently.
3. 2๏ธโฃ O: Open/Closed Principle (OCP)
3.1 ๐ค What does it mean?
๐ Software entities should be open for extension but closed for modification.
It sounds confusing at first, so let's take each phrase:
- Open for extension โ we can add new functionality
- Closed for modification โ we avoid repeatedly changing existing, working code to support every new requirement
โ ๏ธ It does NOT mean existing code can never be changed. It means designing code so that adding a feature usually doesn't require modifying existing, stable logic.
๐ณ Real-life example: a payment app first supports credit and debit cards. Later the company adds UPI, PayPal and net banking. A poorly designed system forces you to change the same payment-processing function every time. A better design lets you add new payment methods without rewriting the existing payment flow.
3.2 โ OCP with bad code
class PaymentService:
def pay(self, payment_type, amount):
if payment_type == "card":
print("Processing card payment")
elif payment_type == "upi":
print("Processing UPI payment")
elif payment_type == "paypal":
print("Processing PayPal payment")
It works! But tomorrow we add Google Pay, so we modify pay(). Next month another method, and we modify pay() again. The method keeps growing with new conditions, which makes it harder to maintain and increases the chance of breaking existing functionality.
3.3 โ Correct implementation using OCP
We use abstraction and polymorphism. Don't worry, we'll explain both.
First, a common interface using Python's ABC. An interface is essentially a contract that says which methods an implementation must provide.
from abc import ABC, abstractmethod
class PaymentMethod(ABC):
@abstractmethod
def pay(self, amount):
pass
-
ABClets us define an abstract base class -
@abstractmethoddeclares a method that child classes must implement -
PaymentMethoddefines the common contract
Now the different payment methods:
class CardPayment(PaymentMethod):
def pay(self, amount):
print(f"Paid {amount} using Card")
class UPIPayment(PaymentMethod):
def pay(self, amount):
print(f"Paid {amount} using UPI")
class PayPalPayment(PaymentMethod):
def pay(self, amount):
print(f"Paid {amount} using PayPal")
The payment service:
class PaymentService:
def process_payment(self, payment_method, amount):
payment_method.pay(amount)
Run it:
service = PaymentService()
card = CardPayment()
upi = UPIPayment()
paypal = PayPalPayment()
service.process_payment(card, 1000)
service.process_payment(upi, 500)
service.process_payment(paypal, 2000)
Paid 1000 using Card
Paid 500 using UPI
Paid 2000 using PayPal
Now the business introduces Google Pay. We simply add another class:
class GooglePay(PaymentMethod):
def pay(self, amount):
print(f"Paid {amount} using Google Pay")
google_pay = GooglePay()
service.process_payment(google_pay, 1500)
Paid 1500 using Google Pay
Look carefully: we did not modify the existing PaymentService class. We extended the system by adding a new implementation. OCP achieved! ๐
3.4 ๐ญ What is polymorphism here?
๐ Polymorphism means one common interface, many different implementations.
payment_method.pay(amount)
The same method call behaves differently depending on the object passed:
| Object | What happens? |
|---|---|
CardPayment |
Card payment |
UPIPayment |
UPI payment |
PayPalPayment |
PayPal payment |
GooglePay |
Google Pay payment |
Same method name, different behavior. That's polymorphism.
3.5 โ Why is OCP important?
- โ Adding new features becomes easier
- ๐งฑ Existing tested code stays stable
- ๐ Reduces repeated modifications
- ๐ Improves scalability
- ๐ก๏ธ Reduces the risk of introducing bugs into existing functionality
3.6 ๐ฑ OCP in a social media app
Your app supports email notifications at first. Later you add push, SMS and in-app notifications. Instead of one huge function with many if/elif branches, create a common notification interface. Each notification type implements its own sending behavior. When a new type arrives, you add a new implementation and don't rewrite the main notification-processing logic.
๐ค Interview question: Why is OCP useful in a growing application?
Answer: Requirements change frequently. OCP lets developers add new functionality through extension instead of repeatedly modifying existing, stable code, reducing the risk of breaking existing features.
4. 3๏ธโฃ L: Liskov Substitution Principle (LSP)
Probably the trickiest of the three at first. But once you see the example, it's easy. ๐
4.1 ๐ค What does it mean?
๐ Objects of a parent class should be replaceable with objects of its child classes without breaking the correctness of the program.
- Parent class = base class
- Child class = derived class
- Substitution = replacing one thing with another
In simple English: if a child class inherits from a parent class, the child should behave in a way that is compatible with what the parent promises. Wherever you use a child object instead of a parent object, the application should keep working correctly.
๐งฌ First, understand inheritance
Inheritance is when one class acquires properties and methods from another:
class Animal:
def eat(self):
print("Animal is eating")
class Dog(Animal):
def bark(self):
print("Dog is barking")
Animal is the parent, Dog is the child, and Dog inherits eat():
dog = Dog()
dog.eat()
dog.bark()
Animal is eating
Dog is barking
But LSP asks something more important: can we safely use the child class wherever the parent class is expected?
4.2 ๐ฆ Real-life example: birds
class Bird:
def fly(self):
print("Bird is flying")
class Sparrow(Bird):
def fly(self):
print("Sparrow is flying")
class Eagle(Bird):
def fly(self):
print("Eagle is flying")
Both can fly, so everything is fine. Now:
def make_bird_fly(bird):
bird.fly()
make_bird_fly(Sparrow())
make_bird_fly(Eagle())
Sparrow is flying
Eagle is flying
The child objects substitute for the parent type without problems. That's LSP. โ
4.3 โ Understanding an LSP violation
Now we introduce a Penguin. Can a penguin fly? No! But our parent class says every Bird has a fly() method.
class Penguin(Bird):
def fly(self):
raise Exception("Penguins cannot fly")
penguin = Penguin()
make_bird_fly(penguin)
Exception: Penguins cannot fly
make_bird_fly() expects a bird that can fly. Penguin inherits from Bird but breaks that expectation. The parent promises flying behavior, and the child can't deliver it. This is a classic Liskov Substitution Principle violation. ๐จ
4.4 โ How do we fix it?
Redesign the hierarchy. Instead of saying every Bird can fly, separate general bird behavior from flying behavior:
class Bird:
def eat(self):
print("Bird is eating")
class FlyingBird(Bird):
def fly(self):
print("Bird is flying")
class Sparrow(FlyingBird):
def fly(self):
print("Sparrow is flying")
class Eagle(FlyingBird):
def fly(self):
print("Eagle is flying")
class Penguin(Bird):
def swim(self):
print("Penguin is swimming")
Bird โ common bird behavior: eat()
โโโ FlyingBird โ fly()
โ โโโ Sparrow
โ โโโ Eagle
โโโ Penguin โ swim() (does not promise flying behavior)
Now:
def make_bird_fly(bird: FlyingBird):
bird.fly()
make_bird_fly(Sparrow())
make_bird_fly(Eagle())
We don't pass Penguin. The design now reflects the actual capabilities of the objects.
๐ก LSP isn't simply about whether inheritance exists. It's about whether the child class honors the behavioral contract of the parent class.
4.5 ๐ Another important example: Rectangle and Square
A famous interview example. Mathematically a square is a special kind of rectangle, so a developer might try this:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def set_width(self, width):
self.width = width
def set_height(self, height):
self.height = height
def area(self):
return self.width * self.height
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
Test it:
def calculate_area(rectangle):
rectangle.set_width(5)
rectangle.set_height(10)
return rectangle.area()
With a normal rectangle:
rectangle = Rectangle(2, 3)
print(calculate_area(rectangle)) # 50 โ
With a square:
square = Square(2, 2)
print(calculate_area(square)) # 100 โ
Why? Setting the height to 10 also changes the square's width to 10. The function expected a rectangle whose width and height can change independently, and Square breaks that expectation. That's an LSP violation.
๐ง The lesson: a mathematically valid inheritance relationship doesn't always make a good software inheritance relationship.
4.6 ๐ How to spot LSP violations
Warning signs:
- A child method throws an exception for behavior the parent promises to support
- A child method does nothing when the parent promises meaningful behavior
- A child changes the expected output or behavior in an incompatible way
- A child requires stricter inputs than the parent
- A child returns weaker or incompatible results than the parent contract promises
There are deeper formal rules behind this:
- Preconditions should not be strengthened
- Postconditions should not be weakened
- Parent invariants should be preserved
| Term | Simple meaning |
|---|---|
| Preconditions | What must be true before a method runs |
| Postconditions | What the method guarantees after execution |
| Invariants | Rules that must stay true throughout the object's valid lifetime |
For example, if a parent method accepts any positive number, a child shouldn't suddenly accept only numbers greater than 100. That would strengthen the precondition and violate LSP.
4.7 โ Why is LSP important?
- ๐ Makes inheritance reliable
- ๐ซ Prevents unexpected runtime errors
- โป๏ธ Improves code reusability
- ๐ญ Makes polymorphism safer
- ๐๏ธ Helps you design correct class hierarchies
- ๐งช Makes unit testing easier, because child objects can be tested against parent expectations
5. โ๏ธ Comparing S, O and L
| Principle | Main question | Problem it prevents |
|---|---|---|
| S: SRP | Does this class have one clear responsibility? | Classes doing too many unrelated jobs |
| O: OCP | Can I add features without repeatedly modifying stable code? | Constant modification of existing logic |
| L: LSP | Can a child object replace its parent safely? | Incorrect inheritance and unexpected behavior |
One example to remember each:
- ๐
ข SRP: a
Userclass shouldn't handle everything. Separate database operations, email sending and registration. - ๐
OCP: adding Google Pay shouldn't require rewriting
PaymentService. Add a new implementation of the common contract. - ๐
LSP: a
Penguinshouldn't be forced to implement flying. Design inheritance around actual behavioral capabilities.
6. ๐ค How Are These Principles Related?
They're separate, but they complement each other. Imagine an e-commerce backend with PaymentService, EmailService, OrderService, PaymentMethod, CardPayment and UPIPayment:
- SRP: each class has a well-defined responsibility
- OCP: you can introduce a new payment method without changing the existing payment-processing logic
-
LSP: every payment implementation honors the common
PaymentMethodcontract, so the payment service can safely use any of them
One good design can follow all three principles at once. โจ
7. โ ๏ธ Common Mistakes with SOLID
Following SOLID doesn't mean creating hundreds of unnecessary classes.
| Mistake | Correct understanding |
|---|---|
| "SRP means one method per class" | No. It means one cohesive responsibility per class |
| "OCP means never modify existing code" | No. It means designing for extension without unnecessary modification |
| "LSP means every child must have exactly the same code as its parent" | No. Children can implement things differently but must preserve expected behavior |
| "More classes always mean better SOLID design" | No. Avoid unnecessary complexity |
| "Inheritance automatically means LSP is satisfied" | No. Behavioral compatibility is what matters |
8. ๐ค Interview Questions and Answers
Q1. What is SOLID?
A collection of five object-oriented design principles that help developers write maintainable, flexible, reusable and scalable software.
Q2. What's the difference between SRP and OCP?
SRP ensures a class has one clear responsibility. OCP allows new functionality to be added without repeatedly modifying existing, stable code.
Q3. What does "one reason to change" mean in SRP?
A class should be responsible for one cohesive area of functionality. If that responsibility changes, the class may need modification.
Q4. How do we implement OCP in Python?
Using abstraction, abstract base classes, inheritance and polymorphism to create extensible designs.
Q5. What is polymorphism?
It lets different objects respond to the same method call in their own ways.
Q6. What happens when LSP is violated?
A child object may cause unexpected errors or incorrect behavior when used in place of its parent object.
Q7. Is a Square a Rectangle? Why can it violate LSP?
Mathematically yes. But if a Rectangle lets width and height change independently, a Square that forces them to stay equal violates the expected behavior of that implementation.
Q8. Are SOLID principles only for Java?
No. They apply to object-oriented design in Python, Java, C++, C# and other languages, and their ideas also help when designing larger application architectures.
9. ๐งช Test Your Understanding!
Try these before peeking at the answers! โค๏ธ
-
A class handles user registration, database operations and email sending. Which principle is violated?
- A) OCP
- B) SRP
- C) LSP
-
You add Google Pay by creating a new payment class without modifying
PaymentService. Which principle are you following?- A) SRP
- B) LSP
- C) OCP
-
A
Penguininherits fromBird, but the parent promises every bird can fly. Which principle is violated?- A) LSP
- B) OCP
- C) SRP
-
Different payment classes implement the same
pay()method differently. What OOP concept is this?- A) Encapsulation
- B) Polymorphism
- C) Normalization
-
A child class requires stricter input conditions than its parent method. Which principle may be violated?
- A) SRP
- B) OCP
- C) LSP
๐ Click to reveal answers
10. ๐ Final Revision Notes
- ๐ ข Single Responsibility Principle: one class = one clear responsibility = one main reason to change
- ๐ Open/Closed Principle: open for extension, closed for unnecessary modification
- ๐ Liskov Substitution Principle: child objects must be safely usable wherever their parent objects are expected
๐ก Don't just memorize the definitions. Whenever you write a FastAPI service, repository, model or authentication module, ask yourself:
- Is this class doing too many jobs?
- Can I add a new feature without rewriting existing logic?
- If I inherit from another class, will my child class preserve its expected behavior?
That's how you start thinking like a software engineer, not just someone who writes code. ๐ง
If this post helped you, drop a โค๏ธ or ๐ฆ and share it with a friend learning software design. Happy coding! ๐ฉโ๐ปโจ
Top comments (0)