📝 Topics Covered
- 1. What are the SOLID Principles?
- 2. The Symptoms of Bad Code
- 3. The Motivation Behind SOLID
- 4. The SOLID Principles Explained
- 5. The Cost of Violation vs. Benefits of Adherence
- 6. Practical Exercise: Data Munging
1. What are the SOLID Principles?
SOLID is a famous, foundational set of five object-oriented design principles formulated to make software systems flexible, modular, and maintainable.
First compiled and introduced by the industry veteran Robert C. Martin (also widely known as Uncle Bob), these principles serve as essential rail-guards when designing class relationships. Adhering to SOLID ensures that codebases remain understandable and scalable, allowing multiple developers to collaborate without stepped-on toes.
2. The Symptoms of Bad Code
When software is designed poorly or changes are made in an ad-hoc manner, it starts exhibiting classic architectural problems:
- Rigidity: The software is
difficult to modify. Even a small, isolated change triggers a cascade of compulsory, subsequent edits across other dependent modules. - Fragility: The software
breaks in unexpected places. Making a change to one section of code breaks logical operations in an entirely unrelated part of the application. - Confusion (Low Readability): The codebase lacks standard, clean organization, making it highly complex for new or existing developers to understand.
Using object-oriented concepts correctly—specifically guided by SOLID—helps prevent rigidity, fragility, and non-reusability.
3. The Motivation Behind SOLID
Applying these design principles helps engineers optimize key metrics during the software development lifecycle:
- Maintainability: Ensures corporate systems can be modified, updated, and debugged easily to keep up with changing market needs.
- Testability: Decoupled classes make it highly practical to apply
Test-Driven Development (TDD)and isolate bugs at the unit level. - Flexibility & Extensibility: Ensures that system modules can be adapted to work in different ways and that we can
add new features easilywithout rewriting the core layers. - Parallel Development: By dividing logic across clear architectural boundaries, multiple engineers can
work simultaneouslyon different components without merge conflicts. - Loose Coupling (Independence): Minimizes logical dependencies between various software modules, ensuring change in one place doesn’t dictate change in another.
4. The SOLID Principles Explained
S: Single Responsibility Principle (SRP)
A class should have one, and only one, reason to change.
This implies that a class should have only one job or responsibility. Everything inside that class must serve that single, well-defined purpose. With SRP, classes become smaller, cleaner, and less fragile.
Violating SRP (The Journal saves itself):
class Journal:
def __init__(self):
self.entries = []
self.count = 0
def add_entry(self, text):
self.entries.append(f"{self.count}: {text}")
self.count += 1
def remove_entry(self, pos):
del self.entries[pos]
def __str__(self):
return "\n".join(self.entries)
# VIOLATION: The journal should not be responsible for file I/O operations
def save(self, filename):
file = open(filename, "w")
file.write(str(self))
file.close()
def load(self, filename):
pass
def load_from_web(self, uri):
pass
Adhering to SRP (Delegating file persistence):
class Journal:
def __init__(self):
self.entries = []
self.count = 0
def add_entry(self, text):
self.entries.append(f"{self.count}: {text}")
self.count += 1
def remove_entry(self, pos):
del self.entries[pos]
def __str__(self):
return "\n".join(self.entries)
class PersistenceManager:
@staticmethod
def save_to_file(journal, filename):
with open(filename, "w") as file:
file.write(str(journal))
# Usage
j = Journal()
j.add_entry("I studied SOLID principles today.")
j.add_entry("I wrote some clean python code.")
print(f"Journal entries:\n{j}\n")
file = 'journal.txt'
PersistenceManager.save_to_file(j, file)
O: Open-Closed Principle (OCP)
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
Modifying the tested behavior of an existing class risks breaking all dependent systems. Instead, we should extend behavior using inheritance or polymorphism.
Violating OCP (Class undergoes code explosion with every new filter type):
class Product:
def __init__(self, name, color, size):
self.name = name
self.color = color
self.size = size
class ProductFilter:
# Adding a new filter criteria requires modifying this tested class
def filter_by_color(self, products, color):
for p in products:
if p.color == color: yield p
def filter_by_size(self, products, size):
for p in products:
if p.size == size: yield p
def filter_by_size_and_color(self, products, size, color):
for p in products:
if p.color == color and p.size == size: yield p
Adhering to OCP (Using the Specification Pattern):
from enum import Enum
class Color(Enum):
RED = 1
GREEN = 2
BLUE = 3
class Size(Enum):
SMALL = 1
MEDIUM = 2
LARGE = 3
class Product:
def __init__(self, name, color, size):
self.name = name
self.color = color
self.size = size
class Specification:
def is_satisfied(self, item):
pass
def __and__(self, other):
return AndSpecification(self, other)
class Filter:
def filter(self, items, spec):
pass
class ColorSpecification(Specification):
def __init__(self, color):
self.color = color
def is_satisfied(self, item):
return item.color == self.color
class SizeSpecification(Specification):
def __init__(self, size):
self.size = size
def is_satisfied(self, item):
return item.size == self.size
class AndSpecification(Specification):
def __init__(self, *args):
self.args = args
def is_satisfied(self, item):
return all(map(lambda spec: spec.is_satisfied(item), self.args))
class BetterFilter(Filter):
def filter(self, items, spec):
for item in items:
if spec.is_satisfied(item):
yield item
# Usage
apple = Product('Apple', Color.GREEN, Size.SMALL)
tree = Product('Tree', Color.GREEN, Size.LARGE)
house = Product('House', Color.BLUE, Size.LARGE)
products = [apple, tree, house]
bf = BetterFilter()
print('Green products:')
green = ColorSpecification(Color.GREEN)
for p in bf.filter(products, green):
print(f' - {p.name} is green')
print('Large blue items:')
large_blue = SizeSpecification(Size.LARGE) & ColorSpecification(Color.BLUE)
for p in bf.filter(products, large_blue):
print(f' - {p.name} is large and blue')
L: Liskov Substitution Principle (LSP)
Derived types must be completely substitutable for their base types.
The child class should be able to do everything the parent class can do without breaking the correctness of the client code. A subclass should only extend without replacing or breaking the expectations of the base class.
Violating LSP (Square overrides Rectangle set-behaviors with unexpected side-effects):
class Rectangle:
def __init__(self, width, height):
self._height = height
self._width = width
@property
def area(self):
return self._width * self._height
@property
def width(self):
return self._width
@width.setter
def width(self, value):
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
self._height = value
class Square(Rectangle):
def __init__(self, size):
super().__init__(size, size)
# VIOLATION: Overriding setters makes the square non-substitutable for a rectangle
@Rectangle.width.setter
def width(self, value):
self._width = self._height = value
@Rectangle.height.setter
def height(self, value):
self._width = self._height = value
def use_it(rc):
w = rc.width
rc.height = 10 # Client expects ONLY height to mutate
expected = int(w * 10)
print(f'Expected an area of {expected}, got {rc.area}')
# Rect behaves correctly
rc = Rectangle(2, 3)
use_it(rc) # Output: Expected an area of 20, got 20
# Square behaves unexpectedly!
sq = Square(5)
use_it(sq) # Output: Expected an area of 50, got 100 (breaks LSP!)
I: Interface Segregation Principle (ISP)
Many client-specific interfaces are better than one general-purpose interface.
Instead of defining one bloated interface, we should break it down into smaller, highly cohesive interfaces. A client should never be forced to implement an interface it does not use.
Violating ISP (Old printer forced to raise NotImplementedError for fax/scan):
class Machine:
def print(self, document):
raise NotImplementedError()
def fax(self, document):
raise NotImplementedError()
def scan(self, document):
raise NotImplementedError()
class OldFashionedPrinter(Machine):
def print(self, document):
print("Printing...")
def fax(self, document):
pass # VIOLATION: forced to implement dummy method
def scan(self, document):
# VIOLATION: forced to throw error for unsupported operations
raise NotImplementedError('Printer cannot scan!')
Adhering to ISP (Splitting into focused interfaces):
from abc import abstractmethod
class Printer:
@abstractmethod
def print(self, document):
pass
class Scanner:
@abstractmethod
def scan(self, document):
pass
# Clients only inherit what they actually support
class SimplePrinter(Printer):
def print(self, document):
print(f"Printing: {document}")
class Photocopier(Printer, Scanner):
def print(self, document):
print(f"Printing photocopy: {document}")
def scan(self, document):
print("Scanning document...")
D: Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Additionally, abstractions should not depend on details; details must depend on abstractions. Depend on abstractions, not on concretions.
Violating DIP (High-level Research directly accesses lists inside Low-level Relationships):
from enum import Enum
class Relationship(Enum):
PARENT = 0
CHILD = 1
class Person:
def __init__(self, name):
self.name = name
class Relationships: # Low-level storage module
def __init__(self):
self.relations = []
def add_parent_and_child(self, parent, child):
self.relations.append((parent, Relationship.PARENT, child))
class Research: # High-level business logic
def __init__(self, relationships):
# VIOLATION: Direct dependency on internal storage implementation details
relations = relationships.relations
for r in relations:
if r[0].name == 'John' and r[1] == Relationship.PARENT:
print(f'John has a child called {r[2].name}.')
Adhering to DIP (Introducing an abstract interface layer):
from abc import abstractmethod
from enum import Enum
class Relationship(Enum):
PARENT = 0
CHILD = 1
class Person:
def __init__(self, name):
self.name = name
class RelationshipBrowser: # The abstraction
@abstractmethod
def find_all_children_of(self, name):
pass
class Relationships(RelationshipBrowser): # Low-level implementation
def __init__(self):
self.relations = []
def add_parent_and_child(self, parent, child):
self.relations.append((parent, Relationship.PARENT, child))
def find_all_children_of(self, name):
for r in self.relations:
if r[0].name == name and r[1] == Relationship.PARENT:
yield r[2].name
class Research: # High-level module depends only on the abstraction
def __init__(self, browser):
for p in browser.find_all_children_of("John"):
print(f'John has a child called {p}')
# Usage
parent = Person('John')
child1 = Person('Chris')
child2 = Person('Matt')
relationships = Relationships()
relationships.add_parent_and_child(parent, child1)
relationships.add_parent_and_child(parent, child2)
Research(relationships)
5. The Cost of Violation vs. Benefits of Adherence
5.1 If We Violate SOLID Principles:
- We end up with
tightly coupled codewhere changes in one module break multiple other systems. - Development speed plummets because adding any new requirement requires massive, unorganized restructuring.
- Writing reliable automated tests becomes extremely difficult or impossible.
- Duplicate code blocks multiply across the codebase (violating the DRY principle).
- Code updates introduce a high risk of unexpected regression bugs.
5.2 If We Adhere to SOLID Principles:
- We achieve a massive
reduction in architectural complexity. - Core readability, changeability, and maintainability are maximized.
- Code blocks are decoupled, boosting
reusabilityacross different projects. - Unit testing and overall
testabilitybecome seamless. - The system remains easily adaptable as team sizes and client demands scale.
🎥 Uncle Bob’s SOLID Principles Guide
6. Practical Exercise: Data Munging
To truly internalize these principles, practice solving this classic software engineering challenge:
- The Kata: CodeKata 04: Data Munging Practice
- Goal: Design clean, decoupled classes using minimum code while applying SOLID relationships to parse meteorological and football data.