SOLID Principles & Clean Code

0/3 in this phase0/54 across the roadmap

📖 Concept

The SOLID principles are five design guidelines that help developers write maintainable, extensible, and testable object-oriented code. While originally formulated for statically-typed languages, they apply equally well to Python — with some Pythonic adaptations.

The SOLID principles:

Principle Name Core Idea
S Single Responsibility A class should have only one reason to change
O Open/Closed Open for extension, closed for modification
L Liskov Substitution Subtypes must be substitutable for their base types
I Interface Segregation Many specific interfaces beat one general-purpose interface
D Dependency Inversion Depend on abstractions, not concrete implementations

Single Responsibility Principle (SRP): Each class or function should do exactly one thing. If a class handles user authentication and sends emails, split it. In Python, functions are often the right unit of responsibility — not everything needs to be a class.

Open/Closed Principle (OCP): Extend behavior without modifying existing code. In Python, this means using composition over inheritance, plugin registries, or strategy functions. The @functools.singledispatch decorator is a built-in example — add new type handlers without touching the original function.

Liskov Substitution Principle (LSP): A subclass must honor the contract of its parent. If Rectangle has a set_width() method, a Square subclass shouldn't break callers that expect width and height to be independent. In Python, use Protocol classes or ABCs to define explicit contracts.

Interface Segregation Principle (ISP): Don't force classes to implement methods they don't need. Python uses duck typing and Protocol classes (PEP 544) instead of Java-style interfaces — define small, focused protocols.

Dependency Inversion Principle (DIP): High-level modules shouldn't depend on low-level modules; both should depend on abstractions. In Python, pass dependencies as constructor arguments or use Protocol types for type hints.

Clean code practices: Use descriptive names, keep functions short (under 20 lines ideally), avoid deep nesting (use early returns), write docstrings, and prefer composition over inheritance — Python's mixin and protocol patterns make this natural.

💻 Code Example

codeTap to expand ⛶
1# ============================================================
2# SOLID Principles in PythonPractical Examples
3# ============================================================
4
5from abc import ABC, abstractmethod
6from typing import Protocol, runtime_checkable
7from dataclasses import dataclass
8
9
10# --- S: Single Responsibility Principle ---
11
12# BAD: One class doing everything
13class UserManagerBad:
14 def create_user(self, name, email):
15 # Validates, saves to DB, sends email — too many responsibilities
16 pass
17
18# GOOD: Separate responsibilities
19@dataclass
20class User:
21 name: str
22 email: str
23
24class UserValidator:
25 def validate(self, user: User) -> bool:
26 if not user.name or len(user.name) < 2:
27 raise ValueError("Name must be at least 2 characters")
28 if "@" not in user.email:
29 raise ValueError("Invalid email address")
30 return True
31
32class UserRepository:
33 def __init__(self):
34 self._users: dict[str, User] = {}
35
36 def save(self, user: User) -> None:
37 self._users[user.email] = user
38
39 def find_by_email(self, email: str) -> User | None:
40 return self._users.get(email)
41
42class EmailService:
43 def send_welcome(self, user: User) -> None:
44 print(f"Welcome email sent to {user.email}")
45
46
47# --- O: Open/Closed Principle ---
48
49# Using functools.singledispatch for extensibility
50from functools import singledispatch
51
52@dataclass
53class TextReport:
54 content: str
55
56@dataclass
57class HTMLReport:
58 html: str
59
60@dataclass
61class PDFReport:
62 binary_data: bytes
63
64@singledispatch
65def export_report(report) -> str:
66 raise NotImplementedError(f"No exporter for {type(report)}")
67
68@export_report.register(TextReport)
69def _(report: TextReport) -> str:
70 return report.content
71
72@export_report.register(HTMLReport)
73def _(report: HTMLReport) -> str:
74 return f"<html><body>{report.html}</body></html>"
75
76# Extend without modifying existing code — just add new registrations
77@export_report.register(PDFReport)
78def _(report: PDFReport) -> str:
79 return f"PDF ({len(report.binary_data)} bytes)"
80
81
82# --- L: Liskov Substitution Principle ---
83
84class Shape(ABC):
85 @abstractmethod
86 def area(self) -> float: ...
87
88class Rectangle(Shape):
89 def __init__(self, width: float, height: float):
90 self._width = width
91 self._height = height
92
93 def area(self) -> float:
94 return self._width * self._height
95
96class Circle(Shape):
97 def __init__(self, radius: float):
98 self._radius = radius
99
100 def area(self) -> float:
101 import math
102 return math.pi * self._radius ** 2
103
104# Any Shape can be used interchangeably
105def print_total_area(shapes: list[Shape]) -> None:
106 total = sum(s.area() for s in shapes)
107 print(f"Total area: {total:.2f}")
108
109print_total_area([Rectangle(3, 4), Circle(5), Rectangle(2, 6)])
110
111
112# --- I: Interface Segregation with Protocols ---
113
114@runtime_checkable
115class Readable(Protocol):
116 def read(self) -> str: ...
117
118@runtime_checkable
119class Writable(Protocol):
120 def write(self, data: str) -> None: ...
121
122class FileStorage:
123 """Implements both Readable and Writable."""
124 def __init__(self):
125 self._data = ""
126
127 def read(self) -> str:
128 return self._data
129
130 def write(self, data: str) -> None:
131 self._data = data
132
133class ReadOnlyCache:
134 """Only implements Readable — not forced to implement write."""
135 def __init__(self, data: str):
136 self._data = data
137
138 def read(self) -> str:
139 return self._data
140
141def display(source: Readable) -> None:
142 """Depends only on what it needs — Readable."""
143 print(f"Content: {source.read()}")
144
145display(FileStorage()) # Works
146display(ReadOnlyCache("hi")) # Works — no unused write() method
147
148
149# --- D: Dependency Inversion Principle ---
150
151class NotificationSender(Protocol):
152 def send(self, to: str, message: str) -> None: ...
153
154class EmailSender:
155 def send(self, to: str, message: str) -> None:
156 print(f"Email to {to}: {message}")
157
158class SMSSender:
159 def send(self, to: str, message: str) -> None:
160 print(f"SMS to {to}: {message}")
161
162class UserService:
163 """Depends on abstraction (Protocol), not concrete sender."""
164 def __init__(self, notifier: NotificationSender):
165 self._notifier = notifier
166
167 def register(self, user: User) -> None:
168 # Business logic...
169 self._notifier.send(user.email, f"Welcome, {user.name}!")
170
171# Easy to swap implementations and mock in tests
172user_svc = UserService(notifier=EmailSender())
173user_svc.register(User("Alice", "alice@example.com"))
174
175user_svc_sms = UserService(notifier=SMSSender())
176user_svc_sms.register(User("Bob", "555-0123"))

🏋️ Practice Exercise

Exercises:

  1. Take this monolithic class and refactor it to follow SRP: a ReportManager class that fetches data from a database, applies business rules, formats the output as HTML, and sends it via email. Split it into at least 4 classes.

  2. Implement an OCP-compliant payment processing system. Start with CreditCardPayment and PayPalPayment, then add CryptoPayment without modifying existing code. Use a registry pattern or singledispatch.

  3. Demonstrate a Liskov Substitution violation: create a Bird base class with a fly() method, then a Penguin subclass. Show why this violates LSP and refactor using Protocols to fix it.

  4. Refactor a "fat interface" into segregated protocols. Start with a single IDatabase class that has read(), write(), delete(), backup(), and replicate() methods. Split into focused protocols and show a class that only implements the ones it needs.

  5. Apply Dependency Inversion to a logging system. Create a LogSink protocol with implementations for console, file, and remote HTTP logging. Build an Application class that accepts any LogSink via constructor injection.

  6. Review one of your past Python projects and identify at least 3 SOLID violations. Document each violation and propose a refactored solution.

⚠️ Common Mistakes

  • Creating classes for everything — in Python, functions and modules can fulfill SRP without unnecessary class wrappers. Not everything needs to be a class.

  • Violating Liskov Substitution by having subclasses raise NotImplementedError for inherited methods. If a subclass can't support a parent's interface, the hierarchy is wrong.

  • Confusing Dependency Inversion with Dependency Injection. DIP is the principle (depend on abstractions); DI is the technique (pass dependencies in). You can practice DI without following DIP.

  • Over-segregating interfaces — creating one Protocol per method leads to fragmented code that's harder to understand. Group related behaviors logically.

  • Applying SOLID rigidly to small scripts or prototypes. These principles shine in large codebases; for a 50-line script, pragmatism beats purity.

💼 Interview Questions

🎤 Mock Interview

Practice a live interview for SOLID Principles & Clean Code