SOLID Principles Explained With Practical Examples
Five design principles that keep your code easy to change, test, and extend
SOLID is a set of five object-oriented design principles that help us write software that is easier to maintain, extend, test, and modify.
The five principles are:
- S: Single Responsibility Principle (SRP)
- O: Open/Closed Principle (OCP)
- L: Liskov Substitution Principle (LSP)
- I: Interface Segregation Principle (ISP)
- D: Dependency Inversion Principle (DIP)
Here is the most important thing to understand before we start:
The goal of SOLID is not to create more classes or make every piece of code complicated. The goal is to organize code so that a change in one part of the application does not unnecessarily affect other parts.
All examples below are in Python, but the ideas apply to any object-oriented language. Let’s go through each principle one by one.
1. Single Responsibility Principle (SRP)
Definition: A class should have one reason to change.
This does not mean a class can have only one method. It means the methods and responsibilities of a class should belong together.
The problem
Imagine a class called UserManager that performs three tasks:
class UserManager:
def validate_user(self, user):
passdef save_to_database(self, user):
pass def send_email(self, user):
pass
This class validates users, saves them to a database, and sends emails. The problem is that these responsibilities can change independently:
- If validation rules change, we modify
UserManager. - If the database implementation changes, we modify
UserManager. - If the email provider changes, we modify
UserManager.
The class therefore has multiple reasons to change.
The solution
Separate the responsibilities into dedicated classes:
class UserValidator:
def validate(self, user):
pass
class UserRepository:
def save(self, user):
pass
class EmailService:
def send(self, user):
pass
Now each class has one clear job:
UserValidatorvalidates user data.UserRepositorystores user data.EmailServicesends emails.
A UserManager can still exist to coordinate these classes. SRP does not prohibit one class from calling other classes. It discourages that class from owning all their separate responsibilities and implementations.
For example, a user registration workflow might validate a user, save the user, and then send a confirmation email. The workflow can coordinate these steps while delegating each task to the appropriate class:
class UserRegistration:
def __init__(self, validator, repository, email_service):
self.validator = validator
self.repository = repository
self.email_service = email_service def register(self, user):
self.validator.validate(user)
self.repository.save(user)
self.email_service.send(user)Another example: report generation
Suppose a ReportGenerator class does three things:
- Generates report data.
- Saves the report to a database.
- Sends the report by email.
This design may violate SRP because report generation, storage, and delivery have different reasons to change. A better design separates these concerns:
ReportGeneratorgenerates the report.ReportRepositorystores the report.EmailServicedelivers the report.
What we learned
SRP is about having one clear responsibility, not one method per class. A class can have multiple methods as long as they serve the same responsibility.
There is also a subtle point about errors. Database errors do not automatically mean that an email service is broken. The overall workflow’s behavior depends on how errors and execution order are handled, and that is a separate question from how responsibilities are divided.
2. Open/Closed Principle (OCP)
Definition: Software entities should be open for extension but closed for modification.
This principle has two parts, and understanding both is essential:
- Open for extension: We should be able to add new behavior or functionality.
- Closed for modification: We should avoid repeatedly changing existing, stable code whenever new functionality is added.
It does not mean we must never modify existing code. It means we should design for common extensions so existing code does not need unnecessary changes.
The problem: payment processing
Imagine a payment processor that supports different payment methods:
class PaymentProcessor:
def process(self, payment_type, amount):
if payment_type == "card":
print("Processing card payment")
elif payment_type == "wallet":
print("Processing wallet payment")Now suppose we want to support bank transfers. We have to modify PaymentProcessor and add another condition. If we keep adding payment methods, the processor becomes more and more dependent on specific implementations.
The solution: polymorphism and a common contract
We can define a common interface that all payment methods follow:
class PaymentMethod:
def pay(self, amount):
raise NotImplementedError
class CardPayment(PaymentMethod):
def pay(self, amount):
print("Processing card payment")
class WalletPayment(PaymentMethod):
def pay(self, amount):
print("Processing wallet payment")
class BankTransfer(PaymentMethod):
def pay(self, amount):
print("Processing bank transfer")
The processor then uses the common behavior:
class PaymentProcessor:
def __init__(self, payment_method):
self.payment_method = payment_method def process(self, amount):
self.payment_method.pay(amount)Usage:
processor = PaymentProcessor(CardPayment())
processor.process(100)processor = PaymentProcessor(BankTransfer())
processor.process(100)To add another payment method, we create another implementation of PaymentMethod. The existing processor remains unchanged.
Understanding the two halves
Imagine we add CryptoPayment:
- Open for extension: We can introduce
CryptoPaymentas another implementation ofPaymentMethod. - Closed for modification: We do not need to change
PaymentProcessor, because it already calls the commonpay()method.
One important detail: the interface alone does not guarantee OCP. The processor must also use the abstraction without adding new conditions for each implementation.
Another example: discount processing
Suppose an application supports discounts for regular customers, premium customers, and seasonal promotions. A processor with conditions for each discount type must be modified whenever a new discount is introduced.
Instead, define a common contract:
class Discount:
def calculate(self, price):
raise NotImplementedError
class RegularDiscount(Discount):
def calculate(self, price):
return price * 0.95
class PremiumDiscount(Discount):
def calculate(self, price):
return price * 0.80
class DiscountProcessor:
def __init__(self, discount):
self.discount = discount def apply(self, price):
return self.discount.calculate(price)
A new discount implementation, such as a seasonal one, can be added without changing DiscountProcessor. Notice that all discounts use the same method name, but each implementation can have its own calculation.
Where do factories fit in?
A factory is a design pattern that creates or selects objects. For example, a factory might receive "card" and return a CardPayment object.
A factory can help organize object creation, but it is not itself a SOLID principle, and it is not required to achieve OCP.
There is a catch. A factory containing a large if/elif block may still need modification for each new type. A registry-based factory can avoid changing the factory's creation logic when new types are registered:
class PaymentFactory:
_registry = {}@classmethod
def register(cls, name, method_class):
cls._registry[name] = method_class @classmethod
def create(cls, name):
return cls._registry[name]()
PaymentFactory.register("card", CardPayment)
PaymentFactory.register("wallet", WalletPayment)
PaymentFactory.register("bank", BankTransfer)method = PaymentFactory.create("card")
Adding CryptoPayment now means one new register call. The factory itself stays untouched.
What we learned
OCP is not simply about reducing duplicate code or adding more classes. The key is to design stable code around extension points so new implementations can be added without repeatedly changing existing logic.
3. Liskov Substitution Principle (LSP)
Definition: Objects of a child class should be usable wherever objects of their parent class are expected, without breaking the program’s expected behavior.
The most important word here is substitution.
What does substitution mean?
Suppose a function expects a Bird object:
class Bird:
def move(self):
print("Bird moves")
class Penguin(Bird):
def move(self):
print("Penguin swims")
def make_bird_move(bird: Bird):
bird.move()
We can call:
make_bird_move(Bird())
make_bird_move(Penguin())The function can accept either object. The penguin replaces the general bird object while preserving the expected behavior that the bird can move. This is substitution.
LSP asks whether that replacement remains behaviorally correct. A child can inherit from a parent and still violate LSP if its behavior breaks the parent’s contract.
Example 1: the flying bird problem
Consider this design:
class FlyingBird:
def fly(self):
print("Flying")
class Sparrow(FlyingBird):
pass
class Penguin(FlyingBird):
def fly(self):
raise NotImplementedError("Penguins cannot fly")
A function that expects a FlyingBird might call fly(). It works for Sparrow, but fails for Penguin. The parent class promises flying, yet the child cannot fulfill that promise.
A better design separates general birds from flying birds:
class Bird:
pass
class FlyingBird(Bird):
def fly(self):
print("Flying")
class Sparrow(FlyingBird):
pass
class Eagle(FlyingBird):
pass
class Penguin(Bird):
def swim(self):
print("Swimming")
Now Penguin is a Bird, but it is not incorrectly classified as a FlyingBird.
Example 2: payment methods and stricter conditions
Consider the following code:
class PaymentMethod:
def pay(self, amount):
if amount <= 0:
raise ValueError("Amount must be positive")
print("Payment processed")
class CreditCardPayment(PaymentMethod):
def pay(self, amount):
if amount < 100:
raise ValueError("Minimum payment is 100")
print("Credit card payment processed")The parent accepts any positive amount. The child requires at least 100. Suppose a function works with PaymentMethod and processes an amount of 50. If we substitute CreditCardPayment, the function fails.
This violates LSP.
Preconditions, postconditions, and invariants
To understand LSP deeply, remember three behavioral rules:
- Preconditions: Conditions that must be satisfied before a method runs. A child must not impose stricter preconditions than its parent.
- Postconditions: Guarantees about the result of a method. A child must not weaken the parent’s guarantees.
- Invariants: Conditions that must remain true throughout the object’s valid use. A child must preserve the relevant invariants.
For example, suppose a parent promises that calculate_area() returns a non-negative number. If a child returns -25 for an object with a valid size, it violates the parent's guarantee.
Get Sujal Neupane’s stories in your inbox
Join Medium for free to get updates from this writer.
Or suppose the parent accepts valid areas from 0 to 10, but the child rejects all values below 10. That stricter restriction may also violate LSP when it breaks the parent’s contract.
Does the child need identical behavior?
No. The child can implement behavior differently, provided it preserves the parent’s contract.
For example, if Bird.move() promises only that a bird moves, a penguin swimming can satisfy the contract. But if the parent specifically promises flight, swimming is not a valid replacement.
What we learned
Inheritance describes a relationship between classes. Substitution is about using one object in place of another. LSP ensures that this replacement does not break expected behavior.
A child class must honor the parent’s behavioral contract, not necessarily copy its implementation.
4. Interface Segregation Principle (ISP)
Definition: Clients should not be forced to depend on methods they do not use.
An interface defines the operations that an implementing class is expected to provide. ISP encourages small, focused interfaces rather than large interfaces that force every class to implement unrelated behaviors.
Example 1: a machine interface
Suppose we define:
class Machine:
def print_document(self):
passdef scan_document(self):
pass def fax_document(self):
pass
Now imagine a simple printer that can print but cannot scan or fax. If it implements Machine, it must still provide methods for unsupported operations. Those methods might be empty, raise errors, or behave in misleading ways.
The problem is that the interface combines capabilities that do not belong to every machine.
The solution: split the interfaces
class Printer:
def print_document(self):
pass
class Scanner:
def scan_document(self):
pass
class Fax:
def fax_document(self):
pass
Now a simple printer implements only printing. A scanner implements scanning. A multifunction machine can support all three capabilities:
class MultiFunctionMachine(Printer, Scanner, Fax):
def print_document(self):
print("Printing")def scan_document(self):
print("Scanning") def fax_document(self):
print("Faxing")
The design does not force a class to depend on functionality it does not need.
Example 2: human and robot workers
Suppose we have this interface:
class Worker:
def work(self):
pass def eat(self):
passA human worker can work and eat, but a robot can work without eating. If RobotWorker must implement eat(), the interface imposes an unnecessary requirement.
A better design is:
class Worker:
def work(self):
pass
class HumanWorker(Worker):
def eat(self):
pass
class RobotWorker(Worker):
pass
Both classes share the work() capability, while only the human worker has the additional eat() method.
If multiple classes share the ability to eat, we could also define a separate Eater interface.
What we learned
ISP does not mean creating as many interfaces as possible. It means creating interfaces around cohesive capabilities so that classes depend only on what they need.
A class should not be forced to implement or depend on operations that are irrelevant to its role.
5. Dependency Inversion Principle (DIP)
Definition: High-level modules should not depend directly on low-level modules. Both should depend on abstractions.
This principle is closely related to Dependency Injection, but they are not the same thing. We will get to that distinction shortly, because it was one of the most important lessons for me.
Understanding high-level and low-level modules
Consider a notification application:
Notificationcontains the business logic for deciding when to send a message. It is the high-level module.EmailServiceperforms the technical task of sending an email. It is the low-level module.
If Notification directly creates an EmailService, the business logic becomes tightly coupled to a specific implementation.
The problem
class EmailService:
def send_email(self, message):
print("Sending email:", message)
class Notification:
def __init__(self):
self.email_service = EmailService() def notify(self, message):
self.email_service.send_email(message)
The notification class creates its own email service and calls its email-specific method. If we want to switch to SMS, we may need to modify Notification.
The solution: depend on an abstraction
Define a common contract:
class MessageService:
def send(self, message):
raise NotImplementedErrorThen implement it for different services:
class EmailService(MessageService):
def send(self, message):
print("Sending email:", message)
class SMSService(MessageService):
def send(self, message):
print("Sending SMS:", message)Finally, make Notification use the abstraction:
class Notification:
def __init__(self, service: MessageService):
self.service = service def notify(self, message):
self.service.send(message)Usage:
notification = Notification(EmailService())
notification.notify("Hello")notification = Notification(SMSService())
notification.notify("Hello")Notification does not need to know whether the message is being sent by email or SMS. It relies on the shared send() contract.
In this example, MessageService is a conceptual base-class contract. In Python, an abstract base class (abc.ABC) or a Protocol can also express this contract.
Dependency Injection versus Dependency Inversion
This was an important distinction in our discussion.
Dependency Injection (DI) is a technique for supplying a dependency from outside a class. For example:
notification = Notification(EmailService())The service object is created elsewhere and passed into Notification through its constructor.
Dependency Inversion Principle (DIP) is a design principle about what modules should depend on. Instead of depending directly on EmailService, Notification should depend on an abstraction such as MessageService.
Here is the quick comparison:
- Dependency Injection is a technique. It supplies dependencies from outside. It answers the question: how does the class receive its dependency?
- Dependency Inversion is a design principle. It organizes dependencies around abstractions. It answers the question: what should the class depend on?
DI can be used without fully following DIP. For example, we might inject an EmailService object into Notification but still check the concrete type:
if isinstance(self.service, EmailService):
self.service.send_email(message)
elif isinstance(self.service, SMSService):
self.service.send_sms(message)Although injection is used, Notification still depends on concrete service types. Adding a new service could require changing the existing logic. Using a common send(message) contract avoids these checks.
Another example: report storage
Suppose ReportGenerator creates its own database repository:
class ReportGenerator:
def __init__(self):
self.repository = MySQLRepository()This tightly couples report generation to MySQL. A better design injects a repository that follows a common storage contract:
class ReportGenerator:
def __init__(self, repository):
self.repository = repository def generate(self, data):
report = f"Report: {data}"
self.repository.save(report)
return reportThe generator calls save() without needing to know which database is used. The design is strongest when the repository follows a clearly defined abstraction such as ReportStorage.
What we learned
Dependency injection alone does not guarantee DIP. The key is the abstraction that the high-level module depends on, and how it uses that dependency.
DIP reduces tight coupling by making high-level business logic depend on contracts rather than specific technical implementations.
6. How the SOLID Principles Work Together
Real code rarely breaks just one principle. Consider a ReportGenerator class that generates reports, saves them to a database, emails them, and selects PDF or CSV formats through if/else conditions.
Several principles apply at once:
- SRP: One class owns generation, storage, and delivery. The fix is to separate the responsibilities.
- OCP: New formats require modifying existing conditional logic. The fix is to add format-specific implementations behind a common contract.
- LSP: A format or storage subclass breaks its parent’s expected behavior. The fix is to preserve the parent contract.
- ISP: A class must implement unrelated operations. The fix is to split interfaces into focused capabilities.
- DIP: High-level logic creates and depends directly on a database implementation. The fix is to depend on a storage abstraction and inject the implementation.
The principles are related, but each addresses a different design problem. For example, injecting a repository can help with DIP. If the repository is an abstraction and the generator uses it without concrete-type checks, the design can also support OCP. But injection alone does not automatically satisfy either principle.
Likewise, splitting a class into smaller classes may help SRP, but it does not guarantee that the new classes follow LSP, ISP, or DIP.
A combined redesign
Here is how a cleaned-up ReportGenerator could look when the principles work together:
class ReportFormat:
def render(self, data):
raise NotImplementedError
class PdfFormat(ReportFormat):
def render(self, data):
return f"PDF report: {data}"
class CsvFormat(ReportFormat):
def render(self, data):
return f"CSV report: {data}"
class ReportStorage:
def save(self, report):
raise NotImplementedError
class MySQLStorage(ReportStorage):
def save(self, report):
print("Saving to MySQL:", report)
class MessageService:
def send(self, message):
raise NotImplementedError
class EmailService(MessageService):
def send(self, message):
print("Emailing:", message)
class ReportGenerator:
def __init__(self, report_format, storage, delivery):
self.report_format = report_format
self.storage = storage
self.delivery = delivery def generate(self, data):
report = self.report_format.render(data)
self.storage.save(report)
self.delivery.send(report)
return report
generator = ReportGenerator(PdfFormat(), MySQLStorage(), EmailService())
generator.generate("Q3 sales")
Notice how each principle shows up:
- Each class has one job (SRP).
- A new format such as
ExcelFormatneeds no changes toReportGenerator(OCP). - Any
ReportFormatcan replace another without breaking the generator (LSP). - Each contract has one focused method (ISP).
ReportGeneratordepends on abstractions and receives them from outside (DIP).
7. A Word of Caution: Do Not Over-Engineer
SOLID is a tool, not a rulebook to follow blindly. A few things I want to remember:
- If a class is small and unlikely to change, splitting it into five classes adds noise, not value.
- Do not create an interface for something that will only ever have one implementation, unless you have a real reason such as testing or a known upcoming change.
- Do not apply a principle just to say you applied it. Each change to the design should solve a real problem.
A good rule of thumb: when you feel pain, such as a change rippling across many files, a bug caused by an unexpected subclass, or a class that is hard to test, look for the SOLID principle that explains the pain. Then refactor.
8. Quick Revision: The Five Principles
S: Single Responsibility Principle A class should have one reason to change. Ask: Does this class own multiple independent responsibilities?
O: Open/Closed Principle Software should be open for extension but closed for unnecessary modification. Ask: Can I add a new behavior without changing stable existing logic?
L: Liskov Substitution Principle A child object should replace its parent without breaking expected behavior. Ask: Does the child honor the parent’s behavioral contract?
I: Interface Segregation Principle Classes should not be forced to depend on methods they do not need. Ask: Is this interface forcing unrelated or unsupported operations on a class?
D: Dependency Inversion Principle High-level and low-level modules should depend on abstractions. Ask: Does the business logic depend on a concrete implementation instead of a contract?
Conclusion
The main lesson from SOLID is that good object-oriented design is not just about writing classes and using inheritance. It is about controlling how responsibilities are separated, how new behavior is added, how child classes honor their contracts, how interfaces are designed, and how dependencies are connected.
When reviewing code, start by identifying the actual problem rather than forcing every SOLID principle onto the design. A single design can violate multiple principles, but each violation should have a clear reason.
If you can explain what is wrong, why it matters, and how your redesign fixes it, you understand SOLID beyond memorizing definitions.
If this helped you, feel free to clap, share it with a teammate, or leave a comment with your own favorite SOLID example.









