📝 Topics Covered

  1. 1. What are the SOLID Principles?
  2. 2. The Symptoms of Bad Code
  3. 3. The Motivation Behind SOLID
  4. 4. The SOLID Principles Explained
  5. 5. The Cost of Violation vs. Benefits of Adherence
  6. 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 easily without rewriting the core layers.
  • Parallel Development: By dividing logic across clear architectural boundaries, multiple engineers can work simultaneously on 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:

  1. We end up with tightly coupled code where changes in one module break multiple other systems.
  2. Development speed plummets because adding any new requirement requires massive, unorganized restructuring.
  3. Writing reliable automated tests becomes extremely difficult or impossible.
  4. Duplicate code blocks multiply across the codebase (violating the DRY principle).
  5. Code updates introduce a high risk of unexpected regression bugs.

5.2 If We Adhere to SOLID Principles:

  1. We achieve a massive reduction in architectural complexity.
  2. Core readability, changeability, and maintainability are maximized.
  3. Code blocks are decoupled, boosting reusability across different projects.
  4. Unit testing and overall testability become seamless.
  5. 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.

Reference