SOLID: The 5 Principles of Object Oriented Design

Anastasios Antoniadis

In software development, writing maintainable, scalable, and robust code is a crucial goal. The SOLID principles, introduced by Robert C. Martin (Uncle Bob), provide a foundational framework for achieving these goals. These principles help developers create software that is easier to manage, understand, and extend.

This guide provides an in-depth exploration of the SOLID principles, explaining their importance, benefits, real-world applications, and best practices. By the end of this guide, you’ll have a solid understanding of how to implement these principles in your own software projects.

Understanding SOLID Principles

SOLID is an acronym that represents five core design principles aimed at making software more flexible and maintainable:

  • Single Responsibility Principle (SRP)
  • Open/Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Segregation Principle (ISP)
  • Dependency Inversion Principle (DIP)

Each of these principles addresses a specific challenge in object-oriented programming and software design, ensuring that code remains modular, reusable, and easy to understand.

Single Responsibility Principle (SRP)

Definition

The Single Responsibility Principle (SRP) is one of the five SOLID principles of object-oriented design. It states:

“A class should have only one reason to change.”

What does this mean?

Each class, module, or function should focus on a single responsibility—one well-defined piece of functionality. If a class has multiple responsibilities, it becomes harder to maintain and modify because changes to one responsibility may unintentionally affect others.

Benefits

  • Improves maintainability
  • Enhances readability
  • Simplifies debugging and testing
  • Reduces coupling between components

Real-World Examples

  1. Bad Example: A ReportGenerator class that handles both generating reports and sending emails.
  2. Good Example: Separating report generation into ReportGenerator and email functionality into EmailSender.

Example (Violating SRP)

Here’s a class that violates SRP because it handles both employee data and report generation:

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def calculate_pay(self):
        return self.salary * 0.9  # Deduct taxes, etc.

    def generate_report(self):
        return f"Employee: {self.name}, Salary: {self.salary}"

Why is this bad?

The class is responsible for both payroll calculation and report generation.

If report formatting needs to change, we have to modify this class, potentially affecting salary calculations.

Example (Applying SRP)

To follow SRP, we should separate concerns:

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

class Payroll:
    def calculate_pay(self, employee):
        return employee.salary * 0.9  # Deduct taxes, etc.

class ReportGenerator:
    def generate_report(self, employee):
        return f"Employee: {employee.name}, Salary: {employee.salary}"

Now:

  • Employee holds employee data.
  • Payroll handles salary calculations.
  • ReportGenerator manages report creation.

Each class has only one reason to change!

Another Code Example

class ReportGenerator:
    def generate_report(self):
        return "Report Data"

class EmailSender:
    def send_email(self, report_data):
        print(f"Sending email with data: {report_data}")

# Usage
report_generator = ReportGenerator()
email_sender = EmailSender()
report = report_generator.generate_report()
email_sender.send_email(report)

Best Practices

  • Follow the separation of concerns principle.
  • Use cohesion to ensure related functionalities remain together.
  • Refactor large classes into smaller, focused classes.

Open/Closed Principle (OCP)

Definition

The Open/Closed Principle (OCP) is the second of the SOLID principles and states:

“Software entities (classes, modules, functions) should be open for extension but closed for modification.”

What does this mean?

Closed for modification → Once a class is implemented and tested, it shouldn’t be changed every time a new feature is needed.

Open for extension → You should be able to add new functionality without modifying existing code.

Benefits

  • Prevents breaking existing code when adding new features.
  • Encourages modular design by allowing extensions instead of modifications.
  • Improves maintainability as changes are made by adding new code instead of modifying stable parts.

Real-World Examples

  1. Bad Example: A PaymentProcessor class that needs modification every time a new payment method is added.
  2. Good Example: Using an interface (PaymentMethod) and concrete implementations (CreditCardPayment, PayPalPayment).

Code Example

from abc import ABC, abstractmethod

class PaymentMethod(ABC):
    @abstractmethod
    def pay(self, amount):
        pass

class CreditCardPayment(PaymentMethod):
    def pay(self, amount):
        print(f"Paid {amount} using Credit Card")

class PayPalPayment(PaymentMethod):
    def pay(self, amount):
        print(f"Paid {amount} using PayPal")

# Usage
payment = PayPalPayment()
payment.pay(100)

Example (Violating OCP)

Imagine we have a class that calculates discounts based on customer types:

class DiscountCalculator:
    def calculate_discount(self, customer_type, price):
        if customer_type == "Regular":
            return price * 0.05  # 5% discount
        elif customer_type == "VIP":
            return price * 0.10  # 10% discount
        else:
            return 0  # No discount

🚨 Why is this bad?

If we need to add a new customer type, we must modify this class. This violates OCP because the class isn’t closed for modification.

Example (Applying OCP)

We can use polymorphism and inheritance to extend functionality without modifying the existing code:

from abc import ABC, abstractmethod

class DiscountStrategy(ABC):
    @abstractmethod
    def calculate(self, price):
        pass

class RegularDiscount(DiscountStrategy):
    def calculate(self, price):
        return price * 0.05  # 5% discount

class VIPDiscount(DiscountStrategy):
    def calculate(self, price):
        return price * 0.10  # 10% discount

class NoDiscount(DiscountStrategy):
    def calculate(self, price):
        return 0  # No discount

class DiscountCalculator:
    def __init__(self, discount_strategy: DiscountStrategy):
        self.discount_strategy = discount_strategy

    def calculate_discount(self, price):
        return self.discount_strategy.calculate(price)

Why is this better?

  • ✅ We don’t modify existing code to add new discount strategies.
  • ✅ We can introduce new discount types just by creating new classes.
  • ✅ The DiscountCalculator class is closed for modification but open for extension.

Best Practices

  • Use abstraction to define extension points.
  • Leverage polymorphism to support new behaviors without modifying existing code.
  • Apply the strategy pattern to allow dynamic selection of behavior.

Liskov Substitution Principle (LSP)

Definition

The Liskov Substitution Principle (LSP) is the third SOLID principle and states:

“Objects of a subclass should be replaceable with objects of a superclass without altering the correctness of the program.”

What does this mean?

Subclasses should not remove functionality that exists in the parent class.

A subclass should extend the behavior of its parent class, not break it.

You should be able to substitute a base class with any of its derived classes without unexpected behavior.

Benefits

  • Ensures inheritance is used correctly.
  • Prevents unexpected errors when substituting subclasses.
  • Helps in maintaining code correctness and reusability.

Real-World Examples

  1. Bad Example: A Bird class with a fly() method, where Penguin (a subclass) must override it improperly.
  2. Good Example: Using separate FlyingBird and NonFlyingBird classes.

Code Example

class Bird:
    def make_sound(self):
        print("Chirp!")

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

class Penguin(Bird):
    def swim(self):
        print("Swimming in water!")

Example (Violating LSP)

Let’s say we have a base class Bird, and we create a subclass Penguin.

class Bird:
    def fly(self):
        return "I can fly!"

class Penguin(Bird):
    def fly(self):
        raise Exception("Penguins can't fly!")

🚨 Why is this bad?

The Penguin class breaks the contract of the Bird class by overriding fly() in a way that removes functionality. Any code expecting a Bird instance to be able to fly will crash if given a Penguin. This violates LSP because Penguin is not a true substitute for Bird.

Example (Applying LSP)

Instead of forcing all birds to have a fly() method, we should separate flying and non-flying birds using an abstraction.

from abc import ABC, abstractmethod

class Bird(ABC):
    @abstractmethod
    def make_sound(self):
        pass

class FlyingBird(Bird):
    @abstractmethod
    def fly(self):
        pass

class Sparrow(FlyingBird):
    def make_sound(self):
        return "Chirp!"

    def fly(self):
        return "I can fly!"

class Penguin(Bird):
    def make_sound(self):
        return "Honk!"

Why is this better?

  • Sparrow inherits from FlyingBird, ensuring only birds that can fly have a fly() method.
  • Penguin inherits from Bird but does not have a fly() method, avoiding broken expectations.
  • LSP is satisfied because all subclasses follow the contracts of their parent classes.

Best Practices

  • Follow the behavioral consistency rule.
  • Avoid violating inheritance hierarchies.
  • Use composition over inheritance where necessary.

Interface Segregation Principle (ISP)

Definition

The Interface Segregation Principle (ISP) is the fourth SOLID principle and states:

“Clients should not be forced to depend on interfaces they do not use.”

What does this mean?

Avoid “fat” interfaces that contain methods unrelated to all implementing classes.

Instead of having large, general-purpose interfaces, split them into smaller, specific ones.

A class should not be forced to implement methods it does not need.

Benefits

  • Prevents unnecessary dependencies – Classes don’t need to implement irrelevant methods.
  • Improves flexibility and maintainability – Changes in one part of the interface don’t affect unrelated classes.
  • Encourages composition over bloated inheritance – Keeps code modular.

Real-World Examples

  1. Bad Example: A Worker interface with work() and eat() methods, forcing robots to implement eat().
  2. Good Example: Separate Workable and Eatable interfaces.

Code Example

from abc import ABC, abstractmethod

class Workable(ABC):
    @abstractmethod
    def work(self):
        pass

class Eatable(ABC):
    @abstractmethod
    def eat(self):
        pass

class Human(Workable, Eatable):
    def work(self):
        print("Working hard!")
    def eat(self):
        print("Eating lunch!")

class Robot(Workable):
    def work(self):
        print("Executing tasks!")

Example (Violating ISP)

Consider an interface for different types of machines:

class Machine:
    def print(self, document):
        pass

    def scan(self, document):
        pass

    def fax(self, document):
        pass

Now, we implement a basic printer:

class BasicPrinter(Machine):
    def print(self, document):
        print(f"Printing: {document}")

    def scan(self, document):
        raise NotImplementedError("BasicPrinter cannot scan!")

    def fax(self, document):
        raise NotImplementedError("BasicPrinter cannot fax!")

🚨 Why is this bad?

  • BasicPrinter is forced to implement scan() and fax() even though it doesn’t need them.
  • If the interface changes (e.g., adding a copy() method), all subclasses must be modified, even if they don’t support copying.

Example (Applying ISP)

To follow ISP, we split the large interface into smaller, more specific ones:

from abc import ABC, abstractmethod

class Printer(ABC):
    @abstractmethod
    def print(self, document):
        pass

class Scanner(ABC):
    @abstractmethod
    def scan(self, document):
        pass

class FaxMachine(ABC):
    @abstractmethod
    def fax(self, document):
        pass

Now, each class only implements what it actually needs:

class BasicPrinter(Printer):
    def print(self, document):
        print(f"Printing: {document}")

class MultiFunctionPrinter(Printer, Scanner, FaxMachine):
    def print(self, document):
        print(f"Printing: {document}")

    def scan(self, document):
        print(f"Scanning: {document}")

    def fax(self, document):
        print(f"Faxing: {document}")

Why is this better?

  • BasicPrinter only implements Printer (no unnecessary methods).
  • MultiFunctionPrinter implements all three interfaces because it actually supports printing, scanning, and faxing.
  • ✅ If we need a new feature (e.g., copying), we can create a separate Copier interface without affecting unrelated classes.

Best Practices

  • Keep interfaces small and focused.
  • Use multiple interfaces instead of a single, large interface.

Dependency Inversion Principle (DIP)

Definition

The Dependency Inversion Principle (DIP) is the fifth and final SOLID principle. It states:

“High-level modules should not depend on low-level modules. Both should depend on abstractions.”
“Abstractions should not depend on details. Details should depend on abstractions.”

What does this mean?

This makes code flexible, maintainable, and testable by allowing dependencies to be easily swapped.

High-level modules (core logic) should not be tightly coupled to low-level modules (specific implementations).

Instead, both should depend on interfaces or abstract classes (abstractions).

Benefits

  • Decouples modules, making changes in one part less disruptive.
  • Improves flexibility by allowing different implementations without modifying existing code.
  • Enables easier testing since dependencies can be replaced with mock implementations.

Code Example

class Logger:
    def log(self, message):
        raise NotImplementedError()

class FileLogger(Logger):
    def log(self, message):
        print(f"Logging to a file: {message}")

class Application:
    def __init__(self, logger: Logger):
        self.logger = logger
    
    def run(self):
        self.logger.log("Application is running")

# Usage
app = Application(FileLogger())
app.run()

Example (Violating DIP)

Here’s a NotificationService class that directly depends on EmailService:

class EmailService:
    def send_email(self, message):
        print(f"Sending email: {message}")

class NotificationService:
    def __init__(self):
        self.email_service = EmailService()  # Direct dependency on EmailService

    def send_notification(self, message):
        self.email_service.send_email(message)

🚨 Why is this bad?

  • NotificationService is tightly coupled to EmailService.
  • If we want to add SMS notifications, we must modify NotificationService, violating Open/Closed Principle (OCP) too.
  • Difficult to test because EmailService is hardcoded.

Example (Applying DIP)

To follow DIP, we introduce an abstraction (interface) for sending notifications:

from abc import ABC, abstractmethod

# Abstraction
class MessageSender(ABC):
    @abstractmethod
    def send(self, message):
        pass

# Low-level modules implementing the abstraction
class EmailService(MessageSender):
    def send(self, message):
        print(f"Sending email: {message}")

class SMSService(MessageSender):
    def send(self, message):
        print(f"Sending SMS: {message}")

# High-level module depending on abstraction
class NotificationService:
    def __init__(self, sender: MessageSender):
        self.sender = sender  # Depends on abstraction, not a specific class

    def send_notification(self, message):
        self.sender.send(message)

Now, we can easily switch the notification method:

email_notification = NotificationService(EmailService())
email_notification.send_notification("Hello via Email!")

sms_notification = NotificationService(SMSService())
sms_notification.send_notification("Hello via SMS!")

Why is this better?

  • NotificationService no longer depends on a specific class (Email, SMS, etc.).
  • ✅ We can add new message types (e.g., Push Notifications) without modifying NotificationService.
  • ✅ Code is flexible, testable, and reusable.

Best Practices

Rely on dependency injection.

Use inversion of control (IoC) containers.

Real-World Case Study: Applying SOLID Principles in a Ride-Sharing App

Scenario:

You are developing a ride-sharing app (like Uber or Lyft) with features such as user registration, ride booking, and payment processing. The initial implementation is simple, but as new features are added, the code becomes difficult to maintain. Applying SOLID principles helps make the system scalable and flexible.

Single Responsibility Principle (SRP) – Keep Responsibilities Separate

Bad Design (Violating SRP)

class RideService:
    def book_ride(self, user, location):
        # Logic for booking a ride
        pass

    def process_payment(self, user, amount):
        # Logic for handling payment
        pass

    def send_notification(self, user, message):
        # Logic for sending a notification
        pass

🚨 Problem: The RideService class does too much—it handles ride booking, payments, and notifications. Any change in one responsibility could affect others.

Good Design (Following SRP)

class RideBookingService:
    def book_ride(self, user, location):
        pass

class PaymentService:
    def process_payment(self, user, amount):
        pass

class NotificationService:
    def send_notification(self, user, message):
        pass

Now:
Each class has a single responsibility, making the code more maintainable.

Open/Closed Principle (OCP) – Extend, Don’t Modify

Bad Design (Violating OCP)

class PaymentService:
    def process_payment(self, user, amount, payment_type):
        if payment_type == "CreditCard":
            self.process_credit_card(user, amount)
        elif payment_type == "PayPal":
            self.process_paypal(user, amount)
        else:
            raise Exception("Payment type not supported")

🚨 Problem: Adding a new payment type (e.g., Apple Pay) requires modifying the existing class.

Good Design (Following OCP)

from abc import ABC, abstractmethod

class PaymentMethod(ABC):
    @abstractmethod
    def process(self, user, amount):
        pass

class CreditCardPayment(PaymentMethod):
    def process(self, user, amount):
        print(f"Processing credit card payment of {amount}")

class PayPalPayment(PaymentMethod):
    def process(self, user, amount):
        print(f"Processing PayPal payment of {amount}")

class PaymentService:
    def __init__(self, payment_method: PaymentMethod):
        self.payment_method = payment_method

    def process_payment(self, user, amount):
        self.payment_method.process(user, amount)

Now:
✅ We can add new payment types (like Apple Pay) without modifying PaymentService, following OCP.

Liskov Substitution Principle (LSP) – Ensure Substitutability

Bad Design (Violating LSP)

class Vehicle:
    def start_engine(self):
        pass

class ElectricScooter(Vehicle):
    def start_engine(self):
        raise Exception("Electric scooters don’t have engines!")

🚨 Problem: ElectricScooter inherits from Vehicle but breaks the contract because scooters don’t have an engine.

Good Design (Following LSP)

from abc import ABC, abstractmethod

class Vehicle(ABC):
    @abstractmethod
    def move(self):
        pass

class Car(Vehicle):
    def move(self):
        print("Car is driving")

class ElectricScooter(Vehicle):
    def move(self):
        print("Scooter is moving")

Now:
ElectricScooter and Car both follow the same contract (move() method), ensuring substitutability.

Interface Segregation Principle (ISP) – Avoid Unnecessary Dependencies

Bad Design (Violating ISP)

class DriverActions:
    def drive(self):
        pass

    def accept_payment(self):
        pass

class Driver(DriverActions):
    def drive(self):
        print("Driving")

    def accept_payment(self):
        print("Accepting payment")

class Passenger(DriverActions):
    def drive(self):
        raise Exception("Passengers don’t drive!")

    def accept_payment(self):
        print("Paying for the ride")

🚨 Problem: Passengers don’t drive, but they’re forced to implement drive() because of the DriverActions interface.

Good Design (Following ISP)

from abc import ABC, abstractmethod

class RideActions(ABC):
    @abstractmethod
    def book_ride(self):
        pass

class PaymentActions(ABC):
    @abstractmethod
    def make_payment(self):
        pass

class Passenger(RideActions, PaymentActions):
    def book_ride(self):
        print("Booking a ride")

    def make_payment(self):
        print("Paying for the ride")

class Driver(RideActions):
    def book_ride(self):
        print("Accepting a ride request")

Now:
✅ Each class only implements methods it actually needs, following ISP.

Dependency Inversion Principle (DIP) – Depend on Abstractions

Bad Design (Violating DIP)

class UberMapService:
    def get_route(self, start, end):
        return f"Route from {start} to {end}"

class RideService:
    def __init__(self):
        self.map_service = UberMapService()  # Direct dependency

    def find_route(self, start, end):
        return self.map_service.get_route(start, end)

🚨 Problem: RideService is tightly coupled to UberMapService. If we switch to Google Maps, we must modify RideService.

Good Design (Following DIP)

from abc import ABC, abstractmethod

class MapService(ABC):
    @abstractmethod
    def get_route(self, start, end):
        pass

class UberMapService(MapService):
    def get_route(self, start, end):
        return f"Route from {start} to {end} (via Uber Maps)"

class GoogleMapService(MapService):
    def get_route(self, start, end):
        return f"Route from {start} to {end} (via Google Maps)"

class RideService:
    def __init__(self, map_service: MapService):
        self.map_service = map_service  # Depends on abstraction

    def find_route(self, start, end):
        return self.map_service.get_route(start, end)

Now:
✅ We can easily swap map services (Uber, Google, etc.) without modifying RideService.

Final Takeaways

SRP → Separate ride booking, payments, and notifications.
OCP → Add new payment methods without modifying existing code.
LSP → Ensure all vehicles follow a common movement contract.
ISP → Split interfaces so classes only implement what they need.
DIP → Use abstractions for map services, not concrete implementations.

By applying SOLID principles, the ride-sharing app remains scalable, maintainable, and flexible, making it easier to introduce new features without breaking existing functionality.

Conclusion

By following SOLID principles, developers can build scalable, maintainable, and efficient software systems. Mastering these concepts leads to cleaner code, better collaboration, and long-term software success.

FAQ

1. What are the SOLID principles?

SOLID is an acronym for five key principles of object-oriented design:

  • 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)

These principles help create maintainable, scalable, and flexible software.

2. Why are the SOLID principles important?

SOLID principles improve software design by:
✅ Making code easier to read and maintain
✅ Reducing dependencies and tight coupling
✅ Enhancing scalability and flexibility
✅ Preventing unintended side effects when modifying code

3. How does the Single Responsibility Principle (SRP) work?

SRP states that a class should have only one reason to change—it should handle a single responsibility. This improves maintainability by ensuring that changes in one area of the system don’t affect unrelated functionality.

4. What is the Open/Closed Principle (OCP)?

The Open/Closed Principle means that a class should be open for extension but closed for modification. Instead of changing existing code, developers should extend functionality using inheritance or composition.

5. How does the Liskov Substitution Principle (LSP) help?

LSP ensures that subclasses can replace their parent class without altering the program’s correctness. If a subclass modifies behavior in a way that breaks expectations, it violates LSP and can cause unexpected bugs.

6. What is the Interface Segregation Principle (ISP)?

ISP states that a class should not be forced to implement methods it doesn’t use. Instead of large, general-purpose interfaces, software should use smaller, more specific interfaces.

7. How does the Dependency Inversion Principle (DIP) improve code?

DIP states that high-level modules should depend on abstractions, not concrete implementations. This reduces coupling and makes it easier to swap dependencies, improving flexibility and testability.

8. How can I apply SOLID principles in real projects?

To apply SOLID in real-world projects:

  • Keep classes focused on a single responsibility (SRP).
  • Use interfaces and inheritance to extend, not modify, functionality (OCP).
  • Ensure subclasses don’t break parent class behavior (LSP).
  • Design modular interfaces that don’t force unnecessary method implementation (ISP).
  • Use dependency injection and abstraction layers instead of directly depending on implementations (DIP).

9. Are SOLID principles only for object-oriented programming?

While SOLID principles were designed for OOP, the ideas of separation of concerns, modular design, and abstraction can be applied to functional programming and other architectural patterns as well.

10. What are some common mistakes when implementing SOLID principles?

  • Overcomplicating designs with excessive abstractions
  • Misusing inheritance instead of favoring composition
  • Creating too many small interfaces without a clear need
  • Applying principles blindly without considering project context
Anastasios Antoniadis
Find me on
Latest posts by Anastasios Antoniadis (see all)

Leave a Comment