Skip to content

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.