Skip to main content
Software Engineering Design Principles

SOLID Principles & Software Design Principles

A complete guide to SOLID, DRY, KISS, YAGNI, Law of Demeter, and other essential design principles — with before/after refactoring examples and a full SOLID walkthrough.

13 sections · 30 min read

What are Design Principles

Design principles are guidelines that help you write code that is easier to understand, change, and extend over time. They are not rules — context always matters — but they represent hard-won wisdom from decades of building large software systems.

Principles vs Patterns: Design patterns (Singleton, Observer, Strategy) are concrete, reusable solutions to recurring problems. Design principles (SOLID, DRY, KISS) are abstract guidelines that inform when and how to apply patterns, and help you evaluate the quality of any design decision.

Technical debt is the implicit cost of rework caused by choosing a quick, easy solution now instead of a better approach that would take longer. Violating design principles generates technical debt. Each shortcut seems cheap alone — together they compound until every change takes ten times as long as it should.

The goal is not to follow principles religiously. The goal is to write code where: each class does one thing well, changes in one place don't break unrelated things, new features can be added without touching working code, and tests can be written in isolation.

# Technical debt in action
# BAD: God class — handles user data, email, and PDF generation
class UserManager:
    def save_user(self, user): ...
    def send_welcome_email(self, user): ...
    def generate_invoice_pdf(self, user): ...
    def log_activity(self, user, action): ...
    # 4 different reasons to change — 4 teams all editing this class

# BETTER: Each class has one clear responsibility
class UserRepository:
    def save(self, user): ...

class EmailService:
    def send_welcome(self, user): ...

class InvoiceService:
    def generate_pdf(self, user): ...

class ActivityLogger:
    def log(self, user, action): ...

S — Single Responsibility Principle

A class should have only one reason to change. "Reason to change" means a stakeholder or concern whose requirements might change independently. If HR changes email templates and Finance changes invoice formats, a class serving both must change twice — that's two reasons to change.

God classes are the canonical SRP violation: a User class that handles authentication, profile management, email notifications, billing, and analytics. Every team edits the same file; merge conflicts are constant; a billing bug can break authentication tests.

The refactoring: split by concern. AuthService handles login/logout/tokens. UserProfileService handles name, avatar, preferences. BillingService handles payments and invoices. Each is independently testable, deployable, and owned by one team.

A useful heuristic: describe your class in one sentence without using "and" or "or". If you need "and", it probably has multiple responsibilities.

# BEFORE: SRP violation — Report class does too much
class Report:
    def __init__(self, data):
        self.data = data

    def generate(self):
        # Reason 1: business logic changes
        return {"total": sum(self.data), "avg": sum(self.data)/len(self.data)}

    def save_to_db(self, conn):
        # Reason 2: database schema changes
        conn.execute("INSERT INTO reports VALUES (?)", [str(self.generate())])

    def send_email(self, to):
        # Reason 3: email provider or template changes
        import smtplib
        # ... send email logic

# AFTER: one class, one reason to change
class ReportGenerator:
    def generate(self, data):
        return {"total": sum(data), "avg": sum(data)/len(data)}

class ReportRepository:
    def save(self, report, conn):
        conn.execute("INSERT INTO reports VALUES (?)", [str(report)])

class ReportEmailer:
    def send(self, report, to):
        pass  # email-specific logic only

O — Open/Closed Principle

Software entities (classes, modules, functions) should be open for extension but closed for modification. Once a class is tested and working, you should be able to add new behavior by writing new code — not by editing the existing class and risking breaking what works.

The classic violation is a long if/elif chain that checks a type and dispatches: if shape == "circle": ..., elif shape == "rectangle": ... Adding a triangle means editing this function, redeploying, and re-testing everything. Every new shape touches the same code.

The fix: define an abstract interface (Shape with an area() method), implement it in separate classes (Circle, Rectangle, Triangle), and let polymorphism do the dispatch. Adding Triangle means writing one new class — no existing code changes.

The Strategy pattern is the canonical OCP tool: encapsulate algorithms behind an interface. A Sorter that accepts a SortStrategy can switch between BubbleSort, QuickSort, and MergeSort without changing the Sorter class.

from abc import ABC, abstractmethod

# BEFORE: OCP violation — adding a new discount type requires editing this function
def calculate_discount(order, customer_type):
    if customer_type == "regular":
        return order.total * 0.05
    elif customer_type == "premium":
        return order.total * 0.10
    elif customer_type == "vip":
        return order.total * 0.20
    # Adding "employee" means editing here — risking regression

# AFTER: open for extension, closed for modification
class DiscountStrategy(ABC):
    @abstractmethod
    def calculate(self, order_total: float) -> float:
        pass

class RegularDiscount(DiscountStrategy):
    def calculate(self, total): return total * 0.05

class PremiumDiscount(DiscountStrategy):
    def calculate(self, total): return total * 0.10

class VIPDiscount(DiscountStrategy):
    def calculate(self, total): return total * 0.20

class EmployeeDiscount(DiscountStrategy):  # NEW — no existing code touched
    def calculate(self, total): return total * 0.30

class OrderProcessor:
    def __init__(self, discount: DiscountStrategy):
        self.discount = discount

    def process(self, order_total: float):
        saving = self.discount.calculate(order_total)
        return order_total - saving

p = OrderProcessor(VIPDiscount())
print(p.process(100))   # 80.0

L — Liskov Substitution Principle

If S is a subtype of T, objects of type T may be replaced with objects of type S without altering the correctness of the program. In plain terms: anywhere you use a base class, a subclass should work without the caller knowing the difference or the behavior breaking.

The square-rectangle problem is the canonical LSP violation. Rectangle has setWidth and setHeight independently. Square extends Rectangle but overrides both to keep sides equal (a square's sides must be equal). A function that sets width=4, height=5 and expects area=20 will get area=25 when given a Square. The subclass breaks the caller's assumptions.

Behavioral subtypes must: not strengthen preconditions (don't require more from callers), not weaken postconditions (don't promise less to callers), preserve invariants of the base class, not throw new exceptions not thrown by the base.

The fix for square-rectangle: don't model this as inheritance. Use a common interface (Shape with area()) that both Rectangle and Square implement independently. They are not in an IS-A relationship — they just both have areas.

# LSP VIOLATION
class Rectangle:
    def set_width(self, w): self.width = w
    def set_height(self, h): self.height = h
    def area(self): return self.width * self.height

class Square(Rectangle):
    def set_width(self, w):      # breaks LSP
        self.width = self.height = w
    def set_height(self, h):
        self.width = self.height = h

def test_area(rect: Rectangle):
    rect.set_width(4)
    rect.set_height(5)
    assert rect.area() == 20, f"Expected 20, got {rect.area()}"

test_area(Rectangle())   # passes
# test_area(Square())    # FAILS — area is 25, not 20

# LSP FIX: don't inherit, use a shared interface
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float: pass

class Rectangle(Shape):
    def __init__(self, w, h): self.w, self.h = w, h
    def area(self): return self.w * self.h

class Square(Shape):
    def __init__(self, side): self.side = side
    def area(self): return self.side ** 2

# Any Shape works here — LSP satisfied
shapes = [Rectangle(4, 5), Square(4)]
for s in shapes:
    print(s.area())   # 20, 16

I — Interface Segregation Principle

Clients should not be forced to depend on interfaces they do not use. A fat interface with 20 methods forces every implementer to define all 20 — even if it only needs 3. Unused methods become dead code that must be maintained and tested.

The problem: a Worker interface with work(), eat(), and sleep() makes sense for human workers. A Robot implementation is forced to implement eat() and sleep() even though robots don't eat or sleep. This creates empty stubs — a sign of an ISP violation.

The fix: split the fat interface into role interfaces. Workable has work(). Feedable has eat(). Restable has sleep(). HumanWorker implements all three. Robot implements only Workable. Each implementer takes exactly what it needs.

Applied to Python: use Abstract Base Classes or Protocols (Python 3.8+) to define narrow interfaces. Protocols give you structural subtyping — if an object has the required methods, it satisfies the protocol without explicit inheritance.

from abc import ABC, abstractmethod

# FAT INTERFACE — ISP violation
class Worker(ABC):
    @abstractmethod
    def work(self): pass
    @abstractmethod
    def eat(self): pass   # robots can't eat
    @abstractmethod
    def sleep(self): pass  # robots don't sleep

class Robot(Worker):
    def work(self): return "Robot working"
    def eat(self): pass    # forced stub — ISP violation
    def sleep(self): pass  # forced stub — ISP violation

# SEGREGATED INTERFACES — ISP satisfied
class Workable(ABC):
    @abstractmethod
    def work(self): pass

class Feedable(ABC):
    @abstractmethod
    def eat(self): pass

class Restable(ABC):
    @abstractmethod
    def sleep(self): pass

class HumanWorker(Workable, Feedable, Restable):
    def work(self):  return "Human working"
    def eat(self):   return "Human eating"
    def sleep(self): return "Human sleeping"

class RobotWorker(Workable):
    def work(self):  return "Robot working"
    # No eat(), no sleep() — only takes what it needs

D — Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.

Without DIP: a UserService creates a MySQLDatabase internally. Now UserService is tightly coupled to MySQL. To switch to PostgreSQL, you modify UserService. To test UserService, you need a real MySQL connection. The high-level policy (UserService) is coupled to the low-level detail (MySQL).

With DIP: define a Database abstract interface. UserService accepts a Database in its constructor. MySQLDatabase and PostgreSQLDatabase both implement Database. To test, inject a FakeDatabase. To switch databases, pass a different object — UserService never changes.

Dependency injection is the mechanism: passing dependencies from outside rather than creating them internally. Constructor injection (most common), method injection, and property injection are the three forms.

Inversion of Control (IoC) containers (Spring, .NET DI, Python's dependency_injector) automate dependency injection — you declare what each class needs, and the container wires everything together.

from abc import ABC, abstractmethod

# DIP VIOLATION: high-level module depends on low-level detail
class UserService:
    def __init__(self):
        self.db = MySQLDatabase()   # hard-coded dependency

    def get_user(self, user_id):
        return self.db.query(f"SELECT * FROM users WHERE id={user_id}")

# DIP COMPLIANT: both depend on the abstraction
class Database(ABC):
    @abstractmethod
    def query(self, sql: str): pass

    @abstractmethod
    def execute(self, sql: str): pass

class MySQLDatabase(Database):
    def query(self, sql):   return f"MySQL: {sql}"
    def execute(self, sql): return f"MySQL executed: {sql}"

class PostgreSQLDatabase(Database):
    def query(self, sql):   return f"PostgreSQL: {sql}"
    def execute(self, sql): return f"PostgreSQL executed: {sql}"

class InMemoryDatabase(Database):   # for testing
    def __init__(self): self.data = {}
    def query(self, sql):   return self.data
    def execute(self, sql): pass

class UserService:
    def __init__(self, db: Database):  # dependency injected
        self.db = db

    def get_user(self, user_id):
        return self.db.query(f"SELECT * FROM users WHERE id={user_id}")

# Production
svc = UserService(MySQLDatabase())

# Testing — no real database needed
test_svc = UserService(InMemoryDatabase())

DRY — Don't Repeat Yourself

Every piece of knowledge must have a single, unambiguous, authoritative representation within a system. Duplication means that when a requirement changes, you must find and update every copy — miss one, and you have a bug.

Types of duplication: code duplication (copy-pasted functions), data duplication (the same constant defined in 5 files), documentation duplication (comments that restate the code and drift out of sync), representation duplication (parsing the same data format in multiple places).

Extracting a helper function, a constant, or a base class eliminates code duplication. The DRY principle applies at the knowledge level — two similar-looking code snippets are not a DRY violation if they represent different concepts that happen to look similar today but will diverge tomorrow.

When DRY is wrong (the AHA principle — Avoid Hasty Abstractions): premature deduplication creates the wrong abstraction. If you merge two similar functions too early, you end up with a tangled function that handles both cases awkwardly. Sometimes duplication is cheaper than the wrong abstraction. Wait until you have three copies (the rule of three) before extracting.

# DRY VIOLATION: validation logic duplicated
def register_user(email, password):
    if not "@" in email:
        raise ValueError("Invalid email")
    if len(password) < 8:
        raise ValueError("Password too short")
    # ... register

def update_credentials(email, password):
    if not "@" in email:         # copy-paste
        raise ValueError("Invalid email")
    if len(password) < 8:        # copy-paste
        raise ValueError("Password too short")
    # ... update

# DRY: single authoritative source
def validate_credentials(email, password):
    if "@" not in email:
        raise ValueError("Invalid email")
    if len(password) < 8:
        raise ValueError("Password too short")

def register_user(email, password):
    validate_credentials(email, password)
    # ... register

def update_credentials(email, password):
    validate_credentials(email, password)
    # ... update

# Password rules change? Update ONE place.

KISS — Keep It Simple, Stupid

Most systems work best if they are kept simple rather than made complicated. Simplicity should be a key goal in design, and unnecessary complexity should be avoided.

Complexity budget: every piece of complexity you add must earn its place by solving a real problem. Nested inheritance hierarchies, factory factories, event buses for 10 users, generic framework code for a one-off script — these all add complexity that must be understood, maintained, and debugged.

Signs you are violating KISS: more time is spent reading the code than writing it, new developers take weeks to understand a module, changes require modifying 8 files across 4 layers, unit tests require 15 lines of setup before the assertion.

Over-engineering examples: creating an abstract factory for an object that only ever has one implementation, writing a plugin system for a feature that never needed plugins, making a configuration file for values that never change, using reflection/metaprogramming for a simple if/else.

The fix: start with the simplest thing that could possibly work. Add complexity only when a real requirement demands it. Refactor when the simple solution genuinely cannot handle the new requirement.

# OVER-ENGINEERED: unnecessary abstraction for a simple problem
class GreeterFactory:
    _registry = {}

    @classmethod
    def register(cls, lang, greeter_class):
        cls._registry[lang] = greeter_class

    @classmethod
    def create(cls, lang):
        return cls._registry[lang]()

class EnglishGreeter:
    def greet(self, name): return f"Hello, {name}"

class SpanishGreeter:
    def greet(self, name): return f"Hola, {name}"

GreeterFactory.register("en", EnglishGreeter)
GreeterFactory.register("es", SpanishGreeter)

g = GreeterFactory.create("en")
print(g.greet("Alice"))

# SIMPLE: just use a dictionary or function — same result, far less code
def greet(name, lang="en"):
    greetings = {"en": "Hello", "es": "Hola", "fr": "Bonjour"}
    return f"{greetings.get(lang, 'Hello')}, {name}"

print(greet("Alice", "en"))   # Hello, Alice
# Adding a language = adding one dictionary entry

YAGNI — You Aren't Gonna Need It

Don't add functionality until it is actually needed. YAGNI is a core principle of Extreme Programming (XP) and fights the instinct to build for imagined future requirements.

Speculative generalization is the YAGNI violation: building a plugin system because "we might need plugins someday", making every method configurable because "someone might want to change this", adding an abstraction layer for a third-party library "in case we switch". These things almost never happen — and when they do, the speculative code was written wrong anyway.

The real cost of unused features: code must be read, understood, and maintained even if it's never called. Tests must cover it. New developers wonder if it's important. When a real requirement arrives, the speculative code often needs to be rewritten anyway because the actual requirement differs from the imagined one.

YAGNI and refactoring work together: write the simple version now, refactor to the more general version when the second use case actually appears (rule of three). You'll have real requirements to guide the design instead of guesses.

# YAGNI VIOLATION: building a plugin system speculatively
class ReportEngine:
    def __init__(self):
        self._plugins = []
        self._hooks = {}
        self._middleware = []

    def register_plugin(self, plugin): ...    # never called
    def add_middleware(self, mw): ...          # never called
    def register_hook(self, event, fn): ...    # never called

    def generate(self, data):
        # the actual feature: 5 lines of useful code buried under scaffolding
        return {"total": sum(data)}

# YAGNI COMPLIANT: build what you need now
class ReportEngine:
    def generate(self, data):
        return {"total": sum(data), "avg": sum(data)/len(data)}

# When the REAL second use case arrives (e.g. PDF export),
# THEN refactor to support it — with actual requirements guiding the design.

Law of Demeter

The Law of Demeter (principle of least knowledge) says a method should only talk to its immediate friends, not to strangers. A method M of object O may only call methods on: O itself, objects passed as arguments to M, objects created inside M, O's direct component objects.

Train wrecks are the tell-tale sign: customer.getWallet().getMoney().getAmount(). This chain means your code knows the internal structure of Customer, Wallet, and Money — three levels deep. If Wallet changes, everything that uses this chain breaks.

Tell, don't ask: instead of asking an object for its internals and computing something yourself, tell the object to do it. customer.pay(amount) is better than customer.getWallet().deduct(amount). The Customer knows how to pay; callers don't need to know about Wallets.

Applying LoD reduces coupling — changes to internal structure don't ripple outward. It also improves encapsulation and makes objects feel like proper services rather than passive data holders.

# LAW OF DEMETER VIOLATION — train wreck
class Money:
    def __init__(self, amount): self.amount = amount

class Wallet:
    def __init__(self, money: Money): self.money = money
    def get_money(self): return self.money

class Customer:
    def __init__(self, wallet: Wallet): self.wallet = wallet
    def get_wallet(self): return self.wallet

def checkout(customer: Customer, price: float):
    # Knows about Customer, Wallet, AND Money internals
    amount = customer.get_wallet().get_money().amount
    if amount >= price:
        customer.get_wallet().get_money().amount -= price  # mutating deeply
        return True
    return False

# LAW OF DEMETER COMPLIANT — tell, don't ask
class Customer:
    def __init__(self, balance: float):
        self._balance = balance

    def can_afford(self, price: float) -> bool:
        return self._balance >= price

    def pay(self, price: float):
        if not self.can_afford(price):
            raise ValueError("Insufficient funds")
        self._balance -= price

def checkout(customer: Customer, price: float):
    customer.pay(price)   # one level deep — LoD satisfied

Other Essential Principles

Separation of Concerns (SoC): divide your program into distinct sections, each addressing a separate concern. The presentation layer does not talk to the database directly. Business logic does not format output. SoC is the foundation of MVC, layered architectures, and microservices.

Composition over Inheritance (CoI): favor assembling behavior from components over building deep hierarchies. Already covered in OOP — repeated here because it matters enough for SOLID discussions too.

Fail Fast: detect and report errors as early as possible rather than silently continuing. Validate inputs at the boundary, throw exceptions early, use assertions in development builds. A NullPointerException at line 800 is harder to debug than a ValueError at line 10.

Convention over Configuration (CoC): framework defaults should work without configuration; override only what differs. Rails knows models live in app/models. Spring beans are auto-wired by type. You configure exceptions, not the common case.

Principle of Least Astonishment: your code should behave the way its name and interface imply. A method called getUser() that also deletes the user is astonishing. An append() that sometimes returns a new list instead of modifying in place is astonishing. Predictable behavior is reliable behavior.

# Separation of Concerns — three-layer example
class UserRepository:
    """Data access layer — only knows about storage"""
    def find_by_id(self, user_id): ...
    def save(self, user): ...

class UserService:
    """Business logic layer — orchestrates rules"""
    def __init__(self, repo: UserRepository):
        self.repo = repo

    def promote_to_admin(self, user_id):
        user = self.repo.find_by_id(user_id)
        if user.account_age_days < 365:
            raise ValueError("Must be a member for 1 year")
        user.is_admin = True
        self.repo.save(user)

class UserController:
    """Presentation layer — only knows about HTTP"""
    def __init__(self, service: UserService):
        self.service = service

    def promote(self, request):
        try:
            self.service.promote_to_admin(request.user_id)
            return {"status": 200, "message": "Promoted"}
        except ValueError as e:
            return {"status": 400, "error": str(e)}

# Each layer changes independently — database changes don't touch HTTP layer

Applying Principles Together — Refactoring Walkthrough

Let's take a realistic God class and apply all five SOLID principles step by step to see how they work together.

Starting point: an OrderService class that handles order creation, inventory checks, payment processing, email notifications, and PDF invoice generation. One class, five concerns, five reasons to change.

Step 1 (SRP): split into OrderRepository (data), InventoryService (stock checks), PaymentGateway (charges), NotificationService (emails), InvoiceService (PDF). Each has one job.

Step 2 (OCP): PaymentGateway becomes an abstract class. StripeGateway and PayPalGateway extend it. Adding a new payment method means a new class — no changes to OrderService.

Step 3 (LSP): ensure StripeGateway and PayPalGateway both honor the contract: charge() always returns a receipt, never returns None, throws the same exception type on failure. Subtypes are substitutable.

Step 4 (ISP): NotificationService splits into EmailNotifier and SMSNotifier. OrderService only depends on the notifiers it actually uses.

Step 5 (DIP): OrderService constructor accepts abstractions: PaymentGateway, EmailNotifier, InventoryChecker — all interfaces. Tests inject mocks. Production code injects real implementations.

Result: OrderService is now a thin orchestrator. Every collaborator is independently testable, swappable, and owned by a single team.

from abc import ABC, abstractmethod

# Step 1-5 applied: clean OrderService after SOLID refactoring
class PaymentGateway(ABC):            # OCP + DIP
    @abstractmethod
    def charge(self, amount, card): pass

class StripeGateway(PaymentGateway):
    def charge(self, amount, card):
        return {"receipt": f"stripe_{amount}"}

class PayPalGateway(PaymentGateway):  # new provider — no OrderService change
    def charge(self, amount, card):
        return {"receipt": f"paypal_{amount}"}

class EmailNotifier(ABC):             # ISP — split from fat notifier
    @abstractmethod
    def send_confirmation(self, order): pass

class InventoryChecker(ABC):
    @abstractmethod
    def reserve(self, items): pass

class OrderService:                   # DIP — all deps injected
    def __init__(
        self,
        payment: PaymentGateway,
        notifier: EmailNotifier,
        inventory: InventoryChecker,
    ):
        self.payment = payment
        self.notifier = notifier
        self.inventory = inventory

    def place_order(self, items, amount, card):
        self.inventory.reserve(items)          # LSP: any InventoryChecker works
        receipt = self.payment.charge(amount, card)
        self.notifier.send_confirmation({"items": items, "receipt": receipt})
        return receipt

Common Interview Questions

What is the difference between a principle and a pattern? Principles are abstract guidelines (SRP, DRY). Patterns are concrete, named solutions to specific problems (Strategy, Factory). Principles inform when and how to apply patterns.

Can you violate SOLID? Yes, intentionally and with clear reasoning. A simple script doesn't need strict DIP. Performance-critical code might inline things that DRY would extract. The key is to know you're violating a principle and why the trade-off is worth it.

How do you apply OCP without creating too many classes? Use strategy for algorithms, template method for steps in a process, and plugin/decorator for optional behaviors. Don't apply OCP speculatively — apply it when the second variant arrives.

What is the most commonly violated SOLID principle in real codebases? SRP. God classes accumulate responsibilities over time because it's always easier to add a method to an existing class than to create a new one. Code review and module ownership help.

How does DRY interact with microservices? In distributed systems, sharing code via libraries introduces coupling between services. Sometimes accepting duplication (each service defines its own User model) is better than creating a shared library that all services depend on. DRY applies within a service; across services, the trade-off is context-dependent.

What is the Hollywood Principle? "Don't call us, we'll call you." High-level components define the workflow; low-level components plug into it. This is IoC/DI in action — the framework calls your code, not the other way around.

Continue Learning

Ready to test your knowledge?

Apply what you learned with curated practice problems.

Find this useful?

This guide is completely free. If it helped, consider buying me a coffee — it keeps new content coming.

Support on Ko-fi
Buy me a coffee