Software Craftsmanship

SOLID Principles Explained: Real-World Examples Every Developer Can Understand

SOLID principles aren't just academic concepts—they're practical guidelines that solve real problems every developer faces. Learn each principle through relatable examples and see how to apply them effectively.

Ruchit Suthar
Ruchit Suthar
September 24, 202511 min read
SOLID Principles Explained: Real-World Examples Every Developer Can Understand

SOLID Principles Explained: Real-World Examples Every Developer Can Understand

Imagine you're managing a busy restaurant. You have cooks, waiters, cashiers, and managers, each with specific responsibilities. What happens when your head cook also tries to handle payments, manage inventory, greet customers, and clean tables? Chaos. The restaurant becomes inefficient, prone to errors, and impossible to scale.

Software development faces the same challenges. As applications grow more complex, code can become tangled, brittle, and nightmarish to maintain. This is where SOLID principles come to the rescue—a set of five design principles that help create software that's as organized and efficient as a well-run restaurant.

SOLID principles aren't just academic concepts or interview buzzwords. They're practical guidelines that solve real problems every developer faces: code that breaks when you change one small thing, classes that are impossible to test, and systems that become harder to modify over time. Understanding and applying these principles can transform how you write code, making it more maintainable, testable, and scalable.

In this comprehensive guide, we'll explore each SOLID principle through relatable real-world examples, examine when to apply them (and when not to), and see practical code implementations that demonstrate their power. Whether you're a junior developer learning design patterns or a senior engineer looking to refresh your knowledge, this guide will help you write better code that stands the test of time.

What Are SOLID Principles?

SOLID is an acronym representing five fundamental 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 were popularized by Robert C. Martin (Uncle Bob) and have become cornerstones of clean, maintainable software design. Let's explore each principle with examples that make them easy to understand and apply.

Single Responsibility Principle (SRP)

The Purpose

The Single Responsibility Principle states that a class should have only one reason to change. In other words, each class should have a single responsibility or job within the system.

Think of it like employees in a company. A software engineer shouldn't also be responsible for HR duties, marketing campaigns, and office maintenance. Each role has distinct responsibilities, making the organization more efficient and manageable.

Real-World Analogy

Consider a Swiss Army knife versus a professional chef's kitchen. While a Swiss Army knife can cut, open bottles, and file nails, a professional chef uses specialized tools: a chef's knife for cutting, a bottle opener for bottles, and a steel for sharpening. Each tool excels at its specific job, making the chef more efficient and the tools more maintainable.

When to Apply

  • Complex classes: When a class is handling multiple unrelated tasks
  • Frequent changes: When changes to one feature require modifying multiple classes
  • Testing difficulties: When unit testing becomes complicated due to multiple responsibilities
  • Team collaboration: When different developers need to work on different aspects of the same class

When to Avoid

  • Simple applications: In very small projects where separation might create unnecessary complexity
  • Tightly coupled responsibilities: When responsibilities are so intertwined that separation would be artificial
  • Performance-critical code: When the overhead of additional classes impacts performance significantly
  • Over-engineering risks: When you're creating classes for responsibilities that may never change independently

Code Example

❌ Violating SRP:

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email
    
    def save_to_database(self):
        # Database logic
        print(f"Saving {self.name} to database")
        # Connect to database, execute SQL, handle errors
    
    def send_welcome_email(self):
        # Email logic
        print(f"Sending welcome email to {self.email}")
        # Connect to email service, format email, send
    
    def validate_email(self):
        # Validation logic
        return "@" in self.email and "." in self.email
    
    def generate_report(self):
        # Reporting logic
        return f"User Report: {self.name} - {self.email}"

This User class violates SRP because it has multiple reasons to change:

  • If database schema changes, we modify this class
  • If email template changes, we modify this class
  • If validation rules change, we modify this class
  • If report format changes, we modify this class

✅ Following SRP:

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

class UserRepository:
    def save(self, user):
        print(f"Saving {user.name} to database")
        # Database-specific logic only

class EmailService:
    def send_welcome_email(self, user):
        print(f"Sending welcome email to {user.email}")
        # Email-specific logic only

class UserValidator:
    def validate_email(self, email):
        return "@" in email and "." in email

class UserReportGenerator:
    def generate_report(self, user):
        return f"User Report: {user.name} - {user.email}"

# Usage
user = User("John Doe", "john@example.com")
repository = UserRepository()
email_service = EmailService()
validator = UserValidator()

if validator.validate_email(user.email):
    repository.save(user)
    email_service.send_welcome_email(user)

Now each class has a single responsibility and reason to change, making the system more maintainable and testable.

Open/Closed Principle (OCP)

The Purpose

The Open/Closed Principle states that software entities should be open for extension but closed for modification. You should be able to add new functionality without changing existing code.

This is like designing a modular furniture system. You can add new pieces (extension) without modifying existing furniture (modification). The system grows while keeping existing components stable.

Real-World Analogy

Think about smartphone apps and their plugin systems. A photo editing app can support new filters without modifying its core code. New filters are added as plugins that extend functionality while the main application remains unchanged and stable.

When to Apply

  • Frequent feature additions: When you regularly need to add new variations of existing functionality
  • Multiple implementations: When you have different ways of handling the same operation
  • Third-party integrations: When you need to support various external services or APIs
  • Stable core with varying behaviors: When you have a stable core that needs different behaviors in different contexts

When to Avoid

  • Simple, stable requirements: When requirements are unlikely to change or expand
  • Performance-critical paths: When abstraction layers introduce unacceptable performance overhead
  • Over-abstraction risks: When you're creating abstractions for variations that may never materialize
  • Small, focused applications: Where the complexity of extensibility outweighs benefits

Code Example

❌ Violating OCP:

class PaymentProcessor:
    def process_payment(self, amount, payment_type):
        if payment_type == "credit_card":
            print(f"Processing ${amount} via Credit Card")
            # Credit card specific logic
            return self.charge_credit_card(amount)
        elif payment_type == "paypal":
            print(f"Processing ${amount} via PayPal")
            # PayPal specific logic
            return self.charge_paypal(amount)
        elif payment_type == "crypto":  # New requirement - requires modification
            print(f"Processing ${amount} via Cryptocurrency")
            return self.charge_crypto(amount)
        else:
            raise ValueError("Unsupported payment type")
    
    def charge_credit_card(self, amount):
        return f"Charged ${amount} to credit card"
    
    def charge_paypal(self, amount):
        return f"Charged ${amount} via PayPal"
    
    def charge_crypto(self, amount):  # Had to add this method
        return f"Charged ${amount} via cryptocurrency"

Every time we add a new payment method, we must modify the PaymentProcessor class, violating OCP.

✅ Following OCP:

from abc import ABC, abstractmethod

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

class CreditCardPayment(PaymentMethod):
    def process_payment(self, amount):
        print(f"Processing ${amount} via Credit Card")
        return f"Charged ${amount} to credit card"

class PayPalPayment(PaymentMethod):
    def process_payment(self, amount):
        print(f"Processing ${amount} via PayPal")
        return f"Charged ${amount} via PayPal"

class CryptocurrencyPayment(PaymentMethod):  # New payment method - no modification needed
    def process_payment(self, amount):
        print(f"Processing ${amount} via Cryptocurrency")
        return f"Charged ${amount} via cryptocurrency"

class PaymentProcessor:
    def __init__(self):
        self.payment_methods = {}
    
    def register_payment_method(self, name, payment_method):
        self.payment_methods[name] = payment_method
    
    def process_payment(self, amount, payment_type):
        if payment_type in self.payment_methods:
            return self.payment_methods[payment_type].process_payment(amount)
        else:
            raise ValueError("Unsupported payment type")

# Usage
processor = PaymentProcessor()
processor.register_payment_method("credit_card", CreditCardPayment())
processor.register_payment_method("paypal", PayPalPayment())
processor.register_payment_method("crypto", CryptocurrencyPayment())  # Extended without modification

processor.process_payment(100, "crypto")

Now we can add new payment methods without modifying existing code, making the system open for extension but closed for modification.

Liskov Substitution Principle (LSP)

The Purpose

The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of a subclass without breaking the application. Subtypes must be substitutable for their base types.

This is like having different types of vehicles that all implement basic driving operations. Whether you're driving a car, truck, or motorcycle, the basic interface (accelerate, brake, steer) should work the same way.

Real-World Analogy

Consider electrical outlets in your home. Any device designed for a standard outlet should work in any outlet of that type. You shouldn't need to modify your lamp when you plug it into a different room's outlet. The outlets are substitutable while maintaining the expected behavior.

When to Apply

  • Inheritance hierarchies: When creating subclasses that should be interchangeable with their parent classes
  • Polymorphism: When you need different implementations of the same interface to behave consistently
  • Framework development: When building libraries or frameworks that others will extend
  • Plugin architectures: When creating systems where components can be swapped out

When to Avoid

  • Fundamentally different behaviors: When subclasses need to behave completely differently from their parents
  • Performance optimizations: When subclasses need to break contracts for performance reasons (with careful documentation)
  • Legacy system integration: When integrating with systems that don't follow LSP (temporary violation may be necessary)

Code Example

❌ Violating LSP:

class Bird:
    def fly(self):
        return "Flying high in the sky"
    
    def make_sound(self):
        return "Generic bird sound"

class Sparrow(Bird):
    def make_sound(self):
        return "Chirp chirp"

class Penguin(Bird):  # This violates LSP
    def fly(self):
        raise Exception("Penguins can't fly!")  # Breaks the contract
    
    def make_sound(self):
        return "Squawk"

# Client code expecting all birds to fly
def make_bird_fly(bird: Bird):
    return bird.fly()  # This will crash with Penguin

# Usage
sparrow = Sparrow()
penguin = Penguin()

print(make_bird_fly(sparrow))  # Works fine
print(make_bird_fly(penguin))  # Crashes! LSP violation

The Penguin class violates LSP because it can't be substituted for its parent Bird class without breaking the application.

✅ Following LSP:

from abc import ABC, abstractmethod

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

class FlyingBird(Bird):
    def fly(self):
        return "Flying high in the sky"
    
    def move(self):
        return self.fly()

class FlightlessBird(Bird):
    def walk(self):
        return "Walking on the ground"
    
    def move(self):
        return self.walk()

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

class Penguin(FlightlessBird):
    def make_sound(self):
        return "Squawk"
    
    def swim(self):
        return "Swimming in the water"

# Client code works with any bird
def bird_activity(bird: Bird):
    print(f"Sound: {bird.make_sound()}")
    print(f"Movement: {bird.move()}")

# Usage
sparrow = Sparrow()
penguin = Penguin()

bird_activity(sparrow)  # Works
bird_activity(penguin)  # Also works - LSP satisfied

Now both Sparrow and Penguin can be substituted for their base types without breaking the application.

Interface Segregation Principle (ISP)

The Purpose

The Interface Segregation Principle states that clients should not be forced to depend on interfaces they don't use. It's better to have many small, specific interfaces than one large, general-purpose interface.

This is like having separate remote controls for different devices. You don't want your TV remote cluttered with dishwasher buttons, or your car stereo controls mixed with air conditioning controls.

Real-World Analogy

Think about different types of workers in a factory. A machine operator doesn't need access to payroll systems, and an HR manager doesn't need to know how to operate manufacturing equipment. Each role has access to only the interfaces relevant to their responsibilities.

When to Apply

  • Large interfaces: When interfaces are becoming unwieldy with many methods
  • Different client needs: When different clients need different subsets of functionality
  • Optional functionality: When some methods are not relevant to all implementers
  • Clean architecture: When building systems with clear separation of concerns

When to Avoid

  • Highly cohesive operations: When all methods in an interface are closely related and typically used together
  • Simple systems: When the overhead of multiple interfaces outweighs benefits
  • Stable, well-defined contracts: When interfaces are unlikely to grow or change
  • Performance considerations: When interface splitting introduces unacceptable overhead

Code Example

❌ Violating ISP:

from abc import ABC, abstractmethod

class WorkerInterface(ABC):
    @abstractmethod
    def work(self):
        pass
    
    @abstractmethod
    def eat(self):
        pass
    
    @abstractmethod
    def sleep(self):
        pass

class HumanWorker(WorkerInterface):
    def work(self):
        return "Human working"
    
    def eat(self):
        return "Human eating"
    
    def sleep(self):
        return "Human sleeping"

class RobotWorker(WorkerInterface):
    def work(self):
        return "Robot working"
    
    def eat(self):
        # Robots don't eat! Forced to implement irrelevant method
        raise NotImplementedError("Robots don't eat")
    
    def sleep(self):
        # Robots don't sleep! Forced to implement irrelevant method
        raise NotImplementedError("Robots don't sleep")

The RobotWorker is forced to implement methods (eat, sleep) that don't make sense for robots, violating ISP.

✅ Following ISP:

from abc import ABC, abstractmethod

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

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

class Sleepable(ABC):
    @abstractmethod
    def sleep(self):
        pass

class HumanWorker(Workable, Eatable, Sleepable):
    def work(self):
        return "Human working"
    
    def eat(self):
        return "Human eating"
    
    def sleep(self):
        return "Human sleeping"

class RobotWorker(Workable):  # Only implements relevant interface
    def work(self):
        return "Robot working"

class SuperRobot(Workable, Sleepable):  # Some robots might need sleep mode
    def work(self):
        return "Super robot working"
    
    def sleep(self):
        return "Super robot in sleep mode"

# Client code can depend on specific interfaces
def manage_workers(workers: list[Workable]):
    for worker in workers:
        print(worker.work())

def schedule_breaks(beings: list[Sleepable]):
    for being in beings:
        print(being.sleep())

Now each class only implements the interfaces relevant to its capabilities, satisfying ISP.

Dependency Inversion Principle (DIP)

The Purpose

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules. Both should depend on abstractions. Additionally, abstractions should not depend on details; details should depend on abstractions.

This is like designing a car's steering system. The steering wheel (high-level) doesn't directly control the wheels (low-level). Instead, it works through an abstract interface (power steering system) that can be implemented in different ways (hydraulic, electric, manual).

Real-World Analogy

Consider a universal phone charger. Your phone (high-level module) doesn't depend on a specific charger brand (low-level module). Instead, both depend on a standard interface (USB-C port). You can use any compatible charger without modifying your phone.

When to Apply

  • External dependencies: When your code depends on databases, APIs, or external services
  • Testing requirements: When you need to mock or stub dependencies for unit testing
  • Flexibility needs: When you might need to swap implementations in the future
  • Configuration-driven systems: When behavior should be configurable without code changes

When to Avoid

  • Simple, stable dependencies: When dependencies are unlikely to change and abstraction adds unnecessary complexity
  • Performance-critical code: When abstraction layers introduce unacceptable performance overhead
  • Internal, tightly coupled systems: When components are inherently coupled and won't change independently
  • Over-engineering risks: When you're abstracting stable, well-understood dependencies unnecessarily

Code Example

❌ Violating DIP:

import sqlite3

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

class UserService:
    def __init__(self):
        # High-level module directly depends on low-level module (SQLite)
        self.connection = sqlite3.connect('users.db')
        self.connection.execute('''
            CREATE TABLE IF NOT EXISTS users 
            (name TEXT, email TEXT)
        ''')
    
    def save_user(self, user):
        # Tightly coupled to SQLite implementation
        cursor = self.connection.cursor()
        cursor.execute(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            (user.name, user.email)
        )
        self.connection.commit()
        return "User saved to SQLite database"
    
    def get_user(self, email):
        cursor = self.connection.cursor()
        cursor.execute("SELECT name, email FROM users WHERE email = ?", (email,))
        result = cursor.fetchone()
        if result:
            return User(result[0], result[1])
        return None

# Usage
service = UserService()  # Tightly coupled to SQLite
user = User("John Doe", "john@example.com")
service.save_user(user)

The UserService is tightly coupled to SQLite. If we want to switch to PostgreSQL or MongoDB, we'd have to modify the service class.

✅ Following DIP:

from abc import ABC, abstractmethod
import sqlite3

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

class UserRepository(ABC):  # Abstraction
    @abstractmethod
    def save(self, user):
        pass
    
    @abstractmethod
    def find_by_email(self, email):
        pass

class SQLiteUserRepository(UserRepository):  # Low-level module depends on abstraction
    def __init__(self, db_path="users.db"):
        self.connection = sqlite3.connect(db_path)
        self.connection.execute('''
            CREATE TABLE IF NOT EXISTS users 
            (name TEXT, email TEXT)
        ''')
    
    def save(self, user):
        cursor = self.connection.cursor()
        cursor.execute(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            (user.name, user.email)
        )
        self.connection.commit()
        return "User saved to SQLite database"
    
    def find_by_email(self, email):
        cursor = self.connection.cursor()
        cursor.execute("SELECT name, email FROM users WHERE email = ?", (email,))
        result = cursor.fetchone()
        if result:
            return User(result[0], result[1])
        return None

class InMemoryUserRepository(UserRepository):  # Alternative implementation
    def __init__(self):
        self.users = []
    
    def save(self, user):
        self.users.append(user)
        return "User saved to memory"
    
    def find_by_email(self, email):
        for user in self.users:
            if user.email == email:
                return user
        return None

class UserService:  # High-level module depends on abstraction
    def __init__(self, user_repository: UserRepository):
        self.user_repository = user_repository
    
    def save_user(self, user):
        return self.user_repository.save(user)
    
    def get_user(self, email):
        return self.user_repository.find_by_email(email)

# Usage - dependency is injected
sqlite_repo = SQLiteUserRepository()
memory_repo = InMemoryUserRepository()

# Can easily switch between implementations
service = UserService(sqlite_repo)  # or memory_repo for testing
user = User("John Doe", "john@example.com")
service.save_user(user)

Now the UserService depends on the UserRepository abstraction, not on specific implementations. We can easily swap between different storage mechanisms without changing the service.

Putting It All Together: A Complete Example

Let's see how all SOLID principles work together in a practical e-commerce notification system:

from abc import ABC, abstractmethod
from typing import List

# SRP: Each class has a single responsibility
class Order:
    def __init__(self, order_id, customer_email, total_amount):
        self.order_id = order_id
        self.customer_email = customer_email
        self.total_amount = total_amount

# ISP: Separate interfaces for different concerns
class MessageFormatter(ABC):
    @abstractmethod
    def format_message(self, order: Order) -> str:
        pass

class NotificationSender(ABC):
    @abstractmethod
    def send(self, recipient: str, message: str) -> bool:
        pass

# OCP: Open for extension, closed for modification
class EmailFormatter(MessageFormatter):
    def format_message(self, order: Order) -> str:
        return f"""
        <html>
        <h2>Order Confirmation</h2>
        <p>Order ID: {order.order_id}</p>
        <p>Total: ${order.total_amount}</p>
        </html>
        """

class SMSFormatter(MessageFormatter):
    def format_message(self, order: Order) -> str:
        return f"Order {order.order_id} confirmed. Total: ${order.total_amount}"

class SlackFormatter(MessageFormatter):  # Easy to add new formatters
    def format_message(self, order: Order) -> str:
        return f"🎉 New order {order.order_id} for ${order.total_amount}!"

# LSP: All implementations can substitute their interfaces
class EmailSender(NotificationSender):
    def send(self, recipient: str, message: str) -> bool:
        print(f"Sending email to {recipient}: {message}")
        return True

class SMSSender(NotificationSender):
    def send(self, recipient: str, message: str) -> bool:
        print(f"Sending SMS to {recipient}: {message}")
        return True

class SlackSender(NotificationSender):
    def send(self, recipient: str, message: str) -> bool:
        print(f"Sending Slack message to {recipient}: {message}")
        return True

# DIP: High-level module depends on abstractions
class NotificationService:
    def __init__(self, formatter: MessageFormatter, sender: NotificationSender):
        self.formatter = formatter  # Depends on abstraction
        self.sender = sender        # Depends on abstraction
    
    def notify_order_confirmation(self, order: Order) -> bool:
        message = self.formatter.format_message(order)
        return self.sender.send(order.customer_email, message)

# Usage - flexible and testable
order = Order("12345", "customer@example.com", 99.99)

# Email notification
email_service = NotificationService(EmailFormatter(), EmailSender())
email_service.notify_order_confirmation(order)

# SMS notification
sms_service = NotificationService(SMSFormatter(), SMSSender())
sms_service.notify_order_confirmation(order)

# Mixed notification (Slack format via Email)
mixed_service = NotificationService(SlackFormatter(), EmailSender())
mixed_service.notify_order_confirmation(order)

This example demonstrates all SOLID principles working together to create a flexible, maintainable notification system.

Best Practices for Applying SOLID Principles

Start simple, refactor when needed: Don't over-engineer from the beginning. Apply SOLID principles when complexity demands it, not preemptively.

Focus on change patterns: Identify what aspects of your system change frequently and apply SOLID principles to those areas first.

Balance principles with pragmatism: SOLID principles are guidelines, not absolute rules. Sometimes violating a principle temporarily is acceptable for practical reasons.

Use dependency injection frameworks: Tools like Spring (Java), ASP.NET Core (C#), or dependency-injector (Python) can help manage dependencies effectively.

Write tests to validate design: Good tests often reveal design problems. If code is hard to test, it probably violates SOLID principles.

Refactor incrementally: Don't try to apply all SOLID principles at once. Improve design gradually through regular refactoring.

Document architectural decisions: Keep track of why you made specific design choices and how they align with SOLID principles.

Common Pitfalls and How to Avoid Them

Over-abstraction: Creating unnecessary interfaces and abstractions can make code harder to understand. Apply abstractions when you have a real need for flexibility.

Premature optimization: Don't apply SOLID principles everywhere just because you can. Focus on areas where they solve actual problems.

Ignoring context: SOLID principles apply differently in different contexts. A small script has different needs than an enterprise application.

Cargo cult programming: Don't apply patterns just because they're "best practices." Understand why and when each principle is useful.

Your Journey with SOLID Principles

SOLID principles are powerful tools for creating maintainable, flexible software, but they're not magic bullets. They're most effective when applied thoughtfully to solve real problems rather than followed blindly as dogma.

Start by identifying pain points in your current codebase: classes that are hard to test, code that breaks when you make changes, or systems that are difficult to extend. These are perfect opportunities to apply SOLID principles and see their benefits firsthand.

Remember, becoming proficient with SOLID principles is a journey, not a destination. Each project teaches you more about when and how to apply these principles effectively. The goal isn't perfect adherence to every principle but building software that serves your needs and can evolve with changing requirements.

Ready to start applying SOLID principles to your codebase? Begin with the Single Responsibility Principle—it's often the easiest to spot and fix. Look for classes doing too many things, extract responsibilities into separate classes, and observe how this improves your code's testability and maintainability.

Want to dive deeper into software design patterns and architectural best practices? Explore my advanced software architecture guides, subscribe to our newsletter for regular coding insights, or check out my comprehensive courses on writing clean, maintainable code. Your future self (and your teammates) will thank you for the investment in better software design.

Topics

solid-principlesobject-oriented-designclean-codesoftware-developmentdesign-patterns
Ruchit Suthar

About Ruchit Suthar

Technical Leader with 15+ years of experience scaling teams and systems