SOLID PRINCIPLES
CLEAN ARCHITECTURE SERIES
SOLID Principles in Python: The Senior Playbook
To master the system, you must first master its boundaries. In production-scale systems, applying SOLID isn't about dogmatic academic rules. It is about managing cognitive load, isolating failure domains, and ensuring codebases evolve without cascading regression failures.
⏳ Context: When working in Python—a highly dynamic, multi-paradigm language—SOLID architecture looks different than it does in compiled, static languages like Java or C++. We leverage structural subtyping (protocols), runtime decoupling, and dynamic interfaces while maintaining strict type safety to write code that scales with team size and infrastructure demands.
1. Single-Responsibility Principle (SRP)
The single-responsibility principle states that "a class should have only one reason to change." In the real world, this is a measure of cohesion. If a single class contains database serialization logic, business validations, and email delivery rules, a database schema modification risks breaking your notification routing system.
By splitting state from behavioral services, we isolate distinct axes of change and make unit testing highly direct and mock-free.
from dataclasses import dataclass from decimal import Decimal # 1. State Representation (Encapsulates data validation invariants) @dataclass(frozen=True) class Order: order_id: str total_amount: Decimal customer_email: str def __post_init__(self): if self.total_amount <= Decimal("0.00"): raise ValueError("Order total must be greater than zero.") # 2. Storage Handler Service (Responsible only for DB writes) class OrderRepository: def save(self, order: Order) -> None: print(f"Saving order {order.order_id} to database...") # 3. Notification Service (Responsible only for communications) class NotificationEngine: def send_receipt(self, order: Order) -> None: print(f"Sending receipt to {order.customer_email}...")
2. Open-Closed Principle (OCP)
Software modules must be "open for extension, but closed for modification." If supporting a new delivery method or a third-party gateway forces you to open your core transaction engine and write an elif gateway == "paypal": block, your system violates OCP.
In Python, we satisfy this beautifully using typing.Protocol, allowing polymorphism to seamlessly route requests to newly compiled extensions without mutating legacy orchestrators.
from typing import Protocol # Open for extension: anyone can implement this protocol class PaymentStrategy(Protocol): def charge(self, amount: Decimal) -> str: ... # Closed for modification: Core engine requires no changes for new gateways class CheckoutService: def __init__(self, processor: PaymentStrategy): self.processor = processor def complete_checkout(self, total: Decimal) -> None: tx_id = self.processor.charge(total) print(f"Checkout completed successfully. TX: {tx_id}") # Concrete Extensions can be declared anywhere without altering CheckoutService class StripePayment: def charge(self, amount: Decimal) -> str: return f"stripe_charge_{amount}"
"The master architect structures the void, not by adding walls, but by securing the thresholds.
When interfaces are clean and contracts are absolute, details may shift endlessly, yet the core remains untouched."
— Meditations on Clean Architecture
3. Liskov Substitution Principle (LSP)
Subtypes must be completely substitutable for their parent types. If a subclass narrows the input parameters, yields unexpected empty defaults (e.g. returning None instead of a collection), or leaks unique, unhandled errors, it violates LSP.
A classic violation in Python occurs when wrapping external SDK clients. If one gateway raises a Stripe-specific error up the call stack, calling layers must declare defensive try-except blocks specific to that subclass, destroying polymorphism.
class PaymentFailed(Exception): pass class BaseGateway: def pay(self, amount: Decimal) -> None: raise NotImplementedError class AdyenGateway(BaseGateway): def pay(self, amount: Decimal) -> None: try: # Low-level network operations here... raise ConnectionResetError("Adyen dropped connection") except Exception as err: # LSP preserved: translate low level library errors to Domain Exceptions raise PaymentFailed("Adyen transaction aborted") from err
4. Interface Segregation Principle (ISP)
Clients should not be forced to depend upon interfaces they do not use. Massive, monolithic interfaces force subclasses to implement empty placeholder logic or raise a NotImplementedError, indicating bad structural design.
In Python, we achieve ISP by declaring small, laser-focused Protocols (often with a single method) and relying on structural composition if a composite interface is required.
class Readable(Protocol): def read(self) -> bytes: ... class Writable(Protocol): def write(self, data: bytes) -> None: ... # Composite Interface: Constructed simply by merging lean protocols class ReadWritable(Readable, Writable, Protocol): pass # Read-only utility depends ONLY on the Readable interface class DataProcessor: def __init__(self, source: Readable): self.source = source def parse(self) -> str: raw_data = self.source.read() return raw_data.decode("utf-8")
5. Dependency Inversion Principle (DIP)
High-level business policies should not depend on low-level utility implementations. Both should depend on abstractions. Tightly coupling orchestrators directly to specific database drivers or network clients introduces extreme friction during updates or test suites execution.
By injecting interface protocols during application bootstrap, we decouple high-level workflows from infrastructure details entirely.
# Abstraction defined inside the High-Level Application boundary class DataStore(Protocol): def retrieve_data(self) -> dict: ... # High-level Orchestrator depends strictly on the abstraction class AnalyticsDashboard: def __init__(self, store: DataStore): self.store = store def render(self) -> None: payload = self.store.retrieve_data() print(f"Rendering dashboard metrics with: {payload}") # Low-level detail implements the high-level contract class PostgresDataStore: def retrieve_data(self) -> dict: return {"metric_a": 100.50, "metric_b": 200.12}
🛠️ SOLID Synthesis: The Pragmatic Architect
A Senior Developer understands when *not* to over-engineer. Dogmatically implementing all five principles on a single-file, short-lived script will only inflate cognitive overhead and complexity without yielding value. Apply abstractions when you recognize a second concrete implementation emerging, or when the cost of testing mixed-concern classes begins to decelerate your deployment pipelines.
🔥 PRO UPGRADE / TEASERWe have conquered structural clean code at the class level. Next, we will bridge these boundaries across microservices. Tomorrow, we dissect the performance and patterns of asynchronous message brokers. Welcome to Day 15: The Event-Driven Frontier.
Comments
Post a Comment
?: "90px"' frameborder='0' id='comment-editor' name='comment-editor' src='' width='100%'/>