Skip to content

SOLID Principles

The SOLID principles are five foundational guidelines for object-oriented design, introduced by Robert C. Martin (Uncle Bob). They help developers write code that is easier to understand, maintain, extend, and refactor. When followed consistently, SOLID principles reduce technical debt and make software systems resilient to change.

LetterPrincipleCore Idea
SSingle ResponsibilityA class should have only one reason to change
OOpen/ClosedOpen for extension, closed for modification
LLiskov SubstitutionSubtypes must be substitutable for their base types
IInterface SegregationPrefer many small interfaces over one large interface
DDependency InversionDepend on abstractions, not concrete implementations

S — Single Responsibility Principle (SRP)

“A class should have one, and only one, reason to change.” — Robert C. Martin

The Single Responsibility Principle states that every class should encapsulate exactly one responsibility. If a class handles multiple concerns, changes to one concern risk breaking the other. SRP leads to smaller, focused classes that are easier to test and maintain.

The Problem: A Class Doing Too Much

Consider a User class that manages user data, validates input, and sends emails. Any change to the email system, validation rules, or data format forces changes to the same class.

# BAD: User class has three responsibilities
class User:
def __init__(self, name, email):
self.name = name
self.email = email
def get_user_info(self):
return f"{self.name} ({self.email})"
def validate_email(self):
# Validation logic -- reason to change #2
import re
pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
return bool(re.match(pattern, self.email))
def send_welcome_email(self):
# Email sending logic -- reason to change #3
if self.validate_email():
print(f"Sending welcome email to {self.email}")
# SMTP setup, template rendering, etc.

The Solution: Separate Responsibilities

Split each responsibility into its own class. Now changes to email logic, validation rules, or user data are isolated from one another.

# GOOD: Each class has a single responsibility
class User:
"""Responsibility: manage user data"""
def __init__(self, name, email):
self.name = name
self.email = email
def get_user_info(self):
return f"{self.name} ({self.email})"
class EmailValidator:
"""Responsibility: validate email addresses"""
@staticmethod
def is_valid(email):
import re
pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
return bool(re.match(pattern, email))
class EmailService:
"""Responsibility: send emails"""
def __init__(self, validator):
self.validator = validator
def send_welcome_email(self, user):
if self.validator.is_valid(user.email):
print(f"Sending welcome email to {user.email}")
# Usage
user = User("Alice", "alice@example.com")
validator = EmailValidator()
email_service = EmailService(validator)
email_service.send_welcome_email(user)

O — Open/Closed Principle (OCP)

“Software entities should be open for extension, but closed for modification.” — Bertrand Meyer

The Open/Closed Principle means you should be able to add new behavior to a system without changing existing, tested code. This is typically achieved through abstraction — using interfaces, abstract classes, or polymorphism so that new functionality is added via new classes rather than by editing old ones.

The Problem: Modifying Existing Code for Every New Feature

Imagine a discount calculator that uses if/else chains. Every time a new discount type is introduced, you must modify the existing function, risking bugs in previously working logic.

# BAD: Must modify this function for every new discount type
class DiscountCalculator:
def calculate(self, customer_type, amount):
if customer_type == "regular":
return amount * 0.95 # 5% discount
elif customer_type == "premium":
return amount * 0.90 # 10% discount
elif customer_type == "vip":
return amount * 0.80 # 20% discount
# Adding a new type? Must edit this class!
else:
return amount

The Solution: Extend with New Classes, Not Modifications

Define a DiscountStrategy abstraction and implement each discount type as its own class. New types are added by creating new classes — existing code is never touched.

# GOOD: Open for extension, closed for modification
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
@abstractmethod
def calculate(self, amount):
pass
class RegularDiscount(DiscountStrategy):
def calculate(self, amount):
return amount * 0.95 # 5% discount
class PremiumDiscount(DiscountStrategy):
def calculate(self, amount):
return amount * 0.90 # 10% discount
class VIPDiscount(DiscountStrategy):
def calculate(self, amount):
return amount * 0.80 # 20% discount
# Adding a new discount? Just create a new class!
class EmployeeDiscount(DiscountStrategy):
def calculate(self, amount):
return amount * 0.70 # 30% discount
class DiscountCalculator:
def __init__(self, strategy: DiscountStrategy):
self.strategy = strategy
def calculate(self, amount):
return self.strategy.calculate(amount)
# Usage
calc = DiscountCalculator(VIPDiscount())
print(calc.calculate(100)) # 80.0

L — Liskov Substitution Principle (LSP)

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

The Liskov Substitution Principle means that if your code works with a base class, it must also work correctly with any derived class. A subclass should extend behavior, not break the contract established by its parent.

The Problem: Subclass Breaks Parent’s Contract

The classic example is Rectangle and Square. A square is a rectangle mathematically, but in code, making Square inherit from Rectangle creates unexpected behavior when width and height are set independently.

# BAD: Square breaks the Rectangle contract
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 __init__(self, size):
super().__init__(size, size)
def set_width(self, width):
# Forces both dimensions to change -- breaks expectations!
self._width = width
self._height = width
def set_height(self, height):
self._width = height
self._height = height
# This function expects Rectangle behavior
def resize_and_check(rect):
rect.set_width(5)
rect.set_height(10)
assert rect.area() == 50, f"Expected 50, got {rect.area()}"
resize_and_check(Rectangle(2, 3)) # Passes
resize_and_check(Square(2)) # FAILS! area() returns 100, not 50

The Solution: Use a Common Abstraction

Instead of forcing Square to inherit from Rectangle, define a shared Shape interface. Each shape implements its own area logic independently, so substitution never breaks the contract.

# GOOD: Both shapes implement a common interface correctly
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side * self.side
# Both work correctly with any code that expects a Shape
def print_area(shape: Shape):
print(f"Area: {shape.area()}")
print_area(Rectangle(5, 10)) # Area: 50
print_area(Square(7)) # Area: 49

I — Interface Segregation Principle (ISP)

“No client should be forced to depend on methods it does not use.” — Robert C. Martin

The Interface Segregation Principle states that large, monolithic interfaces should be split into smaller, more specific ones. Classes should only need to implement the methods that are relevant to them. This prevents “fat interfaces” that force implementing classes to provide stub or empty methods for functionality they do not support.

The Problem: A Fat Interface

A Worker interface that requires all workers to implement work(), eat(), and sleep() forces a Robot class to provide meaningless implementations for eat() and sleep().

# BAD: Fat interface forces irrelevant implementations
from abc import ABC, abstractmethod
class Worker(ABC):
@abstractmethod
def work(self):
pass
@abstractmethod
def eat(self):
pass
@abstractmethod
def sleep(self):
pass
class Human(Worker):
def work(self):
print("Human working")
def eat(self):
print("Human eating")
def sleep(self):
print("Human sleeping")
class Robot(Worker):
def work(self):
print("Robot working")
def eat(self):
pass # Robots don't eat! Forced to implement.
def sleep(self):
pass # Robots don't sleep! Forced to implement.

The Solution: Split Into Focused Interfaces

Break the fat interface into smaller role-based interfaces. Each class only implements the interfaces that apply to it.

# GOOD: Small, focused interfaces
from abc import ABC, abstractmethod
class Workable(ABC):
@abstractmethod
def work(self):
pass
class Feedable(ABC):
@abstractmethod
def eat(self):
pass
class Sleepable(ABC):
@abstractmethod
def sleep(self):
pass
class Human(Workable, Feedable, Sleepable):
def work(self):
print("Human working")
def eat(self):
print("Human eating")
def sleep(self):
print("Human sleeping")
class Robot(Workable):
def work(self):
print("Robot working")
# No need to implement eat() or sleep()!
# Functions depend only on the interface they need
def assign_task(worker: Workable):
worker.work()
def schedule_lunch(eater: Feedable):
eater.eat()
assign_task(Human()) # Works
assign_task(Robot()) # Works
schedule_lunch(Human()) # Works
# schedule_lunch(Robot()) # Type error -- correct!

D — Dependency Inversion Principle (DIP)

“High-level modules should not depend on low-level modules. Both should depend on abstractions.” — Robert C. Martin

The Dependency Inversion Principle states that instead of high-level business logic depending directly on low-level implementation details (databases, file systems, APIs), both should depend on abstract interfaces. This decouples modules from specific implementations and makes the system easier to test and swap components.

The Problem: High-Level Module Depends on Low-Level Details

A NotificationService that directly instantiates and depends on a concrete EmailSender is tightly coupled. Switching to SMS or push notifications requires rewriting the service.

# BAD: High-level module depends directly on low-level module
class EmailSender:
def send(self, recipient, message):
print(f"Email to {recipient}: {message}")
class NotificationService:
def __init__(self):
# Tightly coupled to EmailSender!
self.sender = EmailSender()
def notify(self, recipient, message):
self.sender.send(recipient, message)
# What if we want SMS? Must rewrite NotificationService!

The Solution: Depend on Abstractions

Define a MessageSender interface. The NotificationService depends on the abstraction, and concrete senders are injected at runtime. Swapping implementations requires zero changes to the service.

# GOOD: Both high-level and low-level depend on abstractions
from abc import ABC, abstractmethod
class MessageSender(ABC):
@abstractmethod
def send(self, recipient, message):
pass
class EmailSender(MessageSender):
def send(self, recipient, message):
print(f"Email to {recipient}: {message}")
class SMSSender(MessageSender):
def send(self, recipient, message):
print(f"SMS to {recipient}: {message}")
class PushNotificationSender(MessageSender):
def send(self, recipient, message):
print(f"Push to {recipient}: {message}")
class NotificationService:
def __init__(self, sender: MessageSender):
# Depends on abstraction, not concrete class
self.sender = sender
def notify(self, recipient, message):
self.sender.send(recipient, message)
# Easy to swap implementations
service = NotificationService(EmailSender())
service.notify("alice@example.com", "Welcome!")
service = NotificationService(SMSSender())
service.notify("+1234567890", "Your code is 1234")

SOLID Principles Summary

PrincipleAcronymKey QuestionViolation Smell
Single ResponsibilitySRPDoes this class have more than one reason to change?God classes, classes with “and” in their description
Open/ClosedOCPCan I add new behavior without modifying existing code?Long if/else or switch chains for types
Liskov SubstitutionLSPCan I use a subclass anywhere the parent is expected?Overridden methods that throw NotImplementedError or break contracts
Interface SegregationISPIs every method in this interface used by every implementor?Empty method stubs, “fat” interfaces
Dependency InversionDIPDoes my high-level logic depend on concrete low-level details?new inside business logic, hard-coded dependencies

SOLID in Practice: Real-World Scenarios

Scenario 1: E-Commerce Order Processing

An order processing system must calculate totals, apply discounts, charge payment, and send confirmations. Without SOLID, all this logic lives in one giant OrderProcessor class.

Applying SOLID:

  • SRP — Separate into OrderCalculator, DiscountEngine, PaymentGateway, OrderNotifier
  • OCP — New payment methods (PayPal, crypto) are added as new PaymentMethod implementations
  • LSP — All PaymentMethod subclasses process payments without breaking the checkout flow
  • ISPPaymentMethod interface only requires charge() and refund(), not unrelated methods like sendReceipt()
  • DIPOrderProcessor depends on PaymentMethod interface, not StripePayment directly

Scenario 2: Logging Framework

A logging framework must support multiple outputs (console, file, remote server) and formats (plain text, JSON, XML).

Applying SOLID:

  • SRPLogger orchestrates; Formatter handles formatting; Transport handles output
  • OCP — New formats or transports are added as new classes without modifying the Logger
  • LSP — Any Transport (console, file, HTTP) can be used interchangeably
  • ISPTransport only needs write(entry), not formatting methods
  • DIPLogger depends on Formatter and Transport abstractions, injected at creation

Scenario 3: Authentication System

An authentication system must support multiple strategies (password, OAuth, SSO, biometrics).

Applying SOLID:

  • SRPAuthController handles routing; AuthStrategy handles verification; SessionManager handles sessions
  • OCP — New auth strategies (e.g., WebAuthn) are added by implementing AuthStrategy
  • LSP — All strategies return consistent AuthResult objects
  • ISPAuthStrategy only requires authenticate(credentials), not session management
  • DIP — The controller depends on the AuthStrategy interface, not any specific provider

Common Violations and How to Fix Them

1. The God Class (Violates SRP)

Symptom: A single class with hundreds or thousands of lines that handles UI, business logic, data access, and more.

Fix: Identify distinct responsibilities and extract each into its own class. Use the “describe in one sentence” test — if you need “and” or “or”, the class has too many responsibilities.

2. The Switch/If-Else Chain (Violates OCP)

Symptom: A function with a growing switch or if/else block that checks types or categories.

Fix: Replace the conditional with polymorphism. Define an interface for the varying behavior and create one implementation per case.

3. The Broken Override (Violates LSP)

Symptom: A subclass overrides a parent method to throw an exception, return null, or do nothing.

Fix: Reconsider the inheritance hierarchy. If the subclass cannot honor the parent’s contract, it likely should not extend that parent. Use composition or a different interface instead.

4. The Kitchen-Sink Interface (Violates ISP)

Symptom: An interface with 10+ methods, and most implementing classes leave several of them empty or throw UnsupportedOperationException.

Fix: Split the interface into several smaller, cohesive interfaces. Group methods by the clients that use them.

5. The Hard-Coded Dependency (Violates DIP)

Symptom: Business logic classes instantiate their own dependencies using new or direct constructors, making testing and swapping impossible.

Fix: Accept dependencies through constructors (constructor injection) or setters. Depend on interfaces, not concrete classes. Use a dependency injection container in larger applications.


Key Takeaways

  1. SOLID is about managing change. Every principle addresses a different way that changes in requirements can cascade through your codebase. Following them limits the blast radius of any single change.

  2. Start with SRP. If each class has a single, well-defined purpose, the other principles become easier to apply naturally.

  3. OCP and DIP work together. Abstractions (DIP) are the mechanism that enables extension without modification (OCP). You cannot follow OCP well without DIP.

  4. LSP is about contracts, not types. A subclass that technically compiles but behaves unexpectedly is more dangerous than a compile error. Think about behavioral compatibility, not just structural compatibility.

  5. ISP prevents “pollution.” Fat interfaces force unnecessary coupling. Small, focused interfaces keep classes lean and reduce the impact of changes.

  6. Apply SOLID pragmatically. Over-engineering a simple script with five layers of abstraction is just as harmful as ignoring the principles entirely. Use your judgment — apply SOLID where the code will benefit from flexibility and maintainability.

  7. SOLID principles are complementary. They reinforce each other. A codebase that follows all five tends to be modular, testable, and resilient to changing requirements.


Practice Exercises

Exercise 1: Refactor a Report Generator

You have a ReportGenerator class that fetches data from a database, formats it as HTML, and emails it to the manager. Refactor it to follow SRP and DIP.

Hints
  • Identify the three responsibilities: data retrieval, formatting, and delivery.
  • Create a DataFetcher interface for the data source (could be a database, API, or file).
  • Create a ReportFormatter interface with implementations like HTMLFormatter, PDFFormatter, and CSVFormatter.
  • Create a ReportDelivery interface with implementations like EmailDelivery and FileDelivery.
  • The ReportGenerator should accept all three as constructor dependencies.

Exercise 2: Fix an LSP Violation

Given the following class hierarchy, identify the LSP violation and fix it:

class Bird:
def fly(self):
return "Flying high!"
def make_sound(self):
return "Tweet!"
class Penguin(Bird):
def fly(self):
raise Exception("Penguins can't fly!") # LSP violation!
def make_sound(self):
return "Squawk!"
Hints
  • Not all birds can fly, so fly() should not be part of the base Bird class.
  • Create a Bird base class with make_sound().
  • Create a FlyingBird subclass (or a Flyable interface) that adds fly().
  • Penguin extends Bird but not FlyingBird.
  • Eagle extends FlyingBird (or implements both Bird and Flyable).

Exercise 3: Apply OCP to a Shape Area Calculator

You have a function that calculates the total area of shapes using type-checking:

def total_area(shapes):
total = 0
for shape in shapes:
if isinstance(shape, dict) and shape["type"] == "circle":
total += 3.14159 * shape["radius"] ** 2
elif isinstance(shape, dict) and shape["type"] == "rectangle":
total += shape["width"] * shape["height"]
# Must add more elif for each new shape...
return total

Refactor this to follow OCP so that new shapes can be added without modifying the total_area function.

Hints
  • Define an abstract Shape class with an area() method.
  • Create Circle, Rectangle, Triangle, etc. as concrete implementations.
  • The total_area function simply calls shape.area() for each item — it never needs to know the specific type.
  • Test by adding a Triangle class without changing total_area.

Next Steps