Beginner
This beginner level covers Examples 1-28, reaching approximately 0-35% of software architecture fundamentals. Each example demonstrates a core architectural concept using Python or TypeScript with self-contained, runnable code. These examples target developers who already know at least one language and want to rapidly build architectural instincts through working code.
Separation of Concerns
Example 1: No Separation vs. Clear Separation
Separation of concerns means grouping code by responsibility so that each module handles exactly one aspect of the system. When multiple responsibilities mix in one function, changing any part risks breaking the others.
Tightly coupled approach (no separation):
# => This function handles THREE distinct responsibilities at once:
# => 1. Data access (reading from a dict)
# => 2. Business logic (computing discount)
# => 3. Presentation (formatting a string for display)
def get_user_discount_message(user_id: int) -> str:
# => user_db simulates a database lookup — data access concern
user_db = {1: {"name": "Alice", "purchases": 12}}
user = user_db.get(user_id) # => user is {"name": "Alice", "purchases": 12}
# => Business rule embedded directly here — hard to change independently
if user and user["purchases"] > 10:
discount = 0.15 # => discount is 0.15 (15%)
else:
discount = 0.05 # => discount is 0.05 (5%)
# => Presentation formatted inline — impossible to reuse discount logic elsewhere
return f"Hello {user['name']}, your discount is {discount * 100:.0f}%"
# => Output: "Hello Alice, your discount is 15%"Mixing all three responsibilities means any change — a new discount rule, a different greeting format, or a different data source — requires editing the same function.
Separated approach (three distinct layers):
# => DATA ACCESS — only knows how to retrieve users
def find_user(user_id: int) -> dict | None:
user_db = {1: {"name": "Alice", "purchases": 12}}
return user_db.get(user_id) # => returns dict or None
# => BUSINESS LOGIC — only knows discount rules, not storage or display
def calculate_discount(purchases: int) -> float:
if purchases > 10:
return 0.15 # => 15% for loyal customers (>10 purchases)
return 0.05 # => 5% default discount
# => PRESENTATION — only knows how to format, not how to compute or fetch
def format_discount_message(name: str, discount: float) -> str:
return f"Hello {name}, your discount is {discount * 100:.0f}%"
# => Output: "Hello Alice, your discount is 15%"
# => ORCHESTRATION — thin coordinator that wires the three layers together
def get_user_discount_message(user_id: int) -> str:
user = find_user(user_id) # => delegates data access
discount = calculate_discount(user["purchases"]) # => delegates business rule
return format_discount_message(user["name"], discount) # => delegates formattingEach function now has one reason to change: swap the database without touching the discount rule, change the discount formula without touching the message format.
Key Takeaway: Separate each distinct responsibility into its own function or module. A function should have exactly one reason to change.
Why It Matters: In production systems, business rules change far more often than data storage technology, and display formats change more often than both. When these concerns are mixed, a simple business rule change forces a full regression test of the display layer. Netflix, Stripe, and other high-velocity engineering teams build their systems so each layer evolves independently, enabling hundreds of safe deployments per day.
Example 2: Single Responsibility Principle
The Single Responsibility Principle (SRP) states that a class or module should have one and only one reason to change. Violating SRP creates fragile code where an unrelated change breaks a seemingly unrelated feature.
Violating SRP — one class does too much:
// => UserManager handles user data AND email sending AND password logic
// => This class has THREE reasons to change: user schema, email templates, auth rules
class UserManager {
private users: Map<number, { name: string; email: string }> = new Map();
// => users stores id → { name, email }
addUser(id: number, name: string, email: string): void {
this.users.set(id, { name, email }); // => stores user under id key
}
// => EMAIL CONCERN embedded in user class — mixing responsibilities
sendWelcomeEmail(userId: number): void {
const user = this.users.get(userId); // => retrieves user or undefined
if (user) {
console.log(`Sending email to ${user.email}: Welcome, ${user.name}!`);
// => Output: Sending email to alice@example.com: Welcome, Alice!
}
}
// => PASSWORD CONCERN also embedded — a third responsibility
resetPassword(userId: number): string {
const newPassword = Math.random().toString(36).slice(2, 10);
// => newPassword is a random 8-char string like "k3m9p2ax"
console.log(`Password reset for user ${userId}: ${newPassword}`);
return newPassword; // => returns the new password string
}
}Applying SRP — one class, one responsibility:
// => RESPONSIBILITY 1: User data management only
class UserRepository {
private users: Map<number, { name: string; email: string }> = new Map();
add(id: number, name: string, email: string): void {
this.users.set(id, { name, email }); // => stores user record
}
get(id: number): { name: string; email: string } | undefined {
return this.users.get(id); // => returns user or undefined
}
}
// => RESPONSIBILITY 2: Email notifications only
class EmailService {
sendWelcome(name: string, email: string): void {
console.log(`Sending email to ${email}: Welcome, ${name}!`);
// => Output: Sending email to alice@example.com: Welcome, Alice!
}
}
// => RESPONSIBILITY 3: Password management only
class PasswordService {
reset(userId: number): string {
const newPassword = Math.random().toString(36).slice(2, 10);
// => newPassword is a random 8-char string
console.log(`Password reset for user ${userId}: ${newPassword}`);
return newPassword; // => returns the generated password
}
}Key Takeaway: Each class should have exactly one reason to change. When you add email template logic, only EmailService changes. When you change password policy, only PasswordService changes.
Why It Matters: SRP is the foundational principle behind microservices: each service owns one business capability. Amazon’s transition from monolith to service-oriented architecture succeeded by applying SRP at the service level. Teams that own single-responsibility services deploy independently, reducing the coordination overhead that kills engineering velocity at scale.
Layered Architecture
Example 3: Three-Layer Architecture
A layered architecture organizes code into a presentation layer (handles user interaction), a business logic layer (enforces rules), and a data access layer (manages persistence). Layers only communicate downward — presentation calls business logic, business logic calls data access, never the reverse.
graph TD
A["Presentation Layer<br/>(Controller / API handler)"]
B["Business Logic Layer<br/>(Service / Use case)"]
C["Data Access Layer<br/>(Repository / DAO)"]
A -->|calls| B
B -->|calls| C
C -->|returns data| B
B -->|returns result| A
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
# ============================================================
# DATA ACCESS LAYER — only knows about storage
# ============================================================
class ProductRepository:
def __init__(self) -> None:
# => in-memory store simulating a database table
self._products = {
1: {"id": 1, "name": "Laptop", "price": 1200.0, "stock": 5},
2: {"id": 2, "name": "Mouse", "price": 25.0, "stock": 0},
}
def find_by_id(self, product_id: int) -> dict | None:
return self._products.get(product_id)
# => returns product dict or None if not found
# ============================================================
# BUSINESS LOGIC LAYER — only knows about rules
# ============================================================
class ProductService:
def __init__(self, repo: "ProductRepository") -> None:
self._repo = repo # => stores repository reference (dependency injection)
def get_available_product(self, product_id: int) -> dict:
product = self._repo.find_by_id(product_id)
# => delegates data retrieval to the repository
if product is None:
raise ValueError(f"Product {product_id} not found")
# => raises ValueError — presentation layer will catch this
if product["stock"] == 0:
raise ValueError(f"Product '{product['name']}' is out of stock")
# => business rule: zero stock means unavailable
return product # => returns valid product dict
# ============================================================
# PRESENTATION LAYER — only knows about formatting responses
# ============================================================
def handle_get_product(product_id: int) -> str:
repo = ProductRepository() # => creates data layer
service = ProductService(repo) # => creates business layer, injects repo
try:
product = service.get_available_product(product_id)
# => delegates all business logic to service
return f"Available: {product['name']} at ${product['price']:.2f}"
# => Output (id=1): "Available: Laptop at $1200.00"
except ValueError as e:
return f"Error: {e}"
# => Output (id=2): "Error: Product 'Mouse' is out of stock"
print(handle_get_product(1)) # => Available: Laptop at $1200.00
print(handle_get_product(2)) # => Error: Product 'Mouse' is out of stockKey Takeaway: Each layer communicates only with the layer directly below it. Presentation never touches the database; data access never formats strings for users.
Why It Matters: Layered architecture is the default starting pattern for most enterprise systems (Spring MVC, Django, Rails all enforce it). It enables parallel development — a frontend team can build against an agreed service API while a backend team implements the business rules — and makes testing each layer independently straightforward.
Example 4: Presentation Layer Isolation
The presentation layer should translate raw input into domain calls and translate domain results into output format. It should contain no business logic and no data access code.
// => DATA LAYER — retrieves raw records
const orderDb: Record<number, { id: number; total: number; status: string }> = {
101: { id: 101, total: 299.99, status: "shipped" },
102: { id: 102, total: 49.0, status: "pending" },
};
function findOrder(id: number): { id: number; total: number; status: string } | null {
return orderDb[id] ?? null; // => returns order record or null
}
// => BUSINESS LAYER — applies domain rules
function isOrderEligibleForCancellation(order: { total: number; status: string }): boolean {
return order.status === "pending" && order.total < 500;
// => true only when pending AND total below cancellation threshold
}
// => PRESENTATION LAYER — translates, never decides
function handleCancelRequest(orderId: number): string {
const order = findOrder(orderId); // => fetches from data layer
if (!order) {
return `Order ${orderId} not found`; // => presentation transforms null to message
}
const eligible = isOrderEligibleForCancellation(order);
// => business logic evaluated in business layer, result consumed here
if (eligible) {
return `Order ${orderId} cancelled successfully`;
// => Output (id=102): "Order 102 cancelled successfully"
}
return `Order ${orderId} cannot be cancelled (status: ${order.status})`;
// => Output (id=101): "Order 101 cannot be cancelled (status: shipped)"
}
console.log(handleCancelRequest(101)); // => Order 101 cannot be cancelled (status: shipped)
console.log(handleCancelRequest(102)); // => Order 102 cancelled successfully
console.log(handleCancelRequest(999)); // => Order 999 not found
Key Takeaway: The presentation layer transforms but never decides. All decisions live in the business layer where they can be tested without a UI or HTTP context.
Why It Matters: Teams that keep business logic out of controllers can test their entire rule set with fast in-memory unit tests. When the presentation layer grows (mobile app, CLI tool, REST API), the business layer requires zero modification, which is exactly what allows organizations like Shopify to serve multiple client types from a single codebase.
MVC Pattern
Example 5: Model-View-Controller Basics
MVC separates a program into a Model (data and rules), a View (formatting output), and a Controller (coordinating input and response). The Controller receives input, asks the Model to process it, then passes results to the View for display.
graph LR
User["User Input"]
C["Controller<br/>(coordinates)"]
M["Model<br/>(data + rules)"]
V["View<br/>(formats output)"]
User -->|request| C
C -->|queries / commands| M
M -->|data| C
C -->|data| V
V -->|response| User
style User fill:#CA9161,stroke:#000,color:#fff
style C fill:#0173B2,stroke:#000,color:#fff
style M fill:#DE8F05,stroke:#000,color:#fff
style V fill:#029E73,stroke:#000,color:#fff
# ============================================================
# MODEL — data container + business validation
# ============================================================
class TodoModel:
def __init__(self) -> None:
self._items: list[dict] = [] # => internal list of todo dicts
self._next_id = 1 # => auto-incrementing id counter
def add(self, title: str) -> dict:
item = {"id": self._next_id, "title": title, "done": False}
# => item is {"id": 1, "title": "Buy milk", "done": False}
self._items.append(item) # => appended to internal list
self._next_id += 1 # => next_id is now 2
return item # => returns the created item
def get_all(self) -> list[dict]:
return list(self._items) # => returns shallow copy to prevent mutation
def complete(self, item_id: int) -> bool:
for item in self._items:
if item["id"] == item_id:
item["done"] = True # => marks item as completed
return True # => returns True = success
return False # => returns False = item not found
# ============================================================
# VIEW — formats data for display, no logic
# ============================================================
class TodoView:
def render_list(self, items: list[dict]) -> str:
if not items:
return "No todos yet." # => Output when empty list
lines = []
for item in items:
status = "✓" if item["done"] else "○"
# => status is "✓" for done items, "○" for pending
lines.append(f"[{status}] {item['id']}. {item['title']}")
return "\n".join(lines) # => joined with newlines
def render_created(self, item: dict) -> str:
return f"Created todo #{item['id']}: {item['title']}"
# => Output: "Created todo #1: Buy milk"
# ============================================================
# CONTROLLER — coordinates model and view
# ============================================================
class TodoController:
def __init__(self, model: TodoModel, view: TodoView) -> None:
self._model = model # => stores model reference
self._view = view # => stores view reference
def create(self, title: str) -> str:
item = self._model.add(title) # => delegates creation to model
return self._view.render_created(item) # => delegates formatting to view
def list_all(self) -> str:
items = self._model.get_all() # => fetches all items from model
return self._view.render_list(items) # => delegates rendering to view
def done(self, item_id: int) -> str:
success = self._model.complete(item_id) # => delegates completion to model
if success:
return f"Todo #{item_id} marked as done"
return f"Todo #{item_id} not found"
# => Wire the MVC triad together
model = TodoModel()
view = TodoView()
controller = TodoController(model, view)
print(controller.create("Buy milk")) # => Created todo #1: Buy milk
print(controller.create("Write tests")) # => Created todo #2: Write tests
print(controller.done(1)) # => Todo #1 marked as done
print(controller.list_all())
# => [✓] 1. Buy milk
# => [○] 2. Write testsKey Takeaway: The Controller handles input and coordinates. The Model owns data and rules. The View formats output. None of these three should reach into the others’ domain.
Why It Matters: MVC is the backbone of virtually every web framework (Rails, Django, Laravel, Spring MVC, ASP.NET). Understanding the pure form of MVC lets you debug framework issues quickly and extend frameworks correctly without fighting the intended structure.
Example 6: Model Encapsulates Validation
The Model is responsible for enforcing its own invariants. If validation logic leaks into controllers or views, the same rule must be duplicated everywhere the data is modified, creating drift over time.
// => MODEL with self-contained validation — the model is the single source of truth
class BankAccount {
private balance: number; // => private: external code cannot bypass validation
private readonly minimumBalance = 0; // => business rule: no overdrafts
constructor(initialBalance: number) {
if (initialBalance < 0) {
throw new Error("Initial balance cannot be negative");
// => enforced at construction time, not by the caller
}
this.balance = initialBalance; // => balance is initialBalance (e.g., 100)
}
deposit(amount: number): void {
if (amount <= 0) {
throw new Error("Deposit amount must be positive");
// => model rejects invalid inputs without controller involvement
}
this.balance += amount; // => balance increases by amount
}
withdraw(amount: number): void {
if (amount <= 0) {
throw new Error("Withdrawal amount must be positive");
}
if (this.balance - amount < this.minimumBalance) {
throw new Error(`Insufficient funds: balance is ${this.balance}`);
// => business rule enforced in model, not leaked to controller
}
this.balance -= amount; // => balance decreases by amount
}
getBalance(): number {
return this.balance; // => read-only access to private state
}
}
// => CONTROLLER — delegates entirely to model, no duplicate validation
function handleWithdraw(account: BankAccount, amount: number): string {
try {
account.withdraw(amount); // => model enforces all rules
return `Withdrawal successful. Balance: $${account.getBalance()}`;
// => Output: "Withdrawal successful. Balance: $50"
} catch (e) {
return `Withdrawal failed: ${(e as Error).message}`;
// => Output: "Withdrawal failed: Insufficient funds: balance is 50"
}
}
const account = new BankAccount(100); // => account.balance is 100
console.log(handleWithdraw(account, 50)); // => Withdrawal successful. Balance: $50
console.log(handleWithdraw(account, 200)); // => Withdrawal failed: Insufficient funds: balance is 50
Key Takeaway: Models that enforce their own invariants are impossible to put into invalid states, regardless of which controller or API endpoint calls them.
Why It Matters: Domain model integrity is the first line of defense against data corruption in production. When the model does not enforce its own rules, invariants get enforced inconsistently across controllers, background jobs, and admin scripts — eventually leading to database records that violate business rules, which are notoriously expensive to clean up.
Dependency Injection
Example 7: Manual Dependency Injection
Dependency injection means passing dependencies into an object rather than creating them inside it. The object declares what it needs; the caller decides what to provide. This makes the object testable and reusable across different contexts.
graph TD
Caller["Caller / Composition Root"]
DB["Database (real or fake)"]
Svc["UserService"]
Caller -->|creates and passes| DB
Caller -->|creates and injects DB| Svc
Svc -->|uses injected DB| DB
style Caller fill:#0173B2,stroke:#000,color:#fff
style DB fill:#029E73,stroke:#000,color:#fff
style Svc fill:#DE8F05,stroke:#000,color:#fff
Without dependency injection (hard to test):
# => UserService creates its own database connection — cannot be tested without real DB
class HardcodedUserService:
def __init__(self) -> None:
# => hardcoded dependency: impossible to substitute a fake in tests
self._db = {"data": {1: "Alice", 2: "Bob"}}
def get_name(self, user_id: int) -> str | None:
return self._db["data"].get(user_id) # => returns name or NoneWith dependency injection (easy to test with any backend):
# => PROTOCOL defines what UserService needs from storage
from typing import Protocol
class UserStore(Protocol):
def fetch(self, user_id: int) -> str | None: ...
# => any object with fetch(int) -> str | None satisfies this protocol
# => REAL implementation for production
class DictUserStore:
def __init__(self) -> None:
self._data = {1: "Alice", 2: "Bob"} # => simulates a real DB
def fetch(self, user_id: int) -> str | None:
return self._data.get(user_id) # => returns name or None
# => FAKE implementation for tests — no DB required
class FakeUserStore:
def fetch(self, user_id: int) -> str | None:
return "TestUser" # => always returns a fixed value for testing
# => SERVICE accepts any UserStore — decoupled from specific implementation
class UserService:
def __init__(self, store: UserStore) -> None:
self._store = store # => stores injected dependency
def greet(self, user_id: int) -> str:
name = self._store.fetch(user_id) # => delegates to whatever store was injected
if name is None:
return f"User {user_id} not found"
return f"Hello, {name}!" # => Output: "Hello, Alice!"
# => PRODUCTION: inject real store
service = UserService(DictUserStore())
print(service.greet(1)) # => Hello, Alice!
print(service.greet(99)) # => User 99 not found
# => TEST: inject fake store — no database needed
test_service = UserService(FakeUserStore())
print(test_service.greet(1)) # => Hello, TestUser!Key Takeaway: Inject dependencies from the outside rather than creating them inside. The service only knows the interface it needs, not which implementation provides it.
Why It Matters: Dependency injection is the foundation of testable architectures. Google’s Guice and Java’s Spring DI container both exist to automate what this example does manually. Applications built with DI reach 90%+ test coverage more easily because every dependency can be substituted with a fast in-memory fake.
Example 8: Constructor Injection vs. Method Injection
There are two common styles of dependency injection: constructor injection (dependencies passed when the object is created) and method injection (dependencies passed per-call). Constructor injection is the default for stable dependencies; method injection suits per-request context like loggers or user sessions.
// => CONSTRUCTOR INJECTION — dependency lives for the object's lifetime
// => Use when: dependency is always required and does not change per call
class OrderProcessor {
private paymentGateway: { charge: (amount: number) => boolean };
// => paymentGateway is stored for the lifetime of OrderProcessor
constructor(paymentGateway: { charge: (amount: number) => boolean }) {
this.paymentGateway = paymentGateway; // => injected at construction time
}
processOrder(orderId: number, amount: number): string {
const success = this.paymentGateway.charge(amount);
// => uses the injected gateway — no knowledge of which gateway is used
return success ? `Order ${orderId} paid ($${amount})` : `Order ${orderId} payment failed`;
}
}
// => METHOD INJECTION — dependency passed per call
// => Use when: dependency varies per request (e.g., per-user logger, per-request context)
class AuditLogger {
log(
message: string,
output: { write: (msg: string) => void }, // => injected per call
): void {
output.write(`[AUDIT] ${message}`);
// => output can be console, file, database — determined by caller
}
}
// => USAGE: wire concrete implementations at the composition root
const fakeGateway = { charge: (amount: number) => amount < 1000 };
// => fakeGateway returns true for amounts < 1000 (simulates approval limit)
const processor = new OrderProcessor(fakeGateway);
console.log(processor.processOrder(1, 500)); // => Order 1 paid ($500)
console.log(processor.processOrder(2, 1500)); // => Order 2 payment failed
const logger = new AuditLogger();
logger.log("User login", { write: (msg) => console.log(msg) });
// => Output: [AUDIT] User login
logger.log("File export", { write: (msg) => console.log(`>> ${msg}`) });
// => Output: >> [AUDIT] File export
Key Takeaway: Prefer constructor injection for required, stable dependencies. Use method injection when the dependency varies per invocation.
Why It Matters: Constructor injection makes dependencies explicit and visible in the object’s contract, eliminating invisible global state. Method injection powers extensible APIs like Express middleware and Python decorators, enabling plug-in architectures where behavior can be augmented without modifying the core class.
Interface Segregation
Example 9: Interface Segregation Principle
The Interface Segregation Principle says that classes should not be forced to implement methods they do not use. A fat interface forces every implementor to carry unused methods. Split fat interfaces into focused ones, and implementors pick only what they need.
Fat interface — forces implementors to define methods they do not need:
// => FAT interface: all implementors must provide ALL four methods
interface EmployeeOperations {
calculateSalary(): number; // => relevant for paid employees
clockIn(): void; // => relevant for hourly workers
generateReport(): string; // => relevant for managers
requestLeave(days: number): void; // => relevant for all employees
}
// => CONTRACTOR only needs salary calculation, yet must implement everything
class Contractor implements EmployeeOperations {
calculateSalary(): number {
return 500;
} // => useful
clockIn(): void {
/* not applicable */
} // => forced but meaningless
generateReport(): string {
return "";
} // => forced but meaningless
requestLeave(days: number): void {} // => forced but meaningless
}Segregated interfaces — each implementor picks only what applies:
// => FOCUSED interfaces: each covers one capability
interface Payable {
calculateSalary(): number; // => pay calculation
}
interface Trackable {
clockIn(): void; // => time tracking
}
interface Reportable {
generateReport(): string; // => reporting
}
interface LeaveEligible {
requestLeave(days: number): void; // => leave management
}
// => CONTRACTOR: only salary matters, no forced empty methods
class SegregatedContractor implements Payable {
calculateSalary(): number {
return 500; // => flat daily rate
}
}
// => FULL_TIME EMPLOYEE: salary + time tracking + leave
class FullTimeEmployee implements Payable, Trackable, LeaveEligible {
private hoursWorked = 0; // => tracks hours for this period
calculateSalary(): number {
return this.hoursWorked * 25; // => $25/hour
}
clockIn(): void {
this.hoursWorked += 8; // => adds one full work day
}
requestLeave(days: number): void {
console.log(`Leave requested: ${days} day(s)`);
// => Output: Leave requested: 5 day(s)
}
}
// => MANAGER: salary + reporting
class Manager implements Payable, Reportable {
calculateSalary(): number {
return 8000;
} // => fixed monthly salary
generateReport(): string {
return "Team performance: on track"; // => manager-specific report
}
}
const contractor = new SegregatedContractor();
console.log(contractor.calculateSalary()); // => 500
const emp = new FullTimeEmployee();
emp.clockIn();
console.log(emp.calculateSalary()); // => 200 (8 hours * $25)
Key Takeaway: Split interfaces by cohesive capability, not by the most complex implementor. Implementors import only the interfaces they genuinely fulfill.
Why It Matters: Interface segregation is why TypeScript’s structural typing enables clean plugin architectures and why Java’s standard library separated Readable, Writable, Closeable, and Flushable. Applications that ignore ISP accumulate “empty method” implementations that silently succeed or throw UnsupportedOperationException, creating subtle production bugs.
Open/Closed Principle
Example 10: Open for Extension, Closed for Modification
The Open/Closed Principle states that a class should be open for extension (new behaviors can be added) but closed for modification (existing code does not change when behavior is added). Achieving this typically involves polymorphism or strategy objects.
graph TD
Client["Client Code"]
Base["Discount Strategy<br/>(interface/protocol)"]
A["RegularDiscount"]
B["LoyaltyDiscount"]
C["SeasonalDiscount"]
Client -->|uses| Base
Base -->|implemented by| A
Base -->|implemented by| B
Base -->|implemented by| C
style Client fill:#0173B2,stroke:#000,color:#fff
style Base fill:#DE8F05,stroke:#000,color:#fff
style A fill:#029E73,stroke:#000,color:#fff
style B fill:#029E73,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
Closed approach — requires modifying existing code for every new discount:
# => VIOLATION: adding a new discount type requires editing this function
def calculate_discount(price: float, discount_type: str) -> float:
if discount_type == "regular":
return price * 0.10 # => 10% off
elif discount_type == "loyalty":
return price * 0.20 # => 20% off for loyal customers
# => Every new discount type requires adding another elif here
# => This is the "open for modification" anti-pattern
return 0.0Open/Closed approach — extend by adding new classes, never editing existing ones:
from abc import ABC, abstractmethod
# => ABSTRACT BASE — defines the contract, never changes
class DiscountStrategy(ABC):
@abstractmethod
def calculate(self, price: float) -> float: ...
# => all strategies must implement calculate(price) -> float
# => CONCRETE STRATEGIES — add new ones without touching existing code
class RegularDiscount(DiscountStrategy):
def calculate(self, price: float) -> float:
return price * 0.10 # => 10% discount
class LoyaltyDiscount(DiscountStrategy):
def calculate(self, price: float) -> float:
return price * 0.20 # => 20% discount for loyal customers
# => EXTENSION: new discount type — existing code is UNTOUCHED
class SeasonalDiscount(DiscountStrategy):
def calculate(self, price: float) -> float:
return price * 0.30 # => 30% seasonal sale discount
# => CLIENT: depends on the abstract base, not concrete classes
class PriceCalculator:
def __init__(self, strategy: DiscountStrategy) -> None:
self._strategy = strategy # => stores injected strategy
def final_price(self, price: float) -> float:
discount = self._strategy.calculate(price)
# => delegates discount computation to injected strategy
return price - discount # => price minus discount amount
calc = PriceCalculator(RegularDiscount())
print(calc.final_price(100.0)) # => 90.0 (100 - 10)
calc2 = PriceCalculator(SeasonalDiscount())
print(calc2.final_price(100.0)) # => 70.0 (100 - 30)Key Takeaway: Depend on abstractions and inject concrete strategies from outside. Adding new behavior means writing a new class, not modifying existing ones.
Why It Matters: The Open/Closed Principle is why payment providers like Stripe expose a pluggable system: you add a new payment method by implementing an interface, not by editing their core billing engine. Applications that violate OCP accumulate feature flags and nested conditionals that make every new feature a regression risk.
Liskov Substitution Principle
Example 11: Subtypes Must Be Substitutable
The Liskov Substitution Principle (LSP) says that any subclass instance must be usable wherever its parent class is expected, without breaking the program. A subclass that overrides a method to throw an exception or weaken behavior violates LSP.
LSP violation — subclass breaks the contract:
// => BASE CLASS: Rectangle assumes independent width and height
class Rectangle {
protected width: number;
protected height: number;
constructor(width: number, height: number) {
this.width = width; // => width is set independently
this.height = height; // => height is set independently
}
setWidth(w: number): void {
this.width = w;
} // => changes only width
setHeight(h: number): void {
this.height = h;
} // => changes only height
area(): number {
return this.width * this.height;
}
}
// => SQUARE forces both dimensions equal — violates LSP
class Square extends Rectangle {
setWidth(w: number): void {
this.width = w; // => changes width
this.height = w; // => ALSO changes height — breaks Rectangle's contract
}
setHeight(h: number): void {
this.height = h; // => changes height
this.width = h; // => ALSO changes width — breaks Rectangle's contract
}
}
// => CALLER written for Rectangle — breaks silently when given Square
function testRectangle(r: Rectangle): void {
r.setWidth(5);
r.setHeight(3);
// => For a Rectangle: area should be 5 * 3 = 15
// => For a Square: area is 3 * 3 = 9 — unexpected behavior!
console.log(`Area: ${r.area()}`);
}
testRectangle(new Rectangle(1, 1)); // => Area: 15 (correct)
testRectangle(new Square(1, 1)); // => Area: 9 (violates LSP)
LSP-compliant design — use a shared interface, not inheritance:
// => SHAPE interface: defines area() contract without assuming dimension independence
interface Shape {
area(): number;
}
// => RECTANGLE: independent width and height
class ProperRectangle implements Shape {
constructor(
private width: number,
private height: number,
) {}
area(): number {
return this.width * this.height;
} // => width * height
}
// => SQUARE: single side length, no inherited width/height confusion
class ProperSquare implements Shape {
constructor(private side: number) {}
area(): number {
return this.side * this.side;
} // => side * side
}
// => CALLER: works for any Shape — LSP satisfied
function printArea(shape: Shape): void {
console.log(`Area: ${shape.area()}`);
}
printArea(new ProperRectangle(5, 3)); // => Area: 15
printArea(new ProperSquare(4)); // => Area: 16
Key Takeaway: Prefer interface composition over inheritance when subclass behavior diverges from parent behavior. Subtypes must honor the behavioral contract of the type they replace.
Why It Matters: LSP violations create runtime surprises that escape static analysis. The classic Rectangle/Square problem appears in real codebases as ReadOnlyList extends ArrayList where add() throws UnsupportedOperationException. These bugs surface in production rather than compilation, making them disproportionately expensive to diagnose.
DRY, KISS, and YAGNI
Example 12: DRY — Don’t Repeat Yourself
DRY (Don’t Repeat Yourself) means every piece of knowledge should have a single authoritative representation. Duplication is not just about copying code — it’s about having the same decision expressed in multiple places.
Violation — business rule duplicated in three places:
# => VIOLATION: the "active user" rule is duplicated in every function
# => If the rule changes (e.g., add email_verified check), all three must be updated
def send_notification(user: dict) -> None:
if user["active"] and user["age"] >= 18: # => rule duplicated here
print(f"Notifying {user['name']}")
def generate_report(user: dict) -> None:
if user["active"] and user["age"] >= 18: # => same rule repeated
print(f"Report for {user['name']}")
def allow_purchase(user: dict) -> bool:
if user["active"] and user["age"] >= 18: # => rule duplicated a third time
return True
return FalseDRY — single authoritative location for the rule:
# => SINGLE SOURCE OF TRUTH: rule defined once
def is_eligible_user(user: dict) -> bool:
return user["active"] and user["age"] >= 18
# => returns True only if active AND adult
# => changing this rule updates all three callers automatically
def send_notification(user: dict) -> None:
if is_eligible_user(user): # => delegates to single rule
print(f"Notifying {user['name']}")
# => Output: Notifying Alice
def generate_report(user: dict) -> None:
if is_eligible_user(user): # => same single rule
print(f"Report for {user['name']}")
def allow_purchase(user: dict) -> bool:
return is_eligible_user(user) # => single rule, no duplication
user = {"name": "Alice", "active": True, "age": 25}
send_notification(user) # => Notifying Alice
print(allow_purchase(user)) # => TrueKey Takeaway: Extract repeated decisions into named functions or constants. Code duplication is a symptom of knowledge duplication — fix the knowledge location, not just the syntax.
Why It Matters: The most costly bugs in production are consistency bugs where the same rule was updated in two places but not the third. DRY violations are the primary driver of those failures. Amazon’s famous “two-pizza team” service model enforces DRY at the organizational level: each business rule is owned by exactly one team and one service.
Example 13: KISS — Keep It Simple, Stupid
KISS means preferring the simplest design that satisfies the requirements. Complexity is a cost that must be justified by demonstrable benefit. Over-engineered solutions are harder to debug, test, and hand off.
Over-engineered — excessive abstraction for a simple task:
// => OVER-ENGINEERED: factory + strategy + builder for a simple greeting
interface GreetingStrategy {
greet(name: string): string;
}
class FormalGreetingStrategy implements GreetingStrategy {
greet(name: string): string {
return `Good day, ${name}.`;
}
}
class GreetingFactory {
static create(type: string): GreetingStrategy {
if (type === "formal") return new FormalGreetingStrategy();
throw new Error(`Unknown greeting type: ${type}`);
}
}
class GreetingBuilder {
private type = "formal";
withType(type: string): this {
this.type = type;
return this;
}
build(): GreetingStrategy {
return GreetingFactory.create(this.type);
}
}
// => Usage: 5 classes to print "Good day, Alice."
const greeting = new GreetingBuilder().withType("formal").build().greet("Alice");
console.log(greeting); // => Good day, Alice.
KISS — simplest solution that works:
// => SIMPLE: one function, zero ceremony, achieves the same result
function greet(name: string): string {
return `Good day, ${name}.`;
// => Output: Good day, Alice.
}
console.log(greet("Alice")); // => Good day, Alice.
// => If greeting types are needed later, add them then (YAGNI)
Key Takeaway: Add abstractions only when complexity is demonstrated, not anticipated. The second solution is easier to read, debug, test, extend, and hand off.
Why It Matters: Premature abstraction is one of the top causes of architectural debt in growing codebases. Kent Beck’s Extreme Programming research found that over-engineered systems took 2-4x longer to modify than simple ones, even when the modification aligned with the intended abstraction. Build the simplest thing, then refactor when a pattern genuinely emerges.
Example 14: YAGNI — You Aren’t Gonna Need It
YAGNI means do not add functionality until it is actually needed. Speculative features add code complexity without delivering current value, and they are often built for a requirement that never arrives in the form anticipated.
# => YAGNI VIOLATION: speculative features not required by any current use case
class UserProfile:
def __init__(self, name: str, email: str) -> None:
self.name = name # => required
self.email = email # => required
# => SPECULATIVE: no current feature requires these fields
self.theme = "light" # => "might need dark mode someday"
self.preferred_language = "en" # => "maybe we'll go international"
self.newsletter_frequency = "weekly" # => "for a newsletter we haven't built"
self.ai_recommendations = True # => "for an AI feature in the roadmap"
# => SPECULATIVE: no current caller uses this
def export_to_xml(self) -> str:
return f"<user><name>{self.name}</name></user>"
# => SPECULATIVE: no current caller needs analytics integration
def track_engagement(self, event: str) -> None:
print(f"[Analytics] {self.name}: {event}")
# => YAGNI COMPLIANT: only what the application actually needs right now
class SimpleUserProfile:
def __init__(self, name: str, email: str) -> None:
self.name = name # => required today
self.email = email # => required today
# => No speculative fields — add when a feature actually needs them
def display_name(self) -> str:
return self.name # => required today for display
# => Output: "Alice"
user = SimpleUserProfile("Alice", "alice@example.com")
print(user.display_name()) # => Alice
# => Add export_to_xml() when an export feature is actually built
# => Add theme when a dark mode feature is actually shippedKey Takeaway: Ship only what the current sprint requires. Code that is never executed in production still costs maintenance, testing, and cognitive load.
Why It Matters: YAGNI reduces the “inventory” of unvalidated code. Lean manufacturing teaches that inventory is waste — software inventory (unshipped features, speculative abstractions) follows the same economics. Spotify’s feature teams operate under the principle that 80% of their speculative features never get used as imagined; building them upfront wastes 80% of the effort expended.
Coupling and Cohesion
Example 15: High Coupling — The Problem
Coupling measures how much one module depends on the internals of another. High coupling means a change in one module forces changes in others, making the system brittle and hard to evolve.
# => HIGH COUPLING: OrderService reaches into the internals of Customer and Inventory
class Customer:
def __init__(self, name: str, credit_limit: float) -> None:
self.name = name
self.credit_limit = credit_limit # => internal field exposed directly
self.outstanding_balance = 0.0 # => another internal field exposed
class Inventory:
def __init__(self) -> None:
self.items: dict[str, int] = {"Laptop": 5} # => internal dict exposed
# => OrderService KNOWS about Customer's internals AND Inventory's internals
class TightlyCoupledOrderService:
def place_order(
self, customer: Customer, inventory: Inventory, item: str, price: float
) -> str:
# => directly accesses customer's internal fields — tight coupling
if customer.outstanding_balance + price > customer.credit_limit:
return "Credit limit exceeded"
# => directly accesses inventory's internal dict — tight coupling
if inventory.items.get(item, 0) <= 0:
return f"{item} is out of stock"
# => mutates customer's internal state directly
customer.outstanding_balance += price
# => mutates inventory's internal dict directly
inventory.items[item] -= 1
return f"Order placed: {item} for {customer.name}"
# => Output: "Order placed: Laptop for Alice"
customer = Customer("Alice", 2000.0)
inventory = Inventory()
service = TightlyCoupledOrderService()
print(service.place_order(customer, inventory, "Laptop", 1200.0))
# => Order placed: Laptop for Alice
# => If Customer renames credit_limit to available_credit, OrderService breaks
# => If Inventory changes items to a database call, OrderService breaksKey Takeaway: When one module directly reads or mutates another module’s internal fields, every internal change cascades as a breaking change throughout the codebase.
Why It Matters: High coupling is the primary reason legacy migrations fail. Systems where modules directly manipulate each other’s internals cannot be changed one component at a time — every change is a big-bang rewrite. The cost of decoupling grows exponentially with system size, which is why architectural standards exist: coupling is far cheaper to prevent than to remediate.
Example 16: Low Coupling Through Encapsulation
Reducing coupling means modules communicate through stable public interfaces, not through internal fields. When internal representation changes, the public interface remains stable and callers are unaffected.
graph LR
OS["OrderService"]
C["Customer<br/>(encapsulated)"]
I["Inventory<br/>(encapsulated)"]
OS -->|canAcceptCharge#40;price#41;| C
OS -->|recordCharge#40;price#41;| C
OS -->|isAvailable#40;item#41;| I
OS -->|decrement#40;item#41;| I
style OS fill:#0173B2,stroke:#000,color:#fff
style C fill:#DE8F05,stroke:#000,color:#fff
style I fill:#029E73,stroke:#000,color:#fff
# => ENCAPSULATED Customer — hides internals behind stable methods
class EncapsulatedCustomer:
def __init__(self, name: str, credit_limit: float) -> None:
self._name = name
self._credit_limit = credit_limit
self._balance = 0.0 # => private: external code cannot touch directly
@property
def name(self) -> str:
return self._name # => read-only access to name
def can_accept_charge(self, amount: float) -> bool:
return self._balance + amount <= self._credit_limit
# => hides the credit logic — callers don't know the formula
def record_charge(self, amount: float) -> None:
self._balance += amount # => only method that mutates balance
# => internal representation could change (e.g., add interest) without affecting callers
# => ENCAPSULATED Inventory — hides the backing data structure
class EncapsulatedInventory:
def __init__(self) -> None:
self._stock: dict[str, int] = {"Laptop": 5} # => private backing dict
def is_available(self, item: str) -> bool:
return self._stock.get(item, 0) > 0
# => hides how stock is stored — could be a database call
def decrement(self, item: str) -> None:
if self._stock.get(item, 0) > 0:
self._stock[item] -= 1 # => only method that mutates stock
# => LOOSELY COUPLED OrderService — talks to interfaces only
class LooselyCoupldeOrderService:
def place_order(
self,
customer: EncapsulatedCustomer,
inventory: EncapsulatedInventory,
item: str,
price: float,
) -> str:
if not customer.can_accept_charge(price):
return "Credit limit exceeded" # => no internal field access
if not inventory.is_available(item):
return f"{item} is out of stock"
customer.record_charge(price) # => delegates mutation to owner
inventory.decrement(item) # => delegates mutation to owner
return f"Order placed: {item} for {customer.name}"
# => Output: "Order placed: Laptop for Alice"
customer = EncapsulatedCustomer("Alice", 2000.0)
inventory = EncapsulatedInventory()
service = LooselyCoupldeOrderService()
print(service.place_order(customer, inventory, "Laptop", 1200.0))
# => Order placed: Laptop for Alice
# => Renaming _credit_limit to _available_credit has ZERO impact on OrderServiceKey Takeaway: Define stable public methods that express what the object can do, not what it contains. Callers depend on behavior, not representation.
Why It Matters: Encapsulation is what makes refactoring safe. When the internal representation of an object changes — migrating from a dict to a database row, for example — only that class changes. Martin Fowler’s refactoring research shows that systems with high encapsulation maintain a stable change cost over time, while tightly coupled systems see change cost grow superlinearly.
Example 17: Cohesion — Grouping Related Behavior
Cohesion measures how related the responsibilities within a module are. High cohesion means everything in a class belongs together; low cohesion means the class mixes unrelated concerns.
// => LOW COHESION: MixedUtilities handles three completely unrelated domains
class MixedUtilities {
// => string manipulation
formatTitle(title: string): string {
return title.toUpperCase(); // => "hello" → "HELLO"
}
// => financial calculation — unrelated to strings
calculateTax(price: number, rate: number): number {
return price * rate; // => 100 * 0.1 = 10
}
// => date manipulation — unrelated to both of the above
getDayOfWeek(date: Date): string {
return date.toLocaleDateString("en-US", { weekday: "long" });
// => "Monday", "Tuesday", etc.
}
}// => HIGH COHESION: each class groups only related behavior
// => REASON: when formatTitle changes, it does not affect tax or date logic
class StringFormatter {
// => All methods relate to text formatting
formatTitle(title: string): string {
return title.toUpperCase(); // => "hello" → "HELLO"
}
truncate(text: string, maxLength: number): string {
return text.length > maxLength ? text.slice(0, maxLength) + "…" : text;
// => "Hello World" with maxLength=5 → "Hello…"
}
}
class TaxCalculator {
// => All methods relate to tax computation
calculate(price: number, rate: number): number {
return price * rate; // => 100 * 0.1 = 10.0
}
calculateWithCap(price: number, rate: number, cap: number): number {
const tax = price * rate; // => 100 * 0.15 = 15
return Math.min(tax, cap); // => min(15, 10) = 10 — capped
}
}
class DateHelper {
// => All methods relate to date operations
getDayOfWeek(date: Date): string {
return date.toLocaleDateString("en-US", { weekday: "long" });
// => "Monday" or "Tuesday" etc.
}
isWeekend(date: Date): boolean {
const day = date.getDay(); // => 0=Sunday, 6=Saturday
return day === 0 || day === 6; // => true on weekends
}
}
const fmt = new StringFormatter();
console.log(fmt.formatTitle("hello world")); // => HELLO WORLD
const tax = new TaxCalculator();
console.log(tax.calculate(200, 0.1)); // => 20
Key Takeaway: Group code by what it does, not by convenience. A class with high cohesion has one clear job, making it easy to name, test, and locate.
Why It Matters: Low-cohesion classes like Utils.java or helpers.ts grow without bound in large codebases, becoming catchalls that nobody wants to modify but everybody is afraid to split. High cohesion enables true modularity: each module can evolve, be tested, and be replaced independently. It is the micro-scale version of the microservices philosophy.
Encapsulation
Example 18: Encapsulation with Private State
Encapsulation means bundling data and the methods that operate on it, then hiding the data behind a controlled interface. External code interacts through methods, never through direct field access.
# => POOR ENCAPSULATION: mutable public fields invite inconsistent state
class PoorTemperature:
def __init__(self, celsius: float) -> None:
self.celsius = celsius # => public: anyone can set this to -999
self.fahrenheit = celsius * 9/5 + 32 # => computed at init only
# => Problem: if caller sets celsius = 50 later, fahrenheit stays stale
poor = PoorTemperature(100)
print(poor.celsius) # => 100
print(poor.fahrenheit) # => 212.0
poor.celsius = 50 # => direct mutation — fahrenheit is now stale!
print(poor.fahrenheit) # => 212.0 (wrong! should be 122.0)Encapsulated — state changes only through controlled methods:
class Temperature:
def __init__(self, celsius: float) -> None:
self._validate(celsius) # => validates at construction time
self._celsius = celsius # => private: external code cannot set directly
@staticmethod
def _validate(celsius: float) -> None:
if celsius < -273.15:
raise ValueError(f"Temperature below absolute zero: {celsius}")
# => physics constraint enforced by the class
@property
def celsius(self) -> float:
return self._celsius # => read-only property
@property
def fahrenheit(self) -> float:
return self._celsius * 9 / 5 + 32
# => always computed from _celsius — never stale
# => celsius=100 → fahrenheit=212.0
@property
def kelvin(self) -> float:
return self._celsius + 273.15 # => always derived from single source of truth
def to_celsius(self, value: float) -> "Temperature":
return Temperature(value) # => returns NEW instance — immutable pattern
t = Temperature(100)
print(t.celsius) # => 100
print(t.fahrenheit) # => 212.0
print(t.kelvin) # => 373.15
# => t._celsius = 50 ← would raise AttributeError — cannot bypass encapsulationKey Takeaway: Hide mutable state behind methods. Derived values should always be computed from a single source of truth rather than cached in parallel fields.
Why It Matters: Every public field in a class is a potential consistency bug. NASA’s Mars Climate Orbiter was lost in 1999 partly due to data without encapsulated unit enforcement — a temperature or distance without controlled access. Systems where state changes are controlled and validated at the class boundary make such invariant violations impossible.
Composition Over Inheritance
Example 19: Preferring Composition
Composition over inheritance means building complex behavior by combining simple, focused objects rather than by building deep inheritance trees. Inheritance creates rigid hierarchies; composition creates flexible assemblies.
graph TD
Bird["Bird"]
Fly["FlyBehavior"]
Swim["SwimBehavior"]
Eagle["Eagle"]
Duck["Duck"]
Penguin["Penguin"]
Eagle -->|has-a| Fly
Duck -->|has-a| Fly
Duck -->|has-a| Swim
Penguin -->|has-a| Swim
style Bird fill:#CA9161,stroke:#000,color:#fff
style Fly fill:#0173B2,stroke:#000,color:#fff
style Swim fill:#029E73,stroke:#000,color:#fff
style Eagle fill:#DE8F05,stroke:#000,color:#fff
style Duck fill:#DE8F05,stroke:#000,color:#fff
style Penguin fill:#DE8F05,stroke:#000,color:#fff
Inheritance approach — rigid hierarchy:
# => INHERITANCE: rigid "is-a" hierarchy that breaks when adding Penguin
class Bird:
def fly(self) -> str:
return "Flying" # => default fly behavior
class Duck(Bird):
def quack(self) -> str:
return "Quack"
class Penguin(Bird):
def fly(self) -> str:
raise NotImplementedError("Penguins cannot fly")
# => Penguin is-a Bird, but must break the Bird contract
# => This is an LSP violation — any code using Bird.fly() breaks for PenguinComposition approach — flexible assembly:
# => BEHAVIOR PROTOCOLS — define what behaviors exist
from typing import Protocol
class FlyBehavior(Protocol):
def fly(self) -> str: ...
class SwimBehavior(Protocol):
def swim(self) -> str: ...
# => CONCRETE BEHAVIORS — small, focused, reusable
class CanFly:
def fly(self) -> str:
return "Flapping wings" # => standard flying behavior
class CannotFly:
def fly(self) -> str:
return "Cannot fly" # => used by non-flying birds
class CanSwim:
def swim(self) -> str:
return "Swimming" # => used by aquatic birds
# => COMPOSED BIRDS — each bird assembles the behaviors it actually has
class Eagle:
def __init__(self) -> None:
self._fly = CanFly() # => eagles can fly
def fly(self) -> str:
return self._fly.fly() # => delegates to behavior object
# => Output: "Flapping wings"
class Duck:
def __init__(self) -> None:
self._fly = CanFly() # => ducks can fly
self._swim = CanSwim() # => and swim
def fly(self) -> str:
return self._fly.fly() # => Output: "Flapping wings"
def swim(self) -> str:
return self._swim.swim() # => Output: "Swimming"
class Penguin:
def __init__(self) -> None:
self._fly = CannotFly() # => penguins use the "cannot fly" behavior
self._swim = CanSwim() # => but they can swim
def fly(self) -> str:
return self._fly.fly() # => Output: "Cannot fly"
def swim(self) -> str:
return self._swim.swim() # => Output: "Swimming"
duck = Duck()
print(duck.fly()) # => Flapping wings
print(duck.swim()) # => Swimming
penguin = Penguin()
print(penguin.fly()) # => Cannot fly (no exception thrown)Key Takeaway: Model “has-a” relationships with composition and “is-a” relationships with interfaces. Composition lets you mix and match behaviors without inheriting unwanted methods.
Why It Matters: Deep inheritance hierarchies are a primary driver of architectural rigidity. The Gang of Four design patterns book’s most cited advice is “favor object composition over class inheritance.” Go’s type system uses composition exclusively (no class inheritance), enabling the flexibility that makes Go code so maintainable at scale.
Example 20: Mixin vs. Composition
Python supports mixins as a way to share behavior across unrelated classes. Understanding when to use a mixin (shallow, optional capability) versus composition (required, configurable behavior) is a foundational architectural decision.
Mixin approach — reuses capability without inheritance chain:
# => MIXIN: adds serialization capability to any class that includes it
class JsonSerializableMixin:
def to_json(self) -> str:
import json
# => converts __dict__ to JSON string
return json.dumps(self.__dict__)
# => Output for User: '{"name": "Alice", "email": "alice@example.com"}'
class TimestampMixin:
def __init__(self) -> None:
import time
self.created_at = time.time() # => records creation timestamp (Unix epoch)
# => COMPOSITION: Product uses both mixins, gains both capabilities
class Product(JsonSerializableMixin, TimestampMixin):
def __init__(self, name: str, price: float) -> None:
super().__init__() # => calls TimestampMixin.__init__ via MRO
self.name = name
self.price = price
product = Product("Laptop", 1200.0)
print(product.to_json())
# => {"name": "Laptop", "price": 1200.0, "created_at": 1711234567.89}
# => to_json and created_at come from mixins — Product class has zero duplicationComposition as explicit dependency:
import json, time
# => EXPLICIT COMPOSITION: behaviors are injected, not inherited
class Serializer:
def serialize(self, data: dict) -> str:
return json.dumps(data) # => converts dict to JSON string
class Clock:
def now(self) -> float:
return time.time() # => returns current Unix timestamp
class Order:
def __init__(self, order_id: int, amount: float,
serializer: Serializer, clock: Clock) -> None:
self._id = order_id
self._amount = amount
self._serializer = serializer # => injected behavior
self._clock = clock # => injected behavior
self._created_at = clock.now() # => records creation time via injected clock
def to_json(self) -> str:
return self._serializer.serialize({
"id": self._id,
"amount": self._amount,
"created_at": self._created_at,
})
# => Output: '{"id": 1, "amount": 99.9, "created_at": 1711234567.89}'
order = Order(1, 99.9, Serializer(), Clock())
print(order.to_json()) # => '{"id": 1, "amount": 99.9, "created_at": ...}'
# => In tests: inject FakeClock that returns a fixed timestamp — fully deterministicKey Takeaway: Use mixins for optional, non-configurable capabilities shared across unrelated classes. Use explicit composition when the behavior needs to be swapped, tested, or configured.
Why It Matters: Mixin abuse creates the “mixin hell” antipattern where the method resolution order becomes impossible to reason about and unit tests require instantiating the full mixin chain. Django’s class-based views famously suffer from this. Explicit composition avoids MRO confusion and enables clean dependency injection.
Repository Pattern
Example 21: Repository Pattern Basics
The Repository pattern abstracts the data access layer behind a collection-like interface. The rest of the application treats data access as if it were querying an in-memory collection, regardless of whether the underlying store is a database, file, or external API.
graph LR
Service["Business Logic<br/>(Service)"]
Repo["Repository<br/>(interface)"]
ImplA["InMemoryRepository<br/>(tests)"]
ImplB["DatabaseRepository<br/>(production)"]
Service -->|depends on| Repo
Repo -->|implemented by| ImplA
Repo -->|implemented by| ImplB
style Service fill:#0173B2,stroke:#000,color:#fff
style Repo fill:#DE8F05,stroke:#000,color:#fff
style ImplA fill:#029E73,stroke:#000,color:#fff
style ImplB fill:#CC78BC,stroke:#000,color:#fff
from abc import ABC, abstractmethod
# => REPOSITORY INTERFACE — defines the contract; no storage details
class ProductRepository(ABC):
@abstractmethod
def find_by_id(self, product_id: int) -> dict | None: ...
@abstractmethod
def save(self, product: dict) -> None: ...
@abstractmethod
def find_all(self) -> list[dict]: ...
# => IN-MEMORY IMPLEMENTATION — for tests and fast prototyping
class InMemoryProductRepository(ProductRepository):
def __init__(self) -> None:
self._store: dict[int, dict] = {} # => in-memory dict as backing store
def find_by_id(self, product_id: int) -> dict | None:
return self._store.get(product_id) # => returns dict or None
def save(self, product: dict) -> None:
self._store[product["id"]] = product # => upsert by id key
def find_all(self) -> list[dict]:
return list(self._store.values()) # => returns all products
# => SERVICE — uses only the repository interface, not any concrete implementation
class ProductService:
def __init__(self, repo: ProductRepository) -> None:
self._repo = repo # => injected repository
def add_product(self, product_id: int, name: str, price: float) -> dict:
product = {"id": product_id, "name": name, "price": price}
self._repo.save(product) # => delegates persistence to repository
return product
def get_product(self, product_id: int) -> dict | None:
return self._repo.find_by_id(product_id)
# => delegates retrieval to repository; business layer never knows about storage
# => USAGE: inject the in-memory repo for this demo
repo = InMemoryProductRepository()
service = ProductService(repo)
p = service.add_product(1, "Laptop", 1200.0)
print(p) # => {"id": 1, "name": "Laptop", "price": 1200.0}
print(service.get_product(1)) # => {"id": 1, "name": "Laptop", "price": 1200.0}
print(service.get_product(99)) # => NoneKey Takeaway: Define a collection-like interface for persistence and inject the implementation. The business layer never imports a database driver.
Why It Matters: The repository pattern is the reason Django ORM, Rails ActiveRecord, and Spring Data JPA can be swapped for test doubles in unit tests. Applications built with explicit repository interfaces reach high test coverage without requiring a live database, dramatically reducing CI build times and increasing test reliability.
Example 22: Repository with Query Methods
Real repositories go beyond simple CRUD. They expose domain-meaningful query methods that express business questions as named methods rather than raw queries embedded in business logic.
// => DOMAIN-SPECIFIC REPOSITORY — queries named for business intent
interface OrderRepository {
save(order: { id: number; customerId: number; total: number; status: string }): void;
findById(id: number): { id: number; customerId: number; total: number; status: string } | null;
findByCustomerId(customerId: number): Array<{ id: number; customerId: number; total: number; status: string }>;
// => named for business question: "which orders belong to this customer?"
findPendingAbove(threshold: number): Array<{ id: number; customerId: number; total: number; status: string }>;
// => named for business question: "which pending orders exceed this threshold?"
}
// => IN-MEMORY IMPLEMENTATION — satisfies all query methods without a database
class InMemoryOrderRepository implements OrderRepository {
private orders: Map<number, { id: number; customerId: number; total: number; status: string }> = new Map();
save(order: { id: number; customerId: number; total: number; status: string }): void {
this.orders.set(order.id, order); // => upsert by id
}
findById(id: number) {
return this.orders.get(id) ?? null; // => returns order or null
}
findByCustomerId(customerId: number) {
return [...this.orders.values()].filter((o) => o.customerId === customerId);
// => filters all orders where customerId matches
}
findPendingAbove(threshold: number) {
return [...this.orders.values()].filter((o) => o.status === "pending" && o.total > threshold);
// => keeps only pending orders with total above threshold
}
}
// => USAGE: business service uses named queries — reads like a business document
const repo = new InMemoryOrderRepository();
repo.save({ id: 1, customerId: 10, total: 150, status: "pending" });
repo.save({ id: 2, customerId: 10, total: 800, status: "pending" });
repo.save({ id: 3, customerId: 20, total: 200, status: "shipped" });
console.log(repo.findByCustomerId(10).length); // => 2 (orders 1 and 2)
console.log(repo.findPendingAbove(500).length); // => 1 (only order 2: 800 > 500)
Key Takeaway: Repository methods should read as business questions. Avoid exposing generic query(sql) methods; every query method should have a domain-meaningful name.
Why It Matters: Named query methods make the repository the living documentation of data access patterns. When a developer reads findPendingAbove(threshold), they understand the business intent immediately. Generic query(sql) leaks persistence technology into the service layer and makes it impossible to swap storage backends without rewriting business logic.
Service Layer Pattern
Example 23: Service Layer Coordinates Use Cases
The Service Layer pattern centralizes application use cases in dedicated service classes. A use case (like “place an order”) typically involves multiple domain objects and repositories. The service coordinates them without embedding that coordination logic in controllers or domain objects.
# ============================================================
# DOMAIN OBJECTS
# ============================================================
class Customer:
def __init__(self, customer_id: int, credit: float) -> None:
self.id = customer_id
self._credit = credit
def can_afford(self, amount: float) -> bool:
return self._credit >= amount # => true if credit covers amount
def deduct(self, amount: float) -> None:
self._credit -= amount # => reduces available credit
class Product:
def __init__(self, product_id: int, name: str, price: float, stock: int) -> None:
self.id = product_id
self.name = name
self.price = price
self._stock = stock
def is_available(self) -> bool:
return self._stock > 0 # => true when stock is positive
def reserve(self) -> None:
self._stock -= 1 # => decrements stock by one
# ============================================================
# SERVICE LAYER — orchestrates the "place order" use case
# ============================================================
class OrderService:
def __init__(
self,
customers: dict[int, Customer],
products: dict[int, Product],
) -> None:
self._customers = customers # => in-memory stores (would be repos in production)
self._products = products
def place_order(self, customer_id: int, product_id: int) -> str:
# => STEP 1: retrieve both domain objects
customer = self._customers.get(customer_id)
product = self._products.get(product_id)
if customer is None or product is None:
return "Customer or product not found"
# => guard: exit early if either entity is missing
# => STEP 2: apply business rules via domain object methods
if not product.is_available():
return f"'{product.name}' is out of stock"
if not customer.can_afford(product.price):
return f"Insufficient credit for '{product.name}' (${product.price:.2f})"
# => STEP 3: execute the state changes
product.reserve() # => decrements stock
customer.deduct(product.price) # => deducts credit
return f"Order placed: {product.name} for customer {customer_id}"
# => Output: "Order placed: Laptop for customer 1"
customers = {1: Customer(1, 2000.0), 2: Customer(2, 100.0)}
products = {10: Product(10, "Laptop", 1200.0, 2), 11: Product(11, "Headphones", 150.0, 0)}
service = OrderService(customers, products)
print(service.place_order(1, 10)) # => Order placed: Laptop for customer 1
print(service.place_order(2, 10)) # => Insufficient credit for 'Laptop' ($1200.00)
print(service.place_order(1, 11)) # => 'Headphones' is out of stockKey Takeaway: The service layer owns the sequence of steps for a use case. Domain objects own their own rules. Neither should cross into the other’s territory.
Why It Matters: Without a service layer, use case logic scatters into controllers, domain objects, and scripts — creating duplicate sequences with subtle differences that diverge over time. Martin Fowler identifies the service layer as a key pattern in enterprise applications because it creates a clear API that the presentation layer (web, CLI, background jobs) can all call uniformly.
Example 24: Service Layer with Error Handling
A mature service layer handles errors explicitly, returning structured results rather than leaking exceptions to the presentation layer. This makes error handling consistent across all callers (web controller, CLI, background job).
// => RESULT TYPE: represents success or failure without exceptions
type Result<T> = { success: true; data: T } | { success: false; error: string };
// => callers must handle both cases — no hidden exception paths
// => DOMAIN
const inventory: Record<string, number> = { Widget: 10, Gadget: 0 };
// => SERVICE with explicit Result return type
class InventoryService {
reserve(item: string, quantity: number): Result<{ item: string; reserved: number }> {
const stock = inventory[item];
if (stock === undefined) {
return { success: false, error: `Item '${item}' does not exist` };
// => explicit failure: item not in catalog
}
if (stock < quantity) {
return {
success: false,
error: `Insufficient stock for '${item}': have ${stock}, requested ${quantity}`,
};
// => explicit failure: not enough stock
}
inventory[item] -= quantity; // => decrements stock
return {
success: true,
data: { item, reserved: quantity },
// => explicit success with data payload
};
}
}
// => CONTROLLER: handles Result without try/catch — error path is always visible
function handleReserve(item: string, quantity: number): string {
const service = new InventoryService();
const result = service.reserve(item, quantity);
// => result is either { success: true, data: ... } or { success: false, error: ... }
if (result.success) {
return `Reserved ${result.data.reserved}x ${result.data.item}`;
// => Output: "Reserved 3x Widget"
}
return `Reservation failed: ${result.error}`;
// => Output: "Reservation failed: Insufficient stock for 'Gadget': have 0, requested 1"
}
console.log(handleReserve("Widget", 3)); // => Reserved 3x Widget
console.log(handleReserve("Gadget", 1)); // => Reservation failed: Insufficient stock ...
console.log(handleReserve("Unknown", 1)); // => Reservation failed: Item 'Unknown' does not exist
Key Takeaway: Returning Result types forces callers to handle both success and failure paths. Unhandled errors become a compile-time concern rather than a production incident.
Why It Matters: Rust’s Result<T, E> and Go’s multi-return (value, error) are language-level enforcement of this pattern because production systems show that unhandled exceptions in service layers are the top cause of unexpected downtime. TypeScript’s discriminated unions bring the same discipline without requiring a new language.
DTO Pattern
Example 25: Data Transfer Objects
A Data Transfer Object (DTO) is a simple container for carrying data between layers or across service boundaries. DTOs have no business logic — they are pure data carriers. Using DTOs decouples the internal domain model from the external representation.
graph LR
Ext["External Client<br/>(API / UI)"]
DTO["Request DTO<br/>(validated input)"]
Svc["Service Layer"]
Domain["Domain Object<br/>(internal)"]
RespDTO["Response DTO<br/>(serialized output)"]
Ext -->|sends| DTO
DTO -->|mapped to| Domain
Domain -->|processed by| Svc
Svc -->|mapped to| RespDTO
RespDTO -->|returned to| Ext
style Ext fill:#CA9161,stroke:#000,color:#fff
style DTO fill:#0173B2,stroke:#000,color:#fff
style Svc fill:#DE8F05,stroke:#000,color:#fff
style Domain fill:#029E73,stroke:#000,color:#fff
style RespDTO fill:#CC78BC,stroke:#000,color:#fff
from dataclasses import dataclass
# => REQUEST DTO — represents data coming IN from the external world
@dataclass
class CreateUserRequest:
name: str # => raw string from HTTP request body
email: str # => raw string from HTTP request body
# => no domain logic: just carries data across the boundary
# => RESPONSE DTO — represents data going OUT to the external world
@dataclass
class UserResponse:
user_id: int # => safe to expose externally
name: str
email: str
# => notably MISSING: password_hash, internal_flags, audit_trail
# => DTOs shape what external clients can see
# => DOMAIN OBJECT — internal representation with full context
@dataclass
class User:
user_id: int
name: str
email: str
password_hash: str # => internal only — never in DTO
is_admin: bool = False # => internal only — never in DTO
# => SERVICE — maps between DTOs and domain objects
class UserService:
def __init__(self) -> None:
self._users: dict[int, User] = {}
self._next_id = 1
def create_user(self, request: CreateUserRequest) -> UserResponse:
# => MAP: DTO → Domain Object
user = User(
user_id=self._next_id,
name=request.name, # => copied from DTO
email=request.email, # => copied from DTO
password_hash="hashed_secret", # => generated internally, NOT in DTO
)
self._users[self._next_id] = user
self._next_id += 1
# => MAP: Domain Object → Response DTO
return UserResponse(
user_id=user.user_id,
name=user.name,
email=user.email,
# => password_hash and is_admin intentionally EXCLUDED from response
)
service = UserService()
request = CreateUserRequest(name="Alice", email="alice@example.com")
response = service.create_user(request)
print(response)
# => UserResponse(user_id=1, name='Alice', email='alice@example.com')
# => password_hash is NOT in the response — protected by DTO boundaryKey Takeaway: DTOs are the shape of data at a boundary — they protect internal domain structure from external exposure and decouple serialization from business logic.
Why It Matters: Every major data breach involving accidental field exposure (password hashes returned in API responses, admin flags visible to users) is a failure to use DTOs. The DTO pattern makes it structurally impossible to accidentally expose internal fields — you must explicitly copy each field you intend to share.
Example 26: DTO Validation
DTOs are also the right place to validate external input before it enters the domain layer. Centralizing validation in the DTO prevents invalid data from reaching business logic.
// => REQUEST DTO with built-in validation
class CreateProductRequest {
readonly name: string;
readonly price: number;
readonly stock: number;
constructor(data: { name: unknown; price: unknown; stock: unknown }) {
// => validate name
if (typeof data.name !== "string" || data.name.trim().length === 0) {
throw new Error("name must be a non-empty string");
// => invalid name rejected at DTO boundary
}
// => validate price
if (typeof data.price !== "number" || data.price <= 0) {
throw new Error("price must be a positive number");
// => negative prices rejected at DTO boundary
}
// => validate stock
if (typeof data.stock !== "number" || data.stock < 0 || !Number.isInteger(data.stock)) {
throw new Error("stock must be a non-negative integer");
// => fractional or negative stock rejected at DTO boundary
}
this.name = data.name.trim(); // => normalized: leading/trailing spaces removed
this.price = data.price; // => valid positive price
this.stock = data.stock; // => valid non-negative integer
}
}
// => SERVICE receives only validated DTOs — no re-validation needed
function createProduct(rawInput: { name: unknown; price: unknown; stock: unknown }): string {
try {
const request = new CreateProductRequest(rawInput);
// => if constructor succeeds, all fields are guaranteed valid
return `Product created: ${request.name} at $${request.price} (stock: ${request.stock})`;
// => Output: "Product created: Widget at $9.99 (stock: 100)"
} catch (e) {
return `Validation failed: ${(e as Error).message}`;
// => Output: "Validation failed: price must be a positive number"
}
}
console.log(createProduct({ name: "Widget", price: 9.99, stock: 100 }));
// => Product created: Widget at $9.99 (stock: 100)
console.log(createProduct({ name: "", price: 9.99, stock: 100 }));
// => Validation failed: name must be a non-empty string
console.log(createProduct({ name: "Widget", price: -5, stock: 100 }));
// => Validation failed: price must be a positive number
Key Takeaway: Validate external input in the DTO constructor so that a successfully constructed DTO is guaranteed valid. Business logic should never need to re-validate the same fields.
Why It Matters: Validation scattered across controllers, services, and domain objects produces inconsistent enforcement — the same field passes validation on one API endpoint but not another. Libraries like Zod (TypeScript), Pydantic (Python), and Bean Validation (Java) codify this pattern: parse/validate at the boundary, trust internally. This prevents an entire class of injection and data corruption attacks.
Putting It All Together
Example 27: Small Layered Application
This example combines the patterns introduced in Examples 1-26 into a minimal but realistic layered application: a product catalog with separation of concerns, repository, service, and DTO layers all working together.
from dataclasses import dataclass
from abc import ABC, abstractmethod
# ============================================================
# DTOs — data shapes at the boundary
# ============================================================
@dataclass
class AddProductRequest:
name: str
price: float
# => input DTO: carries validated data from caller to service
@dataclass
class ProductSummary:
product_id: int
name: str
price: float
# => output DTO: carries safe data from service to caller
# ============================================================
# DOMAIN — internal representation
# ============================================================
@dataclass
class Product:
product_id: int
name: str
price: float
is_featured: bool = False
# => internal flag — intentionally absent from ProductSummary DTO
# ============================================================
# REPOSITORY — data access abstraction
# ============================================================
class ProductRepo(ABC):
@abstractmethod
def save(self, product: Product) -> None: ...
@abstractmethod
def find_all(self) -> list[Product]: ...
class InMemoryProductRepo(ProductRepo):
def __init__(self) -> None:
self._data: dict[int, Product] = {} # => in-memory backing store
self._counter = 1
def save(self, product: Product) -> None:
self._data[product.product_id] = product # => upsert
def find_all(self) -> list[Product]:
return list(self._data.values()) # => returns all products
def next_id(self) -> int:
id_ = self._counter
self._counter += 1 # => auto-increment
return id_
# ============================================================
# SERVICE — coordinates use cases
# ============================================================
class ProductCatalogService:
def __init__(self, repo: InMemoryProductRepo) -> None:
self._repo = repo # => injected repository
def add(self, request: AddProductRequest) -> ProductSummary:
if request.price <= 0:
raise ValueError("Price must be positive")
# => business rule: no free or negative-priced products
product = Product(
product_id=self._repo.next_id(),
name=request.name,
price=request.price,
is_featured=False, # => defaults; caller cannot set this
)
self._repo.save(product) # => delegates persistence to repo
return ProductSummary( # => maps domain → output DTO
product_id=product.product_id,
name=product.name,
price=product.price,
)
def list_all(self) -> list[ProductSummary]:
products = self._repo.find_all() # => fetches from repo
return [
ProductSummary(p.product_id, p.name, p.price)
for p in products
# => maps each domain object to output DTO; is_featured excluded
]
# ============================================================
# PRESENTATION — wires and calls
# ============================================================
repo = InMemoryProductRepo()
service = ProductCatalogService(repo)
s1 = service.add(AddProductRequest("Laptop", 1200.0))
s2 = service.add(AddProductRequest("Mouse", 25.0))
# => s1 is ProductSummary(product_id=1, name='Laptop', price=1200.0)
# => s2 is ProductSummary(product_id=2, name='Mouse', price=25.0)
for summary in service.list_all():
print(f" {summary.product_id}. {summary.name}: ${summary.price:.2f}")
# => 1. Laptop: $1200.00
# => 2. Mouse: $25.00Key Takeaway: Each layer has a clear job: DTOs carry data at boundaries, the domain holds rules, the repository manages storage, and the service coordinates use cases. The presentation layer does nothing but wire and call.
Why It Matters: This four-layer structure is the default architecture taught in enterprise engineering onboarding at companies like Google, Microsoft, and ThoughtWorks because it reliably scales from a two-person project to a hundred-engineer system. Each layer can be replaced, tested, and scaled independently — a database can be swapped, a new presentation layer added, and business rules changed without cascading rewrites.
Example 28: Recognizing Architecture Smells
Architecture smells are patterns in code structure that signal design problems. Recognizing them early prevents costly refactoring later. These are the most common smells that violate the principles covered in Examples 1-27.
graph TD
A["Architecture Smell"]
B["God Object<br/>Does everything"]
C["Leaking Layers<br/>SQL in controller"]
D["Anemic Domain<br/>Empty domain objects"]
E["Implicit Coupling<br/>Global state / singletons"]
A --> B
A --> C
A --> D
A --> E
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#DE8F05,stroke:#000,color:#fff
style D fill:#029E73,stroke:#000,color:#fff
style E fill:#CC78BC,stroke:#000,color:#fff
Smell 1: God Object — one class knows everything:
# => GOD OBJECT: one class handles users, orders, payments, and reports
# => Signs: class has >10 methods across unrelated domains
class ApplicationManager:
def create_user(self, name: str) -> None: ... # => user concern
def place_order(self, user_id: int) -> None: ... # => order concern
def charge_card(self, amount: float) -> None: ...# => payment concern
def generate_monthly_report(self) -> str: ... # => reporting concern
# => Every new feature goes here — grows without bound
# => Fix: split into UserService, OrderService, PaymentService, ReportServiceSmell 2: Layer leakage — SQL in the controller:
# => LAYER LEAK: HTTP handler directly queries a database
# => Controller should never see SQL or database connections
import sqlite3
def handle_get_user(user_id: int) -> dict:
conn = sqlite3.connect("app.db") # => data access in presentation layer
cursor = conn.execute(
"SELECT id, name FROM users WHERE id = ?", (user_id,)
)
row = cursor.fetchone()
# => Fix: extract to UserRepository.find_by_id() and call from service
return {"id": row[0], "name": row[1]} if row else {}Smell 3: Anemic domain — domain objects with no behavior:
# => ANEMIC DOMAIN: Order is just a data bag — all logic is in the service
@dataclass
class AnemicOrder:
id: int
total: float
status: str
# => No methods, no rules — just fields
# => All business logic forced into OrderService, making it a God Object
# => FIX: move behavior INTO the domain object where it belongs
@dataclass
class RichOrder:
id: int
total: float
status: str
def can_cancel(self) -> bool:
return self.status == "pending" # => business rule lives with the data
# => OrderService calls order.can_cancel() instead of repeating the ruleSmell 4: Implicit coupling via global state:
# => GLOBAL STATE: any module can read or write this — invisible coupling
_current_user: dict | None = None # => global mutable state
def login(user: dict) -> None:
global _current_user
_current_user = user # => mutates shared global
def get_current_user() -> dict | None:
return _current_user # => any function can read this anytime
# => Fix: pass user as a parameter or inject a session object
# => Global state makes execution order matter invisibly — a major testing and concurrency hazardKey Takeaway: God objects, layer leakage, anemic domains, and global state are the four most common beginner architecture smells. Recognizing them early is as important as knowing the correct patterns.
Why It Matters: Architecture smells compound silently: a god object at 500 lines becomes unmaintainable at 5,000. LinkedIn’s 2011 production outage was partly caused by a single massive Member object that every team edited simultaneously, causing deployment conflicts and data inconsistencies. The patterns in Examples 1-27 exist precisely to prevent these smells from taking root.