SOLID Principles in Python - Engineering Patterns for Maintainable Code
Reading time: ~32 minutes | Level: Intermediate → Engineering
Before reading further, identify every SOLID violation in this code:
import sqlite3
import smtplib
import json
class UserManager:
def __init__(self):
self.db = sqlite3.connect("users.db")
def create_user(self, username, email, password):
hashed = password[::-1] # terrible "hashing"
self.db.execute(
"INSERT INTO users VALUES (?, ?, ?)", (username, email, hashed)
)
self.db.commit()
# Send welcome email
server = smtplib.SMTP("smtp.gmail.com", 587)
server.quit()
# Log to file
with open("app.log", "a") as f:
f.write(f"User created: {username}\n")
return {"username": username, "email": email}
def validate_email(self, email):
return "@" in email
def get_all_users(self):
return self.db.execute("SELECT * FROM users").fetchall()
def export_to_json(self, users):
return json.dumps(users)
def export_to_csv(self, users):
return "\n".join(",".join(str(v) for v in row) for row in users)
This 40-line class violates all five SOLID principles. By the end of this lesson, you will be able to identify each violation precisely and refactor it to a clean design.
What You Will Learn
- Single Responsibility Principle (SRP): one class, one reason to change
- Open/Closed Principle (OCP): extend via
typing.Protocol, not modification - Liskov Substitution Principle (LSP): subclasses must be substitutable - violations and consequences
- Interface Segregation Principle (ISP): small, focused ABCs over fat interfaces
- Dependency Inversion Principle (DIP): inject abstractions, not concretions
- Python-specific implementation patterns for each principle
- Common violations in Python codebases
Prerequisites
- Lessons 01–10 of this module
- Understanding of composition, dependency injection, ABCs, and
typing.Protocol - Familiarity with Python's
abcmodule
SOLID as a Decision Checklist
Before diving into each principle, here is how they interact as a design checklist:
Part 1 - Single Responsibility Principle
The Principle
A class should have one, and only one, reason to change.
The word "reason" means stakeholder or concern. If your class handles both data persistence and email notifications, it will need to change when the database schema changes AND when the email provider changes. Those are two different reasons.
SRP is not "a class should do one thing". A class can do many things as long as those things are all in service of one coherent responsibility. It is about cohesion and the source of change.
SRP diagnostic: if you have to use "and" to describe what a class does, it probably needs to be split. "This class validates users AND sends emails AND logs to disk" - that is three responsibilities, three reasons to change. Each "and" is a split point.
Violation
The UserManager class from the puzzle has at least five reasons to change:
- Database implementation changes (SQLite → PostgreSQL)
- Password hashing algorithm changes
- Email provider changes (Gmail → SendGrid)
- Log format changes (file → structured JSON)
- Export format requirements change (add XML)
# Every method in this class serves a different master
class UserManager:
def create_user(...): # orchestration + persistence + email + logging
def validate_email(...): # validation logic
def get_all_users(...): # data access
def export_to_json(...): # serialisation
def export_to_csv(...): # serialisation (different format)
Applying SRP
Decompose the class by reason-to-change. Each resulting component has one reason to change.
# 1. Password hashing - reason: crypto policy changes
class PasswordHasher:
def hash(self, password: str) -> str:
import hashlib
return hashlib.sha256(password.encode()).hexdigest()
def verify(self, password: str, hashed: str) -> bool:
return self.hash(password) == hashed
# 2. User repository - reason: database/storage changes
class UserRepository:
def __init__(self, db_path: str = "users.db"):
import sqlite3
self._db = sqlite3.connect(db_path)
self._db.execute(
"CREATE TABLE IF NOT EXISTS users (username TEXT, email TEXT, password_hash TEXT)"
)
def save(self, username: str, email: str, password_hash: str) -> None:
self._db.execute(
"INSERT INTO users VALUES (?, ?, ?)",
(username, email, password_hash)
)
self._db.commit()
def find_all(self) -> list:
return self._db.execute("SELECT username, email FROM users").fetchall()
# 3. Email service - reason: email provider or template changes
class EmailService:
def __init__(self, smtp_host: str, smtp_port: int):
self._host = smtp_host
self._port = smtp_port
def send_welcome(self, to: str, username: str) -> None:
import smtplib
server = smtplib.SMTP(self._host, self._port)
server.quit()
# 4. Logger - reason: logging destination or format changes
class Logger:
def __init__(self, log_path: str = "app.log"):
self._path = log_path
def info(self, message: str) -> None:
with open(self._path, "a") as f:
f.write(f"INFO: {message}\n")
# 5. Input validation - reason: business validation rules change
class UserValidator:
def validate_email(self, email: str) -> bool:
return "@" in email and "." in email.split("@")[1]
def validate_username(self, username: str) -> bool:
return 3 <= len(username) <= 50 and username.isalnum()
# 6. Exporter - reason: export format requirements change
class UserExporter:
def to_json(self, users: list) -> str:
import json
return json.dumps([{"username": u, "email": e} for u, e in users])
def to_csv(self, users: list) -> str:
header = "username,email"
rows = "\n".join(f"{u},{e}" for u, e in users)
return f"{header}\n{rows}"
# 7. Orchestration - reason: business workflow changes
class UserRegistrationService:
def __init__(
self,
repo: UserRepository,
hasher: PasswordHasher,
email_svc: EmailService,
validator: UserValidator,
logger: Logger,
):
self._repo = repo
self._hasher = hasher
self._email = email_svc
self._validator = validator
self._logger = logger
def register(self, username: str, email: str, password: str) -> dict:
if not self._validator.validate_username(username):
raise ValueError(f"Invalid username: {username!r}")
if not self._validator.validate_email(email):
raise ValueError(f"Invalid email: {email!r}")
hashed = self._hasher.hash(password)
self._repo.save(username, email, hashed)
self._email.send_welcome(to=email, username=username)
self._logger.info(f"User registered: {username}")
return {"username": username, "email": email}
Now each class has exactly one reason to change. The orchestration service (UserRegistrationService) is the only class that changes when the workflow changes.
SRP at the Module Level
SRP applies to modules too. Do not put database models, business logic, API handlers, and utility functions in a single file. Organise by cohesion.
myapp/
├── models/
│ └── user.py # data models
├── repositories/
│ └── user_repo.py # data access
├── services/
│ └── user_service.py # business logic
├── api/
│ └── user_handler.py # HTTP handlers
└── utils/
└── validators.py # shared utilities
Part 2 - Open/Closed Principle
The Principle
Software entities should be open for extension but closed for modification.
Adding new behaviour should not require modifying existing, working code. The classic violation is a large if/elif/isinstance chain that grows every time a new variant is added.
OCP violation signal: every time you add a new if/elif branch to handle a new type or format, you are modifying existing code and risking regressions. Each new format in a ReportExporter.export(format="xml") call requires touching the same function. The fix is to extract the varying behaviour into a Protocol and let new implementations extend the system without touching the existing code.
Violation
class ReportExporter:
def export(self, data: list, format: str) -> str:
if format == "json":
import json
return json.dumps(data)
elif format == "csv":
return "\n".join(",".join(str(v) for v in row) for row in data)
elif format == "xml":
# Added later - required modifying this class
rows = "\n".join(
f" <row>{''.join(f'<v>{v}</v>' for v in row)}</row>"
for row in data
)
return f"<data>\n{rows}\n</data>"
else:
raise ValueError(f"Unknown format: {format!r}")
# Every new format = modify this class = risk breaking existing formats
Applying OCP with typing.Protocol
from typing import Protocol
class ExportFormatter(Protocol):
def format(self, data: list) -> str: ...
class JsonFormatter:
def format(self, data: list) -> str:
import json
return json.dumps(data)
class CsvFormatter:
def format(self, data: list) -> str:
return "\n".join(",".join(str(v) for v in row) for row in data)
class XmlFormatter:
def format(self, data: list) -> str:
rows = "\n".join(
f" <row>{''.join(f'<v>{v}</v>' for v in row)}</row>"
for row in data
)
return f"<data>\n{rows}\n</data>"
class ReportExporter:
"""Open for extension (new formatters), closed for modification."""
def __init__(self, formatter: ExportFormatter):
self._formatter = formatter
def export(self, data: list) -> str:
return self._formatter.format(data)
# Adding YAML support: create YamlFormatter - zero changes to ReportExporter
class YamlFormatter:
def format(self, data: list) -> str:
import yaml
return yaml.dump(data)
# Usage
exporter = ReportExporter(formatter=JsonFormatter())
print(exporter.export([[1, "Alice"], [2, "Bob"]]))
# Switch to CSV without changing ReportExporter
exporter = ReportExporter(formatter=CsvFormatter())
print(exporter.export([[1, "Alice"], [2, "Bob"]]))
OCP with ABCs for Required Hooks
When you want to enforce that extensions implement specific methods, use an ABC:
from abc import ABC, abstractmethod
class NotificationChannel(ABC):
"""Abstract channel - open for extension via subclassing."""
@abstractmethod
def send(self, recipient: str, message: str) -> bool:
"""Send a notification. Returns True if successful."""
...
def send_batch(self, recipients: list[str], message: str) -> dict[str, bool]:
"""Default batch implementation - overridable for efficiency."""
return {r: self.send(r, message) for r in recipients}
class EmailChannel(NotificationChannel):
def send(self, recipient: str, message: str) -> bool:
print(f"Email → {recipient}: {message}")
return True
class SlackChannel(NotificationChannel):
def __init__(self, webhook_url: str):
self._webhook = webhook_url
def send(self, recipient: str, message: str) -> bool:
print(f"Slack [{self._webhook}] → {recipient}: {message}")
return True
class NotificationService:
"""Closed for modification - supports any NotificationChannel."""
def __init__(self, channels: list[NotificationChannel]):
self._channels = channels
def notify(self, recipient: str, message: str) -> None:
for channel in self._channels:
channel.send(recipient, message)
# Add SMS without touching NotificationService
class SMSChannel(NotificationChannel):
def send(self, recipient: str, message: str) -> bool:
print(f"SMS → {recipient}: {message}")
return True
service = NotificationService(channels=[EmailChannel(), SlackChannel("https://..."), SMSChannel()])
Part 3 - Liskov Substitution Principle
The Principle
Objects of a superclass should be replaceable with objects of a subclass without altering the correctness of the program.
If you have code that works with Base, substituting Derived for Base must not break it. The subclass must honour the contract of the parent - same preconditions, same postconditions, same invariants.
LSP violation: if a subclass raises NotImplementedError for a method defined in the parent, it is NOT a true subtype. Code that calls the parent method trusting its contract will crash when given the subclass. The fix is always to redesign the hierarchy - either remove the method from the parent, split into separate interfaces, or use composition. Never paper over an LSP violation with try/except.
Violation 1 - Raising NotImplementedError in Overrides
class Bird:
def fly(self) -> str:
return "flying"
class Penguin(Bird):
def fly(self) -> str:
raise NotImplementedError("Penguins cannot fly!")
def make_bird_fly(bird: Bird) -> str:
return bird.fly() # works for Bird, breaks for Penguin
make_bird_fly(Bird()) # "flying"
make_bird_fly(Penguin()) # NotImplementedError - LSP violated
The fix: Penguin should not inherit from Bird if it cannot honour the fly() contract. Redesign the hierarchy or use composition.
# Fix: separate the flyable capability
class Bird:
def breathe(self): ...
def eat(self): ...
class FlyingBird(Bird):
def fly(self) -> str:
return "flying"
class Penguin(Bird):
def swim(self) -> str:
return "swimming"
class Eagle(FlyingBird):
pass
def make_fly(bird: FlyingBird) -> str:
return bird.fly() # only accepts birds that can fly
make_fly(Eagle()) # "flying"
# make_fly(Penguin()) # type error - caught by type checker
Violation 2 - Strengthening Preconditions
class DataProcessor:
def process(self, data: list) -> list:
"""Process any list, including empty lists."""
return [item * 2 for item in data]
class StrictProcessor(DataProcessor):
def process(self, data: list) -> list:
if not data:
raise ValueError("data must not be empty") # Stronger precondition - LSP violated
return [item * 2 for item in data]
# Code written for DataProcessor:
def process_dataset(processor: DataProcessor, data: list) -> list:
return processor.process(data) # may be called with empty list
process_dataset(DataProcessor(), []) # [] - works
process_dataset(StrictProcessor(), []) # ValueError - contract broken
A subclass must accept at least the same inputs as the parent. It can accept more, never less.
Violation 3 - Weakening Postconditions
from typing import Optional
class Cache:
def get(self, key: str) -> Optional[str]:
"""Returns the cached value, or None if not found. Never raises."""
return self._store.get(key)
class BrokenCache(Cache):
def get(self, key: str) -> Optional[str]:
# Raises KeyError instead of returning None - weakens postcondition
return self._store[key] # KeyError if missing - LSP violated
def fetch_with_default(cache: Cache, key: str, default: str) -> str:
value = cache.get(key) # caller trusts return-or-None contract
return value if value is not None else default
fetch_with_default(Cache(), "missing", "default") # "default"
fetch_with_default(BrokenCache(), "missing", "default") # KeyError - contract broken
The Practical LSP Check
For every public method in the parent class, ask:
- Can the subclass be called with the same arguments? (preconditions not strengthened)
- Does the subclass return the same types and value ranges? (postconditions not weakened)
- Does the subclass preserve all invariants the parent guarantees?
# Good LSP-compliant hierarchy
class NumberList:
def __init__(self, values: list[float]):
if not all(isinstance(v, (int, float)) for v in values):
raise TypeError("All values must be numbers")
self._values = list(values)
def sum(self) -> float:
return sum(self._values)
def average(self) -> float:
if not self._values:
return 0.0
return self.sum() / len(self._values)
class NonEmptyNumberList(NumberList):
def __init__(self, values: list[float]):
if not values:
raise ValueError("List cannot be empty")
super().__init__(values)
def average(self) -> float:
# Safe - list is guaranteed non-empty by __init__
return self.sum() / len(self._values)
# NonEmptyNumberList IS-A NumberList - but with stronger construction precondition.
# This is technically an LSP grey area: constructor preconditions are often acceptably
# strengthened. The key is that once constructed, it honours all postconditions.
# Code accepting a NumberList that was constructed already can use NonEmptyNumberList safely.
Part 4 - Interface Segregation Principle
The Principle
Clients should not be forced to depend on interfaces they do not use.
Fat interfaces force implementing classes to provide stub implementations of methods they do not need, creating coupling to irrelevant behaviour.
Violation
from abc import ABC, abstractmethod
class WorkerInterface(ABC):
@abstractmethod
def work(self) -> None: ...
@abstractmethod
def eat(self) -> None: ...
@abstractmethod
def sleep(self) -> None: ...
class RobotWorker(WorkerInterface):
def work(self) -> None:
print("Robot working")
def eat(self) -> None:
raise NotImplementedError("Robots don't eat") # forced stub - ISP violated
def sleep(self) -> None:
raise NotImplementedError("Robots don't sleep") # forced stub - ISP violated
RobotWorker is forced to implement eat() and sleep() just to satisfy the interface, even though those methods make no sense for it.
Applying ISP: Small, Focused Interfaces
from abc import ABC, abstractmethod
class Workable(ABC):
@abstractmethod
def work(self) -> None: ...
class Eatable(ABC):
@abstractmethod
def eat(self) -> None: ...
class Sleepable(ABC):
@abstractmethod
def sleep(self) -> None: ...
class HumanWorker(Workable, Eatable, Sleepable):
def work(self) -> None: print("Human working")
def eat(self) -> None: print("Human eating")
def sleep(self) -> None: print("Human sleeping")
class RobotWorker(Workable):
def work(self) -> None: print("Robot working")
# No eat() or sleep() - not forced to implement them
# Functions depend only on what they need
def schedule_work(worker: Workable) -> None:
worker.work()
def schedule_lunch(eater: Eatable) -> None:
eater.eat()
schedule_work(HumanWorker()) # works
schedule_work(RobotWorker()) # works
schedule_lunch(HumanWorker()) # works
# schedule_lunch(RobotWorker()) # type error - RobotWorker is not Eatable
ISP with typing.Protocol
from typing import Protocol
class Readable(Protocol):
def read(self, size: int = -1) -> bytes: ...
class Writable(Protocol):
def write(self, data: bytes) -> int: ...
class Seekable(Protocol):
def seek(self, offset: int) -> int: ...
def tell(self) -> int: ...
class ReadWriteStream(Readable, Writable, Protocol):
pass
# Functions declare only the capabilities they need
def copy_data(source: Readable, dest: Writable) -> None:
while chunk := source.read(4096):
dest.write(chunk)
def get_position(stream: Seekable) -> int:
return stream.tell()
# Standard library file objects satisfy all three without declaring them
import io
buf = io.BytesIO(b"hello")
copy_data(source=buf, dest=io.BytesIO()) # works - BytesIO satisfies Readable and Writable
Python's typing.Protocol enables structural subtyping: no inheritance required for interface compliance. A class satisfies a Protocol simply by having the matching methods and signatures - no implements keyword, no explicit registration. This is sometimes called "duck typing with static checking." It is the most Pythonic way to express ISP: define small Protocols for each capability, and any class that has those methods automatically satisfies the interface.
Production Example: Storage ISP
from typing import Protocol
class BlobReader(Protocol):
def read_blob(self, blob_id: str) -> bytes: ...
class BlobWriter(Protocol):
def write_blob(self, blob_id: str, data: bytes) -> str: ...
class BlobDeleter(Protocol):
def delete_blob(self, blob_id: str) -> None: ...
class BlobMetadata(Protocol):
def blob_exists(self, blob_id: str) -> bool: ...
def blob_size(self, blob_id: str) -> int: ...
# Full storage implements all
class S3Storage:
def read_blob(self, blob_id: str) -> bytes: ...
def write_blob(self, blob_id: str, data: bytes) -> str: ...
def delete_blob(self, blob_id: str) -> None: ...
def blob_exists(self, blob_id: str) -> bool: ...
def blob_size(self, blob_id: str) -> int: ...
# Read-only cache only needs BlobReader + BlobMetadata
class ReadThroughCache:
def __init__(self, storage: BlobReader, metadata: BlobMetadata):
self._storage = storage
self._meta = metadata
self._cache: dict = {}
def get(self, blob_id: str) -> bytes:
if blob_id not in self._cache:
if not self._meta.blob_exists(blob_id):
raise KeyError(blob_id)
self._cache[blob_id] = self._storage.read_blob(blob_id)
return self._cache[blob_id]
# ReadThroughCache does NOT depend on write or delete capabilities
# It is protected from changes to those interfaces
Part 5 - Dependency Inversion Principle
The Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
The key inversion: instead of high-level business logic creating and depending on concrete implementations (SQLite, SMTP, Redis), both sides depend on an abstraction (a Protocol or ABC). The concrete implementations are injected.
DIP Class Diagram
The diagram shows the key DIP insight: OrderProcessor (high-level) points only to Protocols (abstractions). The concrete classes (SQLiteOrderRepository, EmailNotifier) implement the protocols without OrderProcessor ever knowing about them.
Violation
class OrderProcessor:
def __init__(self):
import sqlite3
# High-level module creates and depends on low-level module directly
self._db = sqlite3.connect("orders.db")
import smtplib
self._smtp = smtplib.SMTP("smtp.gmail.com", 587)
def process_order(self, order: dict) -> None:
# Business logic mixed with infrastructure details
self._db.execute("INSERT INTO orders VALUES (?)", (order["id"],))
self._db.commit()
Problems: OrderProcessor is untestable without a real database and SMTP server. Changing from SQLite to PostgreSQL requires modifying OrderProcessor. These are two separate concerns forced together.
Applying DIP
from typing import Protocol
# Abstractions - both sides depend on these
class OrderRepository(Protocol):
def save(self, order: dict) -> None: ...
def find(self, order_id: str) -> dict | None: ...
class Notifier(Protocol):
def notify(self, recipient: str, message: str) -> None: ...
# Low-level details implement the abstractions
class SQLiteOrderRepository:
def __init__(self, db_path: str):
import sqlite3
self._db = sqlite3.connect(db_path)
def save(self, order: dict) -> None:
self._db.execute("INSERT INTO orders VALUES (?)", (order["id"],))
self._db.commit()
def find(self, order_id: str) -> dict | None:
row = self._db.execute(
"SELECT * FROM orders WHERE id = ?", (order_id,)
).fetchone()
return {"id": row[0]} if row else None
class EmailNotifier:
def notify(self, recipient: str, message: str) -> None:
import smtplib
server = smtplib.SMTP("smtp.gmail.com", 587)
server.quit()
# High-level module depends ONLY on abstractions
class OrderProcessor:
def __init__(self, repo: OrderRepository, notifier: Notifier):
# Both dependencies are injected - DIP achieved
self._repo = repo
self._notifier = notifier
def process_order(self, order: dict) -> None:
# Pure business logic - no infrastructure details
self._repo.save(order)
self._notifier.notify(order["email"], f"Order {order['id']} confirmed!")
# Production wiring
processor = OrderProcessor(
repo=SQLiteOrderRepository("orders.db"),
notifier=EmailNotifier(),
)
# Test wiring - no database, no SMTP server
class InMemoryOrderRepository:
def __init__(self):
self.saved: list = []
def save(self, order: dict) -> None:
self.saved.append(order)
def find(self, order_id: str) -> dict | None:
return next((o for o in self.saved if o["id"] == order_id), None)
class RecordingNotifier:
def __init__(self):
self.notifications: list = []
def notify(self, recipient: str, message: str) -> None:
self.notifications.append({"recipient": recipient, "message": message})
# Test
repo = InMemoryOrderRepository()
notifier = RecordingNotifier()
processor = OrderProcessor(repo=repo, notifier=notifier)
assert len(repo.saved) == 1
assert repo.saved[0]["id"] == "ORD-001"
assert len(notifier.notifications) == 1
print("All assertions passed")
DIP in a Layered Architecture
from typing import Protocol
# === Domain Layer (innermost - no external dependencies) ===
class User:
def __init__(self, user_id: str, username: str, email: str):
self.user_id = user_id
self.username = username
self.email = email
# === Application Layer (depends on abstractions only) ===
class UserRepository(Protocol):
def find_by_id(self, user_id: str) -> User | None: ...
def save(self, user: User) -> None: ...
class EventPublisher(Protocol):
def publish(self, event_type: str, payload: dict) -> None: ...
class UserService:
"""High-level business logic - depends only on abstractions."""
def __init__(self, repo: UserRepository, events: EventPublisher):
self._repo = repo
self._events = events
def deactivate_user(self, user_id: str) -> None:
user = self._repo.find_by_id(user_id)
if user is None:
raise ValueError(f"User not found: {user_id}")
# Business rule: cannot deactivate admin users
if user.username.startswith("admin_"):
raise PermissionError(f"Cannot deactivate admin user: {user.username}")
self._repo.save(user)
self._events.publish("user.deactivated", {"user_id": user_id})
# === Infrastructure Layer (implements abstractions) ===
class PostgresUserRepository:
def find_by_id(self, user_id: str) -> User | None:
# SELECT from PostgreSQL
...
def save(self, user: User) -> None:
# INSERT/UPDATE in PostgreSQL
...
class KafkaEventPublisher:
def __init__(self, broker: str):
self._broker = broker
def publish(self, event_type: str, payload: dict) -> None:
import json
print(f"Kafka [{self._broker}] → {event_type}: {json.dumps(payload)}")
The domain layer knows nothing about databases or message brokers. The application layer knows nothing about PostgreSQL or Kafka. The infrastructure layer implements the interfaces that the application layer defined. This is the Dependency Rule from Clean Architecture.
Part 6 - The Complete Refactoring
Returning to the opening example - here it is fully refactored against all five SOLID principles:
from typing import Protocol
from dataclasses import dataclass
from abc import ABC, abstractmethod
# SRP: each class has one responsibility
# ISP: small, focused interfaces
# OCP: open for extension via Protocol
# DIP: high-level depends on abstractions
class PasswordHasher(Protocol): # ISP: just hashing
def hash(self, password: str) -> str: ...
class UserStore(Protocol): # ISP: just storage
def save(self, username: str, email: str, password_hash: str) -> None: ...
def list_all(self) -> list[dict]: ...
class WelcomeMailer(Protocol): # ISP: just welcome emails
def send_welcome(self, to: str, username: str) -> None: ...
class AuditLogger(Protocol): # ISP: just logging
def log_registration(self, username: str) -> None: ...
class UserExporter(Protocol): # ISP: just export
def export(self, users: list[dict]) -> str: ...
# SRP: one class, one reason to change
class SHA256Hasher:
def hash(self, password: str) -> str:
import hashlib
return hashlib.sha256(password.encode()).hexdigest()
class InMemoryUserStore:
def __init__(self):
self._users: list = []
def save(self, username: str, email: str, password_hash: str) -> None:
self._users.append({"username": username, "email": email, "hash": password_hash})
def list_all(self) -> list[dict]:
return [{"username": u["username"], "email": u["email"]} for u in self._users]
class PrintWelcomeMailer:
def send_welcome(self, to: str, username: str) -> None:
print(f"MAIL → {to}: Welcome {username}!")
class FileAuditLogger:
def __init__(self, path: str = "audit.log"):
self._path = path
def log_registration(self, username: str) -> None:
with open(self._path, "a") as f:
f.write(f"REGISTERED: {username}\n")
class JsonUserExporter: # OCP: add new format = new class
def export(self, users: list[dict]) -> str:
import json
return json.dumps(users)
class CsvUserExporter:
def export(self, users: list[dict]) -> str:
lines = ["username,email"]
lines.extend(f"{u['username']},{u['email']}" for u in users)
return "\n".join(lines)
# DIP: high-level orchestration depends only on abstractions
class UserRegistrationService:
def __init__(
self,
store: UserStore,
hasher: PasswordHasher,
mailer: WelcomeMailer,
logger: AuditLogger,
):
self._store = store
self._hasher = hasher
self._mailer = mailer
self._logger = logger
def register(self, username: str, email: str, password: str) -> dict:
if "@" not in email:
raise ValueError(f"Invalid email: {email!r}")
hash_value = self._hasher.hash(password)
self._store.save(username, email, hash_value)
self._mailer.send_welcome(to=email, username=username)
self._logger.log_registration(username)
return {"username": username, "email": email}
# Wire it up
service = UserRegistrationService(
store=InMemoryUserStore(),
hasher=SHA256Hasher(),
mailer=PrintWelcomeMailer(),
logger=FileAuditLogger(),
)
Common Mistakes
Mistake 1 - Confusing SRP with "One Method Per Class"
SRP is about one reason to change, not one method. A UserValidator class can have validate_email, validate_username, and validate_password - all cohesive, all in service of the same responsibility.
Mistake 2 - Creating Proxy Classes for OCP That Add No Value
# Over-engineered OCP: abstraction with no realistic variation
class StringUppercaser(Protocol):
def uppercase(self, s: str) -> str: ...
class DefaultUppercaser:
def uppercase(self, s: str) -> str: return s.upper()
# Just use str.upper() directly - not everything needs to be a Protocol
OCP is about anticipated points of variation. Do not add abstractions unless you have a realistic reason to expect variation.
Mistake 3 - LSP Violation Hidden by try/except
class SafeCache(Cache):
def get(self, key: str) -> str | None:
try:
return self._store[key] # raises KeyError - LSP violation hidden
except KeyError:
return None # caller cannot tell this "fixed" the violation
# The fix should be at the method level, not wrapped in try/except
Mistake 4 - DIP Without an Interface (Partial DIP)
# Not DIP: high-level depends on concrete, even though it's injected
class Service:
def __init__(self, db: SQLiteDatabase): # injected but still concrete
self._db = db
# True DIP: depends on abstraction
class Service:
def __init__(self, db: DatabaseProtocol): # abstraction
self._db = db
Injection alone is not DIP. The dependency must be inverted - the high-level module must define the abstraction, not depend on the concrete type.
Engineering Checklist
Before moving to the next lesson, verify you can answer these without looking:
- What does "one reason to change" mean in SRP? How is it different from "one method"?
- What is the canonical OCP violation pattern, and how do you fix it?
- State the three Liskov substitution rules: preconditions, postconditions, invariants.
- What is the penalty for an ISP violation? What happens to implementing classes?
- How does DIP differ from simple dependency injection?
- In a layered architecture applying DIP, which layer defines the interfaces?
- Given
class Service(Protocol)andclass ServiceImpl(ABC)- which is more suited to external dependencies and which to internal hierarchies? - Why does
raise NotImplementedErrorin an overridden method violate LSP? - How can you verify LSP compliance at the type checker level?
Graded Practice Challenges
Level 1 - Predict the Output / Identify the Violation
For each snippet, identify which SOLID principle is violated and why.
Question 1
class ReportService:
def generate(self, data):
...
def save_to_db(self, report):
...
def email_report(self, report, recipient):
...
def export_to_pdf(self, report):
...
Show Answer
SRP violation. ReportService has at least four reasons to change: report generation logic, database schema, email provider, and PDF library. Each method serves a different stakeholder.
Fix: split into ReportGenerator, ReportRepository, ReportMailer, and ReportExporter.
Question 2
class Animal:
def speak(self) -> str:
return "..."
class Dog(Animal):
def speak(self) -> str:
return "Woof"
class Fish(Animal):
def speak(self) -> str:
raise NotImplementedError("Fish cannot speak")
def make_noise(animal: Animal) -> str:
return animal.speak()
Show Answer
LSP violation. Fish is a subtype of Animal but raises NotImplementedError for speak(). Code that calls make_noise(animal) trusting the Animal contract will crash when passed a Fish.
Fix: remove speak() from Animal, create a Speakable interface, and only let Dog (and similar animals) implement it.
Question 3
from abc import ABC, abstractmethod
class DataStore(ABC):
@abstractmethod
def read(self, key: str) -> bytes: ...
@abstractmethod
def write(self, key: str, data: bytes) -> None: ...
@abstractmethod
def delete(self, key: str) -> None: ...
@abstractmethod
def list_keys(self) -> list[str]: ...
class ReadOnlyArchive(DataStore):
def read(self, key: str) -> bytes:
return b"archived data"
def write(self, key: str, data: bytes) -> None:
raise NotImplementedError("Archive is read-only")
def delete(self, key: str) -> None:
raise NotImplementedError("Archive is read-only")
def list_keys(self) -> list[str]:
return ["key1", "key2"]
Show Answer
ISP violation (and a secondary LSP violation). DataStore is a fat interface - ReadOnlyArchive is forced to stub out write and delete with NotImplementedError.
Fix: split into Readable, Writable, Deletable, and Listable protocols. ReadOnlyArchive only implements Readable and Listable.
Level 2 - Debug Challenge
This code has an OCP and DIP violation. Identify both and refactor to fix them:
import sqlite3
import json
class DataExporter:
def __init__(self):
# DIP violation: creates its own dependency
self._db = sqlite3.connect("app.db")
def export(self, table: str, format: str) -> str:
rows = self._db.execute(f"SELECT * FROM {table}").fetchall()
# OCP violation: adding new format = modifying this method
if format == "json":
return json.dumps(rows)
elif format == "csv":
return "\n".join(",".join(str(v) for v in row) for row in rows)
else:
raise ValueError(f"Unknown format: {format!r}")
Show Solution
DIP violation: DataExporter creates sqlite3.connect(...) directly in __init__. It is tightly coupled to SQLite and cannot be tested without a real database.
OCP violation: Every new export format requires modifying the export method, risking regressions in existing format handling.
Refactored solution:
from typing import Protocol
# Abstraction for data source (DIP)
class DataSource(Protocol):
def fetch(self, query_key: str) -> list: ...
# Abstraction for formatters (OCP)
class Formatter(Protocol):
def format(self, rows: list) -> str: ...
# Concrete data source
class SQLiteDataSource:
def __init__(self, db_path: str):
import sqlite3
self._db = sqlite3.connect(db_path)
def fetch(self, query_key: str) -> list:
return self._db.execute(f"SELECT * FROM {query_key}").fetchall()
# In-memory source for testing
class InMemoryDataSource:
def __init__(self, data: dict[str, list]):
self._data = data
def fetch(self, query_key: str) -> list:
return self._data.get(query_key, [])
# Concrete formatters - add new ones without touching DataExporter
class JsonFormatter:
def format(self, rows: list) -> str:
import json
return json.dumps(rows)
class CsvFormatter:
def format(self, rows: list) -> str:
return "\n".join(",".join(str(v) for v in row) for row in rows)
# New format: no changes to DataExporter
class TsvFormatter:
def format(self, rows: list) -> str:
return "\n".join("\t".join(str(v) for v in row) for row in rows)
# DIP: depends only on abstractions; OCP: add formatter without modification
class DataExporter:
def __init__(self, source: DataSource, formatter: Formatter):
self._source = source
self._formatter = formatter
def export(self, query_key: str) -> str:
rows = self._source.fetch(query_key)
return self._formatter.format(rows)
# Production
exporter = DataExporter(
source=SQLiteDataSource("app.db"),
formatter=JsonFormatter(),
)
# Test - no database, no format coupling
test_data = {"users": [(1, "Alice"), (2, "Bob")]}
test_exporter = DataExporter(
source=InMemoryDataSource(test_data),
formatter=CsvFormatter(),
)
print(test_exporter.export("users"))
# 1,Alice
# 2,Bob
Level 3 - Design Challenge
Design a payment processing system that follows all five SOLID principles. Requirements:
- Support multiple payment methods:
CreditCard,PayPal,BankTransfer - Support multiple notification channels after payment: email and SMS
- Support multiple audit log backends: file and database
- The payment orchestrator (
PaymentService) must be testable without real payment gateways, notification providers, or log backends - Adding a new payment method should require zero changes to
PaymentService
Show Reference Solution
from typing import Protocol
from dataclasses import dataclass
# === Abstractions (ISP: small and focused) ===
@dataclass
class PaymentRequest:
amount: float
currency: str
customer_id: str
reference: str
@dataclass
class PaymentResult:
success: bool
transaction_id: str
message: str
class PaymentGateway(Protocol): # ISP: only payment
def charge(self, request: PaymentRequest) -> PaymentResult: ...
class CustomerNotifier(Protocol): # ISP: only notification
def notify(self, customer_id: str, message: str) -> None: ...
class PaymentAuditor(Protocol): # ISP: only audit
def record(self, request: PaymentRequest, result: PaymentResult) -> None: ...
# === Concrete Implementations (OCP: extend without modifying PaymentService) ===
class CreditCardGateway:
def charge(self, request: PaymentRequest) -> PaymentResult:
print(f"Credit card: charging {request.amount} {request.currency}")
return PaymentResult(success=True, transaction_id="CC-001", message="Approved")
class PayPalGateway:
def charge(self, request: PaymentRequest) -> PaymentResult:
print(f"PayPal: charging {request.amount} {request.currency}")
return PaymentResult(success=True, transaction_id="PP-001", message="Completed")
class BankTransferGateway:
def charge(self, request: PaymentRequest) -> PaymentResult:
print(f"Bank transfer: initiating {request.amount} {request.currency}")
return PaymentResult(success=True, transaction_id="BT-001", message="Initiated")
class EmailNotifier:
def notify(self, customer_id: str, message: str) -> None:
print(f"EMAIL to {customer_id}: {message}")
class SMSNotifier:
def notify(self, customer_id: str, message: str) -> None:
print(f"SMS to {customer_id}: {message}")
class FileAuditor:
def record(self, request: PaymentRequest, result: PaymentResult) -> None:
print(f"FILE LOG: {request.reference} → {result.transaction_id} ({result.success})")
class DatabaseAuditor:
def record(self, request: PaymentRequest, result: PaymentResult) -> None:
print(f"DB INSERT: {request.reference} → {result.transaction_id}")
# === High-Level Orchestration (DIP: depends only on abstractions) ===
class PaymentService:
"""SRP: only payment orchestration. DIP: only Protocols. OCP: extend via injection."""
def __init__(
self,
gateway: PaymentGateway,
notifiers: list[CustomerNotifier],
auditor: PaymentAuditor,
):
self._gateway = gateway
self._notifiers = notifiers
self._auditor = auditor
def process(self, request: PaymentRequest) -> PaymentResult:
result = self._gateway.charge(request)
self._auditor.record(request, result)
message = (
f"Payment of {request.amount} {request.currency} succeeded (ref: {request.reference})"
if result.success
else f"Payment failed: {result.message}"
)
for notifier in self._notifiers:
notifier.notify(request.customer_id, message)
return result
# === Production Wiring ===
service = PaymentService(
gateway=CreditCardGateway(),
notifiers=[EmailNotifier(), SMSNotifier()],
auditor=FileAuditor(),
)
req = PaymentRequest(amount=99.99, currency="USD", customer_id="cust-42", reference="ORD-2024-001")
result = service.process(req)
print(result)
# === Test Wiring - no real gateways, notifiers, or file I/O ===
class MockGateway:
def __init__(self, success: bool = True):
self._success = success
self.charged: list = []
def charge(self, request: PaymentRequest) -> PaymentResult:
self.charged.append(request)
return PaymentResult(success=self._success, transaction_id="MOCK-001", message="Mock")
class RecordingNotifier:
def __init__(self):
self.sent: list = []
def notify(self, customer_id: str, message: str) -> None:
self.sent.append({"customer": customer_id, "msg": message})
class NullAuditor:
def record(self, request: PaymentRequest, result: PaymentResult) -> None:
pass # no-op for tests
gateway = MockGateway(success=True)
notifier = RecordingNotifier()
test_service = PaymentService(gateway=gateway, notifiers=[notifier], auditor=NullAuditor())
test_req = PaymentRequest(amount=10.0, currency="USD", customer_id="test-1", reference="T-001")
test_service.process(test_req)
assert len(gateway.charged) == 1
assert gateway.charged[0].reference == "T-001"
assert len(notifier.sent) == 1
assert "succeeded" in notifier.sent[0]["msg"]
print("All test assertions passed")
Key Takeaways
- SRP: each class should have one, and only one, reason to change. If you use "and" to describe what a class does, it likely needs to be split.
- OCP: adding new behaviour should not require modifying existing code. The signal for an OCP violation is an
if/elifchain that grows with every new type or format. - LSP: subclasses must honour the parent's contract - same accepted inputs, same return types, same invariants. A
raise NotImplementedErrorin a subclass override is always an LSP violation. - ISP: clients should only depend on the interface capabilities they use.
typing.Protocolmakes this natural in Python - define small, focused protocols for each capability. - DIP: high-level modules depend on abstractions (Protocols/ABCs), not concrete implementations. Abstractions are defined by the high-level module, not the low-level one. Injection alone is not DIP - the dependency must also be an abstraction.
- Python's
typing.Protocolenables structural subtyping: a class satisfies a Protocol simply by having the matching methods, with no explicitimplementsdeclaration. This is the most Pythonic way to implement ISP and DIP.
What's Next
Lesson 12 covers Design Patterns - the Gang of Four patterns reimplemented in idiomatic Python. Python's dynamic typing, first-class functions, and metaclass system make many classic patterns simpler or look fundamentally different from their Java/C++ originals. You will see Singleton, Factory, Strategy, Observer, Decorator, Registry, and Builder - each with a GoF intent, a Pythonic implementation, and a real framework usage example.
