Principles of Software Design: The Complete Guide¶
Every senior engineer you admire follows a set of principles — not rules written in a style guide, but instincts built from hard experience. They look at code and immediately feel whether something is wrong, even before they can articulate why.
Those instincts are learnable. They're codified in the principles this guide covers.
Software design principles are heuristics — guidelines, not laws. No principle applies in every situation. But together they form a vocabulary for talking about code quality and a compass for navigating trade-offs. Internalize them, and you'll spend less time untangling the past and more time building the future.
Four layers, each building on the last:
- Foundational — timeless principles every developer should know cold
- SOLID — the five object-oriented design principles, explained with before/after code
- Advanced — deeper patterns that separate good from great code
- Cloud-Native — distributed systems principles for modern architectures
Why Design Principles Matter¶
Bad code doesn't happen because developers are careless. It happens because software is hard — requirements change, deadlines compress, and complexity compounds invisibly. Without principles, every shortcut makes the next task harder. With them, you build systems that stay maintainable under pressure.
The cost of ignoring design principles is real and measurable:
THE COMPOUNDING COST OF TECHNICAL DEBT
Week 1: Feature takes 2 days to build (shortcuts taken)
Week 4: Similar feature takes 3 days (must work around week 1 shortcuts)
Week 12: Bug hunt takes 1 week (nobody understands the code anymore)
Week 24: Refactor the whole module (the shortcuts own you now)
Following principles:
Week 1: Feature takes 3 days (principled implementation)
Week 4: Similar feature takes 1 day (reuses clean abstractions)
Week 12: Bug fixed in hours (code is understandable and testable)
Week 24: New feature added cleanly (the design absorbs change)
The principles in this guide are how you stay on the right side of that curve.
Part 1: The Foundational Principles¶
These four principles predate SOLID and underpin everything else. They apply at every level — function, class, module, system.
1. KISS — Keep It Simple, Stupid¶
"Make everything as simple as possible, but not simpler." — Albert Einstein
Complexity is the enemy of software. Every unnecessary complexity layer is a bug waiting to happen, a junior engineer who can't contribute, and a 3am incident you'll never fully understand.
KISS says: choose the simplest solution that correctly solves the problem.
The Complexity Traps¶
# TRAP 1: Over-engineering a simple problem
# BAD: building a factory + strategy + registry for something that needs an if/else
class DiscountStrategyRegistry:
_strategies: dict[str, 'DiscountStrategy'] = {}
@classmethod
def register(cls, name: str, strategy: 'DiscountStrategy') -> None:
cls._strategies[name] = strategy
@classmethod
def get(cls, name: str) -> 'DiscountStrategy':
if name not in cls._strategies:
raise KeyError(f"Unknown discount strategy: {name}")
return cls._strategies[name]
class DiscountStrategy(ABC):
@abstractmethod
def apply(self, price: float) -> float: ...
class TenPercentDiscount(DiscountStrategy):
def apply(self, price: float) -> float:
return price * 0.90
class TwentyPercentDiscount(DiscountStrategy):
def apply(self, price: float) -> float:
return price * 0.80
# 4 classes, 30 lines to do what this does:
# GOOD: simple and clear — if requirements grow, refactor then
def apply_discount(price: float, discount_type: str) -> float:
discounts = {"ten_percent": 0.90, "twenty_percent": 0.80}
multiplier = discounts.get(discount_type, 1.0)
return price * multiplier
# TRAP 2: Clever one-liners that nobody can read
# BAD: "clever" but unreadable
result = [x for x in (lambda l: (sorted(l), l)[1])(data)
if (lambda v: v > 0 and v % 2 == 0)(x)]
# GOOD: readable — anyone can understand this in 5 seconds
def get_positive_even_numbers(numbers: list[int]) -> list[int]:
return [n for n in numbers if n > 0 and n % 2 == 0]
result = get_positive_even_numbers(data)
How to Apply KISS¶
Before writing complex code, ask: "What is the simplest thing that could possibly work?" Build that. Complexity is added later when the simple solution proves genuinely insufficient — not speculatively, in advance.
The Rule of Three for abstraction
Don't abstract until you have three concrete cases. One case: just write it. Two cases: maybe a helper. Three cases: now you understand the pattern well enough to abstract it correctly.
2. DRY — Don't Repeat Yourself¶
"Every piece of knowledge must have a single, unambiguous, authoritative representation within a system." — Andy Hunt & Dave Thomas, The Pragmatic Programmer
DRY is about knowledge duplication, not just code duplication. When the same logic exists in two places, a change requirement means changing both — and you will forget to change one.
The Hidden Cost of Duplication¶
# BAD: The shipping cost logic duplicated in two places
class OrderService:
def calculate_order_total(self, order: Order) -> float:
subtotal = sum(item.price * item.quantity for item in order.items)
# Shipping logic here...
if order.destination_country != "US":
shipping = subtotal * 0.15 # 15% for international
elif subtotal >= 50:
shipping = 0 # free domestic over $50
else:
shipping = 5.99 # flat rate domestic
return subtotal + shipping
class QuoteService:
def generate_shipping_quote(self, cart: Cart) -> float:
subtotal = sum(item.price * item.quantity for item in cart.items)
# SAME shipping logic duplicated — if the rules change,
# will you remember to update BOTH places?
if cart.destination_country != "US":
shipping = subtotal * 0.15
elif subtotal >= 50:
shipping = 0
else:
shipping = 5.99
return shipping
# GOOD: One authoritative source for shipping logic
class ShippingCalculator:
INTERNATIONAL_RATE = 0.15
FREE_DOMESTIC_THRESHOLD = 50.00
FLAT_DOMESTIC_RATE = 5.99
def calculate(self, subtotal: float, destination_country: str) -> float:
if destination_country != "US":
return subtotal * self.INTERNATIONAL_RATE
if subtotal >= self.FREE_DOMESTIC_THRESHOLD:
return 0.0
return self.FLAT_DOMESTIC_RATE
class OrderService:
def __init__(self, shipping: ShippingCalculator):
self._shipping = shipping
def calculate_order_total(self, order: Order) -> float:
subtotal = sum(item.price * item.quantity for item in order.items)
return subtotal + self._shipping.calculate(subtotal, order.destination_country)
class QuoteService:
def __init__(self, shipping: ShippingCalculator):
self._shipping = shipping
def generate_shipping_quote(self, cart: Cart) -> float:
subtotal = sum(item.price * item.quantity for item in cart.items)
return self._shipping.calculate(subtotal, cart.destination_country)
Now when shipping rules change (and they will), there is exactly one place to change.
DRY Is About Knowledge, Not Code¶
Two nearly identical code blocks are not necessarily a DRY violation if they represent different knowledge:
# This is NOT a DRY violation — same structure, different knowledge
def validate_username(username: str) -> bool:
return len(username) >= 3 and username.isalnum()
def validate_product_code(code: str) -> bool:
return len(code) >= 3 and code.isalnum()
These happen to have the same structure today. But username validation and product code validation are separate business rules that will diverge. Merging them into a generic validate_alphanumeric_min_3 function couples two unrelated concepts.
WET code vs accidental DRY
WET = "Write Everything Twice." The cost of WET code is maintenance burden. But aggressive deduplication of code that represents different domain concepts creates wrong abstractions — and wrong abstractions are worse than duplication. As Sandi Metz said: "Duplication is far cheaper than the wrong abstraction."
3. YAGNI — You Aren't Gonna Need It¶
"Always implement things when you actually need them, never when you just foresee that you need them." — Ron Jeffries
YAGNI is a discipline: do not add functionality until it is required. Build for today's requirements, not tomorrow's hypothetical ones.
The Speculative Generality Problem¶
# BAD: building a plugin system "just in case" we need extensibility
class NotificationService:
def __init__(self):
self._channels: dict[str, 'NotificationChannel'] = {}
self._formatters: dict[str, 'MessageFormatter'] = {}
self._rate_limiters: dict[str, 'RateLimiter'] = {}
self._fallback_chain: list[str] = []
# ... 200 more lines of infrastructure for requirements that don't exist
def register_channel(self, name: str, channel: 'NotificationChannel') -> None: ...
def register_formatter(self, name: str, formatter: 'MessageFormatter') -> None: ...
def send(self, user_id: str, message: str, channel: str = "default") -> None: ...
# etc.
# GOOD: build what you need, refactor when requirements actually arrive
class NotificationService:
def __init__(self, smtp_client: SmtpClient):
self._smtp = smtp_client
def send_email(self, to: str, subject: str, body: str) -> None:
self._smtp.send(to=to, subject=subject, body=body)
When you need SMS: add it then. When you need a plugin system: add it when the third channel is required. Right now, email is the requirement. Build email.
Why YAGNI Gets Violated¶
- Fear: "If I don't build it now, I'll regret it later"
- Ego: "A real architect would make this extensible"
- Past trauma: "Last time I didn't do this, I had to rewrite everything"
The antidote: code that is clean and well-tested is easy to refactor. The cost of adding something later is lower than the cost of carrying unneeded complexity now.
The YAGNI / SOLID balance
YAGNI and the Open/Closed Principle seem to conflict. OCP says "design for extension." YAGNI says "don't design speculatively."
The resolution: use OCP patterns where you know extension is needed (because the requirements show it), or where extension points are cheap to add. Don't apply OCP speculatively to every class.
4. Separation of Concerns (SoC)¶
"The art of programming is the art of organizing complexity." — Edsger Dijkstra
A concern is any distinct aspect of a system's functionality. Separation of Concerns says: keep different concerns in different places, so they can be understood, developed, and changed independently.
# BAD: one function does everything — business logic, data access, formatting
def process_order(order_id: str) -> str:
# Data access concern
conn = psycopg2.connect("postgresql://...")
cursor = conn.cursor()
cursor.execute("SELECT * FROM orders WHERE id = %s", (order_id,))
row = cursor.fetchone()
# Business logic concern
total = row['subtotal']
if row['membership'] == 'gold':
total *= 0.90
if row['country'] != 'US':
total += total * 0.15
# Persistence concern
cursor.execute(
"UPDATE orders SET total = %s, status = 'processed' WHERE id = %s",
(total, order_id)
)
conn.commit()
# Formatting / presentation concern
return f"<html><body>Order {order_id}: ${total:.2f} processed</body></html>"
When requirements change (and they always do), where do you make the change? You have to read everything. A change to discount logic risks breaking the database query. A change to HTML formatting is buried in business logic.
# GOOD: each concern in its own layer
# Data access layer
class OrderRepository:
def find_by_id(self, order_id: str) -> Order: ...
def save(self, order: Order) -> None: ...
# Business logic layer (domain)
class OrderProcessor:
def __init__(self, shipping: ShippingCalculator, membership: MembershipService):
self._shipping = shipping
self._membership = membership
def process(self, order: Order) -> Order:
discount = self._membership.get_discount(order.customer_id)
shipping = self._shipping.calculate(order.subtotal, order.country)
order.apply_discount(discount)
order.set_shipping(shipping)
order.mark_processed()
return order
# Presentation layer
class OrderSerializer:
def to_html(self, order: Order) -> str:
return f"<html><body>Order {order.id}: ${order.total:.2f} processed</body></html>"
def to_json(self, order: Order) -> dict:
return {"order_id": order.id, "total": order.total, "status": order.status}
Each layer can now be changed, tested, and replaced independently.
5. High Cohesion, Low Coupling¶
These two concepts are the structural foundation of good software architecture.
Cohesion measures how strongly related the responsibilities within a module are. High cohesion means a module does one thing and does it completely.
Coupling measures how much a module depends on other modules. Low coupling means changes in one module have minimal impact on others.
THE IDEAL:
HIGH COHESION: LOW COUPLING:
Everything inside a module Modules depend on each other
is related to one concept. as little as possible.
┌────────────────┐ ┌─────┐ ┌─────┐
│ UserAccount │ │ A │ ───────→ │ B │
│ │ └─────┘ thin └─────┘
│ - authenticate │ interface
│ - changePassword│
│ - resetToken │ NOT:
│ - verifyEmail │
│ │ ┌─────┐ ┌─────┐
│ (all about │ │ A │◄────────►│ B │
│ one account) │ └─────┘ tightly └─────┘
└────────────────┘ tangled
A class that handles user authentication, sends emails, generates PDF reports, and queries the database has low cohesion — it does everything. A class with high cohesion does exactly one thing well.
Part 2: SOLID — Five Principles of Object-Oriented Design¶
SOLID is an acronym coined by Robert C. Martin ("Uncle Bob") for five principles that make object-oriented designs more understandable, flexible, and maintainable.
S — Single Responsibility Principle
O — Open/Closed Principle
L — Liskov Substitution Principle
I — Interface Segregation Principle
D — Dependency Inversion Principle
S — Single Responsibility Principle (SRP)¶
"A class should have one, and only one, reason to change."
The key word is reason to change. A class has one responsibility if it would only need to change for one kind of business reason.
The Test: Two Different "Bosses"¶
If two different people in your organization could legitimately ask you to change a class, it has more than one responsibility.
# BAD: this class has THREE reasons to change
class UserReport:
def __init__(self, db):
self._db = db
def get_user_data(self, user_id: str) -> dict:
# Database team could ask to change this
return self._db.query("SELECT * FROM users WHERE id = ?", user_id)
def format_report(self, user_data: dict) -> str:
# Design/product team could ask to change this
return f"User Report\n{'='*40}\n" + "\n".join(
f"{k}: {v}" for k, v in user_data.items()
)
def send_report(self, email: str, report: str) -> None:
# Infrastructure/ops team could ask to change this
smtp = smtplib.SMTP("smtp.company.com")
smtp.sendmail("reports@company.com", email, report)
Three bosses: the DBA who controls query structure, the product team that controls report format, the ops team that controls email delivery. One change request → changes in the same class that could break the others.
# GOOD: one reason to change each
class UserRepository:
"""Only reason to change: how we store/retrieve users."""
def __init__(self, db):
self._db = db
def find_by_id(self, user_id: str) -> dict:
return self._db.query("SELECT * FROM users WHERE id = ?", user_id)
class UserReportFormatter:
"""Only reason to change: how the report looks."""
def format(self, user_data: dict) -> str:
return f"User Report\n{'='*40}\n" + "\n".join(
f"{k}: {v}" for k, v in user_data.items()
)
class EmailSender:
"""Only reason to change: how we send email."""
def __init__(self, smtp_host: str):
self._smtp_host = smtp_host
def send(self, to: str, subject: str, body: str) -> None:
smtp = smtplib.SMTP(self._smtp_host)
smtp.sendmail("reports@company.com", to, body)
# The orchestrator — its only job is to coordinate
class UserReportService:
def __init__(self, repo: UserRepository, formatter: UserReportFormatter,
sender: EmailSender):
self._repo = repo
self._formatter = formatter
self._sender = sender
def send_user_report(self, user_id: str, recipient_email: str) -> None:
user_data = self._repo.find_by_id(user_id)
report = self._formatter.format(user_data)
self._sender.send(recipient_email, "User Report", report)
SRP ≠ one method per class
SRP is about one reason to change, not one function. A ShoppingCart class can have add_item, remove_item, apply_coupon, and calculate_total — all of these are aspects of one responsibility: managing the cart's contents.
O — Open/Closed Principle (OCP)¶
"Software entities should be open for extension, but closed for modification."
When new requirements arrive, you should be able to add new behavior without changing existing, working code. Existing code is tested, trusted, and in production. Every change to it is a risk.
The Smell: Growing if/elif Chains¶
# BAD: adding a new shape requires modifying this function
def calculate_area(shape: dict) -> float:
if shape['type'] == 'circle':
return math.pi * shape['radius'] ** 2
elif shape['type'] == 'rectangle':
return shape['width'] * shape['height']
elif shape['type'] == 'triangle':
return 0.5 * shape['base'] * shape['height']
# Every new shape means modifying this function — opening it to bugs
elif shape['type'] == 'pentagon':
...
# GOOD: open for extension (add new shapes), closed for modification
from abc import ABC, abstractmethod
import math
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius
def area(self) -> float:
return math.pi * self.radius ** 2
class Rectangle(Shape):
def __init__(self, width: float, height: float):
self.width = width
self.height = height
def area(self) -> float:
return self.width * self.height
class Triangle(Shape):
def __init__(self, base: float, height: float):
self.base = base
self.height = height
def area(self) -> float:
return 0.5 * self.base * self.height
# Adding Pentagon requires ZERO changes to existing code:
class Pentagon(Shape):
def __init__(self, side: float):
self.side = side
def area(self) -> float:
return (self.side ** 2 * math.sqrt(25 + 10 * math.sqrt(5))) / 4
def calculate_total_area(shapes: list[Shape]) -> float:
return sum(shape.area() for shape in shapes)
OCP is realized through polymorphism and abstraction — design against interfaces/abstract classes, and new implementations can be added without touching the existing code.
A Real-World OCP Example: Payment Processing¶
# BAD: adding PayPal or Apple Pay means modifying PaymentProcessor
class PaymentProcessor:
def process(self, amount: float, method: str, details: dict) -> bool:
if method == "credit_card":
return self._charge_stripe(amount, details['card_token'])
elif method == "bank_transfer":
return self._initiate_ach(amount, details['account_number'])
# Adding PayPal: modify this class and risk breaking Stripe + ACH
# GOOD: each payment method is a separate, addable implementation
class PaymentGateway(ABC):
@abstractmethod
def process(self, amount: float, details: dict) -> PaymentResult: ...
class StripeGateway(PaymentGateway):
def process(self, amount: float, details: dict) -> PaymentResult:
# Stripe-specific logic
...
class ACHGateway(PaymentGateway):
def process(self, amount: float, details: dict) -> PaymentResult:
# ACH-specific logic
...
class PayPalGateway(PaymentGateway): # Added without modifying anything above
def process(self, amount: float, details: dict) -> PaymentResult:
# PayPal-specific logic
...
class PaymentProcessor:
def __init__(self, gateway: PaymentGateway): # depends on abstraction
self._gateway = gateway
def charge(self, amount: float, details: dict) -> PaymentResult:
return self._gateway.process(amount, details)
L — Liskov Substitution Principle (LSP)¶
"Objects of a subtype must be substitutable for objects of their supertype without breaking the program." — Barbara Liskov, 1987
If class B extends class A, then anywhere you use an A, you should be able to use a B and the program should still work correctly. If substituting B breaks anything, the inheritance relationship is wrong.
The Classic Violation: The Square-Rectangle Problem¶
# BAD: Square extends Rectangle — but violates LSP
class Rectangle:
def __init__(self, width: float, height: float):
self.width = width
self.height = height
def area(self) -> float:
return self.width * self.height
class Square(Rectangle):
def __init__(self, side: float):
super().__init__(side, side)
# "Square" must override setters to keep width == height
@Rectangle.width.setter
def width(self, value: float):
self._width = value
self._height = value # forces height to match
@Rectangle.height.setter
def height(self, value: float):
self._height = value
self._width = value # forces width to match
# This function works correctly with Rectangle
def double_width(shape: Rectangle) -> float:
shape.width *= 2
return shape.area() # expects: width * height (unchanged)
r = Rectangle(4, 5)
print(double_width(r)) # 40.0 ✅ correct
s = Square(4)
print(double_width(s)) # 64.0 ❌ expected 40.0 — LSP violated!
# Substituting Square for Rectangle broke the function
# GOOD: Square and Rectangle share an interface, not an inheritance chain
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Rectangle(Shape):
def __init__(self, width: float, height: float):
self._width = width
self._height = height
def area(self) -> float:
return self._width * self._height
class Square(Shape):
def __init__(self, side: float):
self._side = side
def area(self) -> float:
return self._side ** 2
The Bird Example¶
# BAD: Penguin can't fly — inheriting from Bird violates LSP
class Bird:
def fly(self) -> str:
return "I am flying!"
class Penguin(Bird):
def fly(self) -> str:
raise NotImplementedError("Penguins can't fly!") # LSP violation
def make_bird_fly(bird: Bird) -> str:
return bird.fly() # breaks when called with Penguin
# GOOD: model what birds actually have in common
class Bird(ABC):
@abstractmethod
def move(self) -> str: ...
class FlyingBird(Bird):
def move(self) -> str:
return "flying"
def fly(self) -> str:
return "I am flying!"
class SwimmingBird(Bird):
def move(self) -> str:
return "swimming"
class Eagle(FlyingBird): ...
class Duck(FlyingBird): ... # ducks can also swim — add SwimmingBird mixin
class Penguin(SwimmingBird): ...
LSP Check: The "Is-A" Test vs The "Behaves-As" Test¶
The standard OOP test is: "Is a Penguin a Bird?" — Yes. So inheritance feels right. But LSP demands the stronger test: "Does a Penguin behave as a Bird in every context where Bird is used?" — No. So inheritance is wrong.
Always apply the behavioral test, not just the conceptual one.
I — Interface Segregation Principle (ISP)¶
"No client should be forced to depend on methods it does not use."
Fat interfaces force implementors to provide implementations for methods they don't need, often with empty stubs or raise NotImplementedError() — which is a silent LSP violation waiting to happen.
The Multi-Function Device Problem¶
# BAD: one giant interface — not every device can do everything
class MultifunctionDevice(ABC):
@abstractmethod
def print_document(self, doc: Document) -> None: ...
@abstractmethod
def scan_document(self) -> Document: ...
@abstractmethod
def send_fax(self, doc: Document, number: str) -> None: ...
@abstractmethod
def copy_document(self, doc: Document) -> None: ...
class OldPrinter(MultifunctionDevice):
def print_document(self, doc: Document) -> None:
# actual implementation
...
def scan_document(self) -> Document:
raise NotImplementedError("This printer cannot scan") # forced stub
def send_fax(self, doc: Document, number: str) -> None:
raise NotImplementedError("This printer cannot fax") # forced stub
def copy_document(self, doc: Document) -> None:
raise NotImplementedError("This printer cannot copy") # forced stub
# GOOD: small, specific interfaces — implement only what you can do
class Printer(ABC):
@abstractmethod
def print_document(self, doc: Document) -> None: ...
class Scanner(ABC):
@abstractmethod
def scan_document(self) -> Document: ...
class Fax(ABC):
@abstractmethod
def send_fax(self, doc: Document, number: str) -> None: ...
class OldPrinter(Printer):
def print_document(self, doc: Document) -> None:
# prints
...
class ModernMFD(Printer, Scanner, Fax):
def print_document(self, doc: Document) -> None: ...
def scan_document(self) -> Document: ...
def send_fax(self, doc: Document, number: str) -> None: ...
ISP in API Design (Python Protocols / TypeScript Interfaces)¶
# Python Protocol — structural typing, no explicit inheritance needed
from typing import Protocol
class Readable(Protocol):
def read(self, n: int = -1) -> bytes: ...
class Writable(Protocol):
def write(self, data: bytes) -> int: ...
class Seekable(Protocol):
def seek(self, pos: int) -> int: ...
def tell(self) -> int: ...
# Functions depend only on what they actually need
def compress(source: Readable, dest: Writable) -> None:
# only needs read and write — not seek
...
def copy_section(source: Readable & Seekable, dest: Writable,
start: int, length: int) -> None:
# needs read AND seek
source.seek(start)
data = source.read(length)
dest.write(data)
D — Dependency Inversion Principle (DIP)¶
"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."
This is the most architecturally significant SOLID principle. It's the principle that enables testability, replaceability, and loose coupling.
Dependency Direction: The Wrong Way and Right Way¶
WITHOUT DIP (coupled to implementation):
OrderService ──depends on──→ PostgresDatabase
(high-level) (low-level detail)
Change PostgreSQL → Change OrderService
Test OrderService → Need PostgreSQL running
Switch to MongoDB → Rewrite OrderService
WITH DIP (coupled to abstraction):
OrderService ──depends on──→ OrderRepository (interface)
(high-level) (abstraction)
▲ ▲
│ │
└──────────────────────────────┘
PostgresOrderRepository (detail)
MongoOrderRepository (detail)
InMemoryOrderRepository (for tests)
# BAD: high-level module directly depends on low-level module
class OrderService:
def __init__(self):
# Hard dependency on specific implementation
self._db = psycopg2.connect("postgresql://localhost/orders")
self._emailer = smtplib.SMTP("smtp.sendgrid.net")
def place_order(self, cart: Cart) -> str:
# Uses _db directly — impossible to test without PostgreSQL
cursor = self._db.cursor()
cursor.execute("INSERT INTO orders ...")
order_id = cursor.fetchone()[0]
self._emailer.sendmail(...)
return order_id
# GOOD: both high-level and low-level depend on abstractions
from abc import ABC, abstractmethod
# Abstractions (the boundary)
class OrderRepository(ABC):
@abstractmethod
def save(self, order: Order) -> str: ...
class EmailService(ABC):
@abstractmethod
def send_confirmation(self, order: Order, recipient: str) -> None: ...
# High-level module — depends only on abstractions
class OrderService:
def __init__(self, order_repo: OrderRepository, email_service: EmailService):
self._orders = order_repo
self._email = email_service
def place_order(self, cart: Cart, customer_email: str) -> str:
order = Order.from_cart(cart)
order_id = self._orders.save(order)
self._email.send_confirmation(order, customer_email)
return order_id
# Low-level implementations — depend on abstractions too
class PostgresOrderRepository(OrderRepository):
def __init__(self, connection_string: str):
self._conn = psycopg2.connect(connection_string)
def save(self, order: Order) -> str:
# PostgreSQL-specific implementation
...
class SendGridEmailService(EmailService):
def send_confirmation(self, order: Order, recipient: str) -> None:
# SendGrid-specific implementation
...
# For tests — no real database or email needed:
class InMemoryOrderRepository(OrderRepository):
def __init__(self):
self._store: dict[str, Order] = {}
def save(self, order: Order) -> str:
self._store[order.id] = order
return order.id
class FakeEmailService(EmailService):
def __init__(self):
self.sent_confirmations: list[tuple[Order, str]] = []
def send_confirmation(self, order: Order, recipient: str) -> None:
self.sent_confirmations.append((order, recipient))
# Tests are now fast, isolated, and reliable
def test_place_order_sends_confirmation_email():
repo = InMemoryOrderRepository()
email = FakeEmailService()
service = OrderService(repo, email)
cart = Cart.with_items([CartItem("Book A", 29.99)])
order_id = service.place_order(cart, "customer@example.com")
assert order_id is not None
assert len(email.sent_confirmations) == 1
assert email.sent_confirmations[0][1] == "customer@example.com"
Part 3: Advanced Principles¶
Law of Demeter — Talk Only to Your Friends¶
"Only talk to your immediate friends."
Also called the Principle of Least Knowledge, the Law of Demeter says a method should only call methods on: 1. Itself 2. Its own fields 3. Objects passed as parameters 4. Objects it creates locally
It should not reach through intermediate objects to call methods on their internals.
# BAD: violates Law of Demeter — "train wreck" chaining
class OrderService:
def notify_customer(self, order: Order) -> None:
# Reaching through order → customer → contact_info → email
# OrderService now depends on: Order, Customer, ContactInfo, Email
email = order.get_customer().get_contact_info().get_primary_email().get_address()
self._email_client.send(email, "Your order is ready")
# GOOD: tell Order to handle the notification, or pass email directly
class Order:
def get_customer_email(self) -> str:
return self._customer.primary_email # encapsulated internally
class OrderService:
def notify_customer(self, order: Order) -> None:
email = order.get_customer_email() # one level deep
self._email_client.send(email, "Your order is ready")
The "train wreck" (a.b().c().d()) is a Law of Demeter smell. It couples you to the entire chain of objects — a change anywhere in the chain breaks you.
Composition over Inheritance¶
"Favor object composition over class inheritance." — Gang of Four, Design Patterns
Inheritance creates rigid, brittle hierarchies. Composition creates flexible, changeable behavior.
# BAD: inheritance hierarchy that gets tangled fast
class Animal:
def breathe(self): ...
class Pet(Animal):
def be_trained(self): ...
class Dog(Pet):
def bark(self): ...
class RobotDog(???): # Robot dogs don't breathe — now what?
# Can't extend Animal without getting breathe()
def bark(self): ...
# GOOD: compose behaviors from smaller pieces
class Barker:
def bark(self) -> str:
return "Woof!"
class Trainer:
def train(self, command: str) -> str:
return f"Learning: {command}"
class Breather:
def breathe(self) -> str:
return "Inhale... exhale..."
class Dog:
def __init__(self):
self._barker = Barker()
self._trainer = Trainer()
self._breather = Breather()
def bark(self) -> str: return self._barker.bark()
def train(self, cmd: str) -> str: return self._trainer.train(cmd)
def breathe(self) -> str: return self._breather.breathe()
class RobotDog:
def __init__(self):
self._barker = Barker() # shares Barker behavior
self._trainer = Trainer() # shares training behavior
# No Breather — robots don't breathe. No problem.
def bark(self) -> str: return self._barker.bark()
def train(self, cmd: str) -> str: return self._trainer.train(cmd)
Composition gives you fine-grained control over what behaviors an object has — you pick exactly what you need. Inheritance forces you to accept everything in the parent chain.
When Inheritance IS the Right Tool¶
Use inheritance when: - There is a genuine is-a relationship that is stable and unlikely to change - You want to share a default implementation across a family of related types - You're using a template method pattern intentionally
Inheritance is most appropriate for framework hooks and stable taxonomies. For flexible domain behavior, prefer composition.
Command Query Separation (CQS)¶
"Every method should either be a Command (changes state) or a Query (returns data), but never both." — Bertrand Meyer
CQS makes code reasoning much simpler: calling a query method is always safe (no side effects). Calling a command changes state but tells you nothing.
# BAD: method both changes state AND returns data
class ShoppingCart:
def remove_item_and_get_total(self, item_id: str) -> float:
self._items = [i for i in self._items if i.id != item_id]
return sum(i.price for i in self._items) # mutation + query mixed
# BAD: "is_valid" sounds like a query but has side effects
class Form:
def is_valid(self) -> bool:
result = self._validate()
self._save_validation_errors() # hidden side effect!
self._send_validation_event() # caller is surprised
return result
# GOOD: separate commands from queries
class ShoppingCart:
def remove_item(self, item_id: str) -> None: # Command: changes state
self._items = [i for i in self._items if i.id != item_id]
def total(self) -> float: # Query: reads state
return sum(i.price for i in self._items)
class Form:
def validate(self) -> None: # Command: runs validation
self._errors = self._run_validation()
def is_valid(self) -> bool: # Query: reads errors
return len(self._errors) == 0
def errors(self) -> list[str]: # Query: reads errors
return list(self._errors)
The benefit: a developer reading cart.total() knows with certainty it changes nothing. A developer reading cart.remove_item() knows it changes state. No surprises.
Tell, Don't Ask¶
"Tell objects what to do; don't ask them for their data and then act on it yourself."
This principle is about putting behavior where the data is. If you're asking an object for its state just to make a decision that the object itself should make, you're violating encapsulation.
# BAD: asking for data, making the decision externally
class OrderProcessor:
def apply_discount(self, customer: Customer, order: Order) -> None:
# Asking customer for its data, making the decision here
if customer.membership_tier == "gold":
if customer.total_purchases > 1000:
order.discount = 0.20
else:
order.discount = 0.10
elif customer.membership_tier == "silver":
order.discount = 0.05
# GOOD: tell the customer to determine its own discount
class Customer:
def applicable_discount(self) -> float:
if self.membership_tier == "gold":
return 0.20 if self.total_purchases > 1000 else 0.10
if self.membership_tier == "silver":
return 0.05
return 0.0
class OrderProcessor:
def apply_discount(self, customer: Customer, order: Order) -> None:
order.apply_discount(customer.applicable_discount()) # tell, don't ask
The discount logic now lives where it belongs: in the Customer class, next to the data it operates on. If the discount rules change, there's one place to change.
Fail Fast¶
"Report errors as soon as possible after they occur."
Fail Fast says: validate inputs at the earliest possible moment and raise errors immediately when something is wrong. Don't let invalid state propagate deep into the system where the error is disconnected from its cause.
# BAD: fails late — error is thrown deep in the call stack
def process_payment(amount, currency, user_id):
# No validation here...
payment = Payment(amount=amount, currency=currency)
user = UserService.get(user_id) # fails here if user_id is None
# If we got here with amount=-100, we'll find out later in billing
PaymentGateway.charge(user.payment_method, payment)
# GOOD: fails fast — errors caught at the entry point
def process_payment(amount: float, currency: str, user_id: str) -> None:
# Validate at the boundary before doing anything
if amount <= 0:
raise ValueError(f"Payment amount must be positive, got: {amount}")
if len(currency) != 3:
raise ValueError(f"Currency must be ISO 3-letter code, got: {currency}")
if not user_id:
raise ValueError("user_id is required")
payment = Payment(amount=amount, currency=currency)
user = UserService.get(user_id)
PaymentGateway.charge(user.payment_method, payment)
Fail Fast is especially important in constructors and value objects — don't let an object be created in an invalid state:
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def __post_init__(self):
# Fail fast in the constructor — no invalid Money objects can exist
if self.amount < 0:
raise ValueError(f"Money amount cannot be negative: {self.amount}")
if len(self.currency) != 3:
raise ValueError(f"Currency must be 3-letter ISO code: {self.currency}")
The Boy Scout Rule¶
"Always leave the campground cleaner than you found it." — Robert C. Martin
Every time you touch a piece of code, leave it slightly better than you found it. Fix the confusing variable name. Extract the 50-line function. Add the missing test.
The Boy Scout Rule is the practical answer to "how do you improve a messy codebase without a big rewrite?" You don't rewrite — you continuously improve as you go.
# You're adding a new feature to calculate_invoice()
# You notice: confusing variable names, magic numbers, no tests
# BEFORE (what you found)
def calculate_invoice(c, items, d=False):
t = 0
for i in items:
t += i[0] * i[1]
if d:
t = t * 0.85
if t > 500:
t = t - 25
return t
# AFTER (what you leave — naming, constants, clarity)
BULK_ORDER_THRESHOLD = 500.00
BULK_ORDER_DISCOUNT = 25.00
MEMBER_DISCOUNT_RATE = 0.15
def calculate_invoice(
customer_id: str,
line_items: list[tuple[float, int]], # (unit_price, quantity)
is_member: bool = False
) -> float:
subtotal = sum(unit_price * quantity for unit_price, quantity in line_items)
if is_member:
subtotal *= (1 - MEMBER_DISCOUNT_RATE)
if subtotal > BULK_ORDER_THRESHOLD:
subtotal -= BULK_ORDER_DISCOUNT
return subtotal
Not a rewrite. Just a cleaner campsite. Repeat this on every touch, and messy codebases gradually become manageable.
Part 4: Principles for Cloud-Native and Distributed Systems¶
Classical design principles were written for single-process, in-memory systems. Cloud-native architectures introduce new failure modes — network partitions, partial failures, eventual consistency — that need their own principles.
Design for Failure¶
"Everything fails, all the time." — Werner Vogels, CTO of Amazon
In a distributed system, any network call can fail, any service can be slow, any dependency can be unavailable. Design for it from the start.
# BAD: assumes the external service always responds
class ProductService:
def get_price(self, product_id: str) -> float:
response = requests.get(f"{CATALOG_URL}/products/{product_id}")
return response.json()['price'] # what if this times out? what if catalog is down?
# GOOD: circuit breaker + fallback + timeout
import tenacity
from circuitbreaker import circuit
class ProductService:
def __init__(self, catalog_client: CatalogClient, cache: Cache):
self._catalog = catalog_client
self._cache = cache
@circuit(failure_threshold=5, recovery_timeout=30)
@tenacity.retry(
stop=tenacity.stop_after_attempt(3),
wait=tenacity.wait_exponential(min=1, max=8),
retry=tenacity.retry_if_exception_type(RequestException),
)
def get_price(self, product_id: str) -> float:
try:
price = self._catalog.get_price(product_id)
self._cache.set(f"price:{product_id}", price, ttl=300)
return price
except (RequestException, Timeout):
# Fallback: serve cached value if catalog is unavailable
cached = self._cache.get(f"price:{product_id}")
if cached is not None:
return cached
raise # propagate if no fallback available
Design for Observability¶
A service you can't observe is a black box. When it fails at 3am, you need to know what failed, why, and when.
Build three capabilities in from day one:
# Logs + Metrics + Traces — the observability triad
import structlog
from opentelemetry import trace, metrics
import time
log = structlog.get_logger()
tracer = trace.get_tracer("order-service")
meter = metrics.get_meter("order-service")
# Metrics
orders_placed = meter.create_counter("orders.placed.total")
order_duration = meter.create_histogram("orders.duration.ms")
order_errors = meter.create_counter("orders.errors.total")
def place_order(cart_id: str, customer_id: str) -> str:
start = time.time()
with tracer.start_as_current_span("place_order") as span:
span.set_attribute("cart.id", cart_id)
span.set_attribute("customer.id", customer_id)
log.info("placing_order", cart_id=cart_id, customer_id=customer_id)
try:
order = _do_place_order(cart_id, customer_id)
orders_placed.add(1, {"status": "success"})
log.info("order_placed", order_id=order.id, total=str(order.total))
span.set_attribute("order.id", order.id)
return order.id
except Exception as e:
order_errors.add(1, {"error_type": type(e).__name__})
log.error("order_failed", cart_id=cart_id, error=str(e))
span.record_exception(e)
raise
finally:
duration_ms = (time.time() - start) * 1000
order_duration.record(duration_ms)
log.info("order_duration", duration_ms=duration_ms)
Idempotency — Safe to Retry¶
In distributed systems, operations are retried on failure. An idempotent operation produces the same result whether executed once or ten times.
# BAD: double-processing an order charges the customer twice
class OrderProcessor:
def process_payment(self, order_id: str, amount: float) -> None:
stripe.Charge.create(amount=int(amount * 100), currency="usd")
# If this method is called twice (retry after network error),
# customer is charged twice. 😱
# GOOD: idempotency key prevents double-charging
class OrderProcessor:
def process_payment(self, order_id: str, amount: float) -> str:
# order_id as idempotency key: same order_id = same charge
# Stripe deduplicates on the idempotency key
charge = stripe.Charge.create(
amount=int(amount * 100),
currency="usd",
idempotency_key=f"order-payment-{order_id}", # safe to retry
)
return charge.id
# Database upsert as idempotency pattern
def upsert_inventory_reservation(order_id: str, product_id: str, qty: int) -> None:
db.execute("""
INSERT INTO inventory_reservations (order_id, product_id, quantity)
VALUES (:order_id, :product_id, :qty)
ON CONFLICT (order_id, product_id)
DO UPDATE SET quantity = :qty, updated_at = NOW()
""", {"order_id": order_id, "product_id": product_id, "qty": qty})
# Safe to call multiple times — same result every time
Stateless Services¶
Stateless services hold no session state in memory between requests. Any request can be handled by any instance — which is what enables horizontal scaling.
# BAD: server-side session state — sticky sessions required
class OrderService:
_user_sessions: dict = {} # In-memory state — dies on restart
def start_checkout(self, user_id: str, cart: dict) -> str:
session_id = str(uuid4())
self._user_sessions[session_id] = cart # stored in THIS instance only
return session_id
def complete_checkout(self, session_id: str) -> None:
cart = self._user_sessions.get(session_id) # only works on same instance
if not cart:
raise SessionNotFoundError(session_id)
# GOOD: externalized state — any instance can serve any request
class OrderService:
def __init__(self, session_store: Redis):
self._sessions = session_store
def start_checkout(self, user_id: str, cart: dict) -> str:
session_id = str(uuid4())
self._sessions.setex(
f"checkout:{session_id}",
3600,
json.dumps(cart)
) # stored in Redis — any pod can read it
return session_id
def complete_checkout(self, session_id: str) -> None:
data = self._sessions.get(f"checkout:{session_id}")
if not data:
raise SessionNotFoundError(session_id)
cart = json.loads(data)
# ... process order from cart
Putting It All Together: The Principles Map¶
The principles don't exist in isolation — they reinforce each other. Here's how they connect:
FOUNDATIONAL
┌─────────────────────────────────────────────────────────────┐
│ KISS → enables → DRY → enables → SoC → enables → Cohesion │
│ YAGNI + Coupling │
└─────────────────────────────────────────────────────────────┘
↓ builds on
SOLID
┌─────────────────────────────────────────────────────────────┐
│ SRP (clear responsibility) │
│ OCP (stable abstractions) ←── DIP (depend on abstractions│
│ LSP (correct inheritance) │
│ ISP (lean interfaces) │
└─────────────────────────────────────────────────────────────┘
↓ refined by
ADVANCED
┌─────────────────────────────────────────────────────────────┐
│ LoD (minimize knowledge) CQS (clear intent) │
│ Composition (flexibility) Tell Don't Ask (encapsulation) │
│ Fail Fast (early errors) Boy Scout (continuous care) │
└─────────────────────────────────────────────────────────────┘
↓ extended for
CLOUD-NATIVE
┌─────────────────────────────────────────────────────────────┐
│ Design for Failure Idempotency │
│ Design for Observability Stateless Services │
└─────────────────────────────────────────────────────────────┘
Quick Reference — All Principles at a Glance¶
| Principle | In One Line | The Smell It Fixes |
|---|---|---|
| KISS | Simplest solution that works | Over-engineering, abstraction for its own sake |
| DRY | One authoritative source for each piece of knowledge | Copy-paste code, scattered logic |
| YAGNI | Build for today, not hypothetical tomorrow | Unused features, speculative generality |
| SoC | Different concerns in different places | God classes, layered spaghetti |
| High Cohesion | Everything in a module is related | Classes that do unrelated things |
| Low Coupling | Modules depend on each other minimally | Ripple effect — one change breaks everything |
| SRP | One reason to change | Classes with multiple bosses |
| OCP | Extend without modifying | Growing if/elif chains |
| LSP | Subtypes behave like their parent | raise NotImplementedError in subclass |
| ISP | Lean interfaces, no forced stubs | Fat interfaces with unused methods |
| DIP | Depend on abstractions, not implementations | Untestable code, tight vendor coupling |
| Law of Demeter | Only talk to immediate friends | Train-wreck method chains |
| Composition | Compose behavior, don't inherit it | Deep inheritance hierarchies |
| CQS | Commands change state; queries return data | Methods that do both |
| Tell, Don't Ask | Tell objects what to do, don't interrogate them | Feature envy — logic outside its data |
| Fail Fast | Validate early, fail loudly | Errors far from their cause |
| Boy Scout Rule | Leave code cleaner than you found it | Perpetually messy legacy code |
| Design for Failure | Assume everything fails, handle it | Unhandled timeouts and cascading failures |
| Observability | Logs + metrics + traces from day one | "We have no idea what's happening" |
| Idempotency | Same result if called once or ten times | Double-charges, duplicate records |
| Stateless | No in-memory state between requests | Sticky sessions, can't scale |
How to Apply These Principles in Practice¶
Principles can become a checklist that paralyzes rather than guides. Here's how to use them productively:
During Design (Before Writing Code)¶
Ask: - What is the single responsibility of this component? - What will change independently? Separate those things. - What abstractions does this depend on? Can I test it without the real implementation? - Am I building something I actually need today?
During Code Review¶
Look for: - Methods that are too long → likely violating SRP or SoC - if/elif chains that grow with new types → likely violating OCP - Constructors with many parameters → likely violating SRP or DIP - Direct instantiation of external services → likely violating DIP - Methods that both change state and return data → CQS violation - Deep method chains (a.b().c().d()) → Law of Demeter violation
During Refactoring¶
Follow the Boy Scout Rule: one small improvement per touch. You don't need to refactor the whole class to apply SRP. Extract one method. Rename one variable. Add one test. The codebase improves continuously.
When Principles Conflict¶
Principles sometimes point in different directions. A few resolutions:
- KISS vs OCP: When requirements are stable, KISS wins. When extension is a known need, OCP wins.
- DRY vs SoC: Don't merge two separate concerns just because they look similar. Prefer SoC over aggressive deduplication.
- YAGNI vs DIP: Always apply DIP — testability is not speculative. But don't add abstraction layers you don't need yet.
The principles are heuristics, not laws. The goal is always code that is clear, correct, testable, and adaptable to change — use the principles as tools toward that goal, not as ends in themselves.
Summary¶
| Principle | One Rule | Red Flag |
|---|---|---|
| KISS | Prefer the simpler solution | Class you'd need 10 minutes to explain |
| DRY | One authoritative source per piece of knowledge | Same logic copy-pasted in three places |
| YAGNI | Don't build what you don't need today | Abstractions with no current caller |
| SRP | One reason to change | Constructor with 6+ parameters |
| OCP | Extend without modifying | if/elif chain that grows with every new type |
| LSP | Subtypes behave like their parent | raise NotImplementedError in a subclass method |
| ISP | Lean interfaces, no forced stubs | Interface with methods only half the implementors use |
| DIP | Depend on abstractions, not implementations | UserService directly imports psycopg2 |
| Law of Demeter | Only talk to immediate friends | a.getB().getC().doThing() |
| Composition | Compose behavior, don't inherit it | 5-level deep inheritance hierarchy |
| CQS | Commands change state; queries return data | Method that saves to DB and returns the saved row |
| Fail Fast | Validate at the boundary, loudly | Null check buried 10 calls deep |
| Design for Failure | Assume everything fails | HTTP call with no timeout |
| Idempotency | Same result called once or ten times | Charge that fires on retry |
When in doubt, apply SRP first — it drives almost every other good design decision downstream.
The best book for going deeper on these principles: Clean Code and Clean Architecture by Robert C. Martin, The Pragmatic Programmer by Hunt & Thomas, and A Philosophy of Software Design by John Ousterhout — four books that will permanently change how you think about code.
Questions or discussion? Connect on LinkedIn, X or reach out via email.
Discussion
Have thoughts on this post? Share them below — questions, corrections, or your own experience are all welcome.