StackPractices
intermediate By Mathias Paulenko

SOLID Principles Explained with Examples

Learn the five SOLID principles with bad-vs-good code examples and a full before/after refactor of a TypeScript payment service.

Topics: design

Introduction

Every long-lived codebase has a class that started small and grew until nobody wanted to touch it. A validation tweak breaks the report. A new payment method forces you to retest logic that already worked. SOLID is a set of five design principles aimed at exactly that pain: keeping classes small, changes local, and dependencies replaceable.

Robert C. Martin popularized the principles in his 2000 paper “Design Principles and Design Patterns” and later in Agile Software Development; the SOLID acronym itself was coined by Michael Feathers. They’re foundational to object-oriented design, and most of them translate to modules and services too. This guide is for developers who already write OOP code and want the principles with working examples, not just definitions.

LetterPrincipleCore Idea
SSingle ResponsibilityA class should have one reason to change
OOpen/ClosedOpen for extension, closed for modification
LLiskov SubstitutionSubtypes must be substitutable for their base types
IInterface SegregationClients shouldn’t depend on interfaces they don’t use
DDependency InversionDepend on abstractions, not concretions

Below you’ll find each principle with runnable bad-vs-good Python examples, a section on when the principles are worth their cost (and when they aren’t), and a full TypeScript refactor that applies all five to a payment service. If you already know the definitions, jump to the worked example — that’s where SOLID stops being vocabulary and starts being design.

S — Single Responsibility Principle (SRP)

A class should have only one reason to change.

The classic violation: one class that manages orders and produces reports. Two unrelated reasons to modify it, and every change risks the other half.

# Bad: one class handles order logic AND reporting
class OrderManager:
    def __init__(self):
        self.orders: list[dict] = []

    def create_order(self, items: list[dict]) -> dict:
        order = {"id": len(self.orders) + 1, "items": items}
        self.orders.append(order)
        return order

    def cancel_order(self, order_id: int) -> None:
        self.orders = [o for o in self.orders if o["id"] != order_id]

    def generate_monthly_report(self) -> str:
        return "\n".join(
            f"Order {o['id']}: {len(o['items'])} items" for o in self.orders
        )
# Good: separate responsibilities
class OrderService:
    def __init__(self):
        self.orders: list[dict] = []

    def create_order(self, items: list[dict]) -> dict:
        order = {"id": len(self.orders) + 1, "items": items}
        self.orders.append(order)
        return order

    def cancel_order(self, order_id: int) -> None:
        self.orders = [o for o in self.orders if o["id"] != order_id]


class ReportGenerator:
    def __init__(self, order_service: OrderService):
        self.order_service = order_service

    def generate_monthly_report(self) -> str:
        return "\n".join(
            f"Order {o['id']}: {len(o['items'])} items"
            for o in self.order_service.orders
        )

The smell you’ll notice in real codebases is a method that doesn’t touch the same state as the rest of the class — like generate_monthly_report above, which only reads self.orders. That’s a second responsibility looking for its own home. Watch out for the opposite extreme, though: splitting every method into its own class gives you fragmentation, not focus. See Clean Code Principles for related practices.

O — Open/Closed Principle (OCP)

Software entities should be open for extension but closed for modification.

# Bad: modify existing code for every new payment method
class PaymentProcessor:
    def process(self, payment: dict) -> str:
        if payment["type"] == "credit_card":
            return f"Charged {payment['amount']} to card {payment['card']}"
        elif payment["type"] == "paypal":
            return f"Sent {payment['amount']} to {payment['email']}"
        # adding "crypto" means editing — and retesting — this method
# Good: extend via new classes
from abc import ABC, abstractmethod


class PaymentMethod(ABC):
    @abstractmethod
    def process(self, amount: float) -> str:
        ...


class CreditCardPayment(PaymentMethod):
    def __init__(self, card_number: str):
        self.card_number = card_number

    def process(self, amount: float) -> str:
        return f"Charged {amount} to card {self.card_number}"


class PayPalPayment(PaymentMethod):
    def __init__(self, email: str):
        self.email = email

    def process(self, amount: float) -> str:
        return f"Sent {amount} to {self.email}"


class PaymentProcessor:
    def __init__(self, method: PaymentMethod):
        self.method = method

    def process(self, amount: float) -> str:
        return self.method.process(amount)


# Adding a new method requires zero changes to existing code
class CryptoPayment(PaymentMethod):
    def __init__(self, wallet: str):
        self.wallet = wallet

    def process(self, amount: float) -> str:
        return f"Transferred {amount} to wallet {self.wallet}"

The payoff shows up the third time you add a payment method: instead of touching tested code, you write a new class and nothing else. The trade-off is real, though — every extension point adds an interface, and premature ones are the most common way OCP gets over-applied. If a value has only ever had one or two variants, a plain if is fine. See Strategy Pattern for interchangeable behavior.

L — Liskov Substitution Principle (LSP)

Objects of a superclass should be replaceable with objects of its subclasses without breaking the program.

The textbook violation is Square inheriting from Rectangle: a square “is” a rectangle mathematically, but the set_width/set_height contract doesn’t survive.

# Bad: Square violates LSP when used as Rectangle
class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height

    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):  # violates LSP
    def set_width(self, w):
        self._width = w
        self._height = w  # surprising side effect!

    def set_height(self, h):
        self._width = h   # surprising side effect!
        self._height = h


# A function expecting Rectangle behavior breaks with Square
def resize_rectangle(rect: Rectangle):
    rect.set_width(5)
    rect.set_height(4)
    assert rect.area() == 20  # fails for Square!
# Good: model Square independently or as a value object
from dataclasses import dataclass


class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height


@dataclass(frozen=True)
class Square:
    side: int

    def area(self):
        return self.side * self.side

LSP violations are the quietest bugs of the five — the code compiles, polymorphism works, and the failure only appears when a caller trusts the parent’s contract. In practice it shows up as isinstance checks sprinkled through callers, or overridden methods that throw NotImplementedError. Both are signs the inheritance is wrong and the two types just share an interface.

I — Interface Segregation Principle (ISP)

Clients shouldn’t be forced to depend on interfaces they don’t use.

# Bad: one bloated interface
from abc import ABC, abstractmethod


class Worker(ABC):
    @abstractmethod
    def work(self) -> None:
        ...

    @abstractmethod
    def eat(self) -> None:    # robots don't eat
        ...

    @abstractmethod
    def sleep(self) -> None:  # robots don't sleep
        ...


class RobotWorker(Worker):
    def work(self) -> None:
        print("Assembling widgets")

    def eat(self) -> None:
        raise NotImplementedError  # forced to implement a meaningless method

    def sleep(self) -> None:
        raise NotImplementedError
# Good: split into focused interfaces
class Workable(ABC):
    @abstractmethod
    def work(self) -> None:
        ...


class Feedable(ABC):
    @abstractmethod
    def eat(self) -> None:
        ...


class HumanWorker(Workable, Feedable):
    def work(self) -> None:
        print("Writing code")

    def eat(self) -> None:
        print("Lunch break")


class RobotWorker(Workable):
    def work(self) -> None:
        print("Assembling widgets")
    # no need to implement eat() or sleep()

A NotImplementedError in an override is ISP telling you the interface is too wide. Bloated interfaces create unnecessary coupling: when a client depends on methods it never calls, changes to those methods can force recompilation or retesting. Don’t slice too finely either — one method per interface is usually overkill in dynamic languages.

D — Dependency Inversion Principle (DIP)

High-level modules shouldn’t depend on low-level modules. Both should depend on abstractions.

# Bad: high-level module depends on a concrete low-level module
class EmailService:
    def send(self, to: str, subject: str, body: str) -> None:
        print(f"SMTP -> {to}: {subject}")  # a real impl talks to a server


class NotificationManager:  # high-level
    def __init__(self):
        self.email = EmailService()  # hardcoded dependency

    def notify_user(self, user: dict) -> None:
        self.email.send(user["email"], "Hello", "Welcome!")
# Good: depend on abstractions
class NotificationChannel(ABC):
    @abstractmethod
    def send(self, to: str, subject: str, body: str) -> None:
        ...


class EmailService(NotificationChannel):
    def send(self, to: str, subject: str, body: str) -> None:
        print(f"SMTP -> {to}: {subject}")


class SMSService(NotificationChannel):
    def send(self, to: str, subject: str, body: str) -> None:
        print(f"SMS -> {to}: {subject}")


class NotificationManager:
    def __init__(self, channel: NotificationChannel):
        self.channel = channel

    def notify_user(self, user: dict) -> None:
        self.channel.send(user["email"], "Hello", "Welcome!")


# Swap implementations without touching NotificationManager
email_notifier = NotificationManager(EmailService())
sms_notifier = NotificationManager(SMSService())

Depending on abstractions means you can swap implementations — for testing, different environments, or new requirements — without touching the high-level business logic. It’s also what makes unit tests cheap: pass a fake channel instead of patching SMTP. Don’t inject everything, though; stable utilities like datetime or a JSON parser are fine to call directly. See Factory Pattern for creating abstractions.

When to Apply SOLID (and When Not To)

The principles pay off where change happens. Reach for them when you see these signals:

  • A class keeps getting touched for unrelated reasons. Every sprint someone edits UserService for a different concern — auth, emails, exports. That’s SRP asking for a split.
  • An if/elif or switch grows a branch every release. Payment types, notification channels, report formats — that’s the OCP case for a plugin-style abstraction.
  • Callers defend themselves with isinstance checks. They’re compensating for a subclass that doesn’t honor the parent’s contract (LSP).
  • Tests require patching a network client or a database connection. The dependency should have been inverted (DIP).
  • Implementing an interface requires stub methods that throw. The interface is too wide for that client (ISP).

And the cases where SOLID is usually overkill:

  • Scripts and one-off tooling. If the code runs once and is easy to rewrite, abstraction costs more than it saves.
  • Early prototypes. You don’t know the change axes yet; premature interfaces freeze the wrong boundaries.
  • Straightforward CRUD operations. A thin controller over a data model doesn’t need five layers of indirection.
  • Stable, boring dependencies. Wrapping math or json buys nothing.

The honest rule: apply SOLID where volatility is proven, not where it might someday exist. Common Mistakes below covers how this goes wrong.

Applying SOLID Together

The principles reinforce each other — small, focused classes (SRP) are easier to extend (OCP), correct subtyping (LSP) is what makes polymorphic injection safe (DIP), and narrow interfaces (ISP) shrink the surface each dependency exposes:

flowchart diagram: SRP[

The dependency injection in DIP is also where everything converges in a codebase: a composition root wires abstractions to concrete implementations, which is why frameworks like NestJS or ASP.NET Core are essentially SOLID delivery mechanisms.

Worked Example: Refactoring a Payment Service

Take the shape legacy payment code usually has: one class that validates, charges, logs, emails, and persists in a single method. The “before” version below violates SRP, OCP, and DIP at once:

// Before: violates SRP, OCP, and DIP
class PaymentProcessor {
  processPayment(payment: Payment) {
    // Validates (SRP violation)
    if (!payment.amount || payment.amount <= 0) throw new Error("Invalid");
    // Processes with Stripe (OCP violation - hardcoded)
    const stripe = new StripeClient("sk_live_xxx");
    const result = stripe.charge(payment.amount, payment.currency);
    // Logs to console (SRP violation)
    console.log("Payment processed:", result);
    // Sends email (SRP violation)
    const email = new EmailService();
    email.send(payment.customerEmail, "Payment received");
    // Saves to MySQL (DIP violation - hardcoded)
    const db = new MySQLConnection("localhost", "payments");
    db.query("INSERT INTO payments ...", result);
    return result;
  }
}
// After: SOLID-compliant refactor

// SRP: each class has one responsibility
interface PaymentValidator {
  validate(payment: Payment): void;
}
class PaymentValidatorImpl implements PaymentValidator {
  validate(payment: Payment) {
    if (!payment.amount || payment.amount <= 0)
      throw new Error("Invalid amount");
    if (!payment.currency || payment.currency.length !== 3)
      throw new Error("Invalid currency");
  }
}

// OCP: new gateways without modifying existing code
interface PaymentGateway {
  charge(amount: number, currency: string): Promise<PaymentResult>;
}
class StripeGateway implements PaymentGateway {
  constructor(private client: StripeClient) {}
  async charge(amount: number, currency: string) {
    return this.client.charge(amount, currency);
  }
}
class PayPalGateway implements PaymentGateway {
  constructor(private client: PayPalClient) {}
  async charge(amount: number, currency: string) {
    return this.client.createOrder(amount, currency);
  }
}

// ISP: segregated interfaces
interface PaymentRepository {
  save(result: PaymentResult): Promise<void>;
}
interface NotificationService {
  notify(email: string, subject: string, body: string): Promise<void>;
}
interface Logger {
  log(level: string, message: string, meta?: object): void;
}

// DIP: depends on abstractions, not concretions
class PaymentProcessor {
  constructor(
    private validator: PaymentValidator,
    private gateway: PaymentGateway,
    private repo: PaymentRepository,
    private notifier: NotificationService,
    private logger: Logger
  ) {}

  async processPayment(payment: Payment): Promise<PaymentResult> {
    this.validator.validate(payment);
    this.logger.log("info", "Processing payment", { amount: payment.amount });

    const result = await this.gateway.charge(payment.amount, payment.currency);

    await this.repo.save(result);
    await this.notifier.notify(payment.customerEmail, "Payment received",
      `Your payment of ${payment.amount} ${payment.currency} was processed.`);
    this.logger.log("info", "Payment completed", { id: result.id });
    return result;
  }
}

// LSP: any PaymentGateway implementation works
const processor = new PaymentProcessor(
  new PaymentValidatorImpl(),
  new StripeGateway(stripeClient),  // or new PayPalGateway(paypalClient)
  new PostgresPaymentRepository(db),
  new EmailNotificationService(smtp),
  new WinstonLogger()
);

// Testing: mock everything via interfaces
const testProcessor = new PaymentProcessor(
  mockValidator, mockGateway, mockRepo, mockNotifier, mockLogger
);
// Each dependency is swappable and testable independently

The refactor isn’t free — five interfaces where there was one class — so it earns its keep only if gateways actually change or get added. Where it pays immediately is testing: PaymentProcessor now accepts mocks with no patching framework. Both versions are available as a runnable mini-project in the companion repository. For a TypeScript-specific treatment of the same principles (decorators, DI containers, structural typing), see SOLID Principles in TypeScript.

SOLID Beyond Classes

The principles map cleanly onto larger units of design:

  • Microservices. SRP becomes service boundaries: each service owns one business capability. OCP means new functionality ships as new services or endpoints instead of edits to stable ones. LSP is API contract compatibility across versions — a v2 endpoint that breaks v1 clients is an LSP violation at the HTTP level. ISP shows up as backend-for-frontend: each client gets only the endpoints it needs. DIP means services depend on published contracts (OpenAPI specs, message schemas), not on each other’s internals.
  • Functional programming. SRP applies to modules and functions unchanged. DIP comes from higher-order functions — pass the behavior in instead of injecting a collaborator. LSP matters less without inheritance but still applies to functions that claim the same signature.
  • Configuration and infrastructure. DIP is why infrastructure-as-code modules take injected parameters rather than hardcoding environments.

If you’re evaluating whether a boundary belongs at class or service level, the answer is usually SRP: one reason to change, one team that owns it.

Best Practices

  • Extract by reason to change, not by method count. Two methods that change together belong together; one method that answers to a different stakeholder belongs elsewhere.
  • Introduce an interface when the second implementation appears. Writing PaymentGateway before PayPal exists is guessing; writing it when PayPal lands is OCP.
  • Prefer composition over deep inheritance. Most LSP pain disappears when subclasses stop trying to be their parents and instead use collaborators.
  • Keep the composition root thin. Wire dependencies in one place (main, a DI container) and keep constructors free of new for anything swappable.
  • Let pain drive the refactor. Apply the principle when the change proves the need — a growing if/elif, a test you can’t write, a subclass that surprises.

Common Mistakes

  • Creating a class per method to force SRP. Not every function needs its own class; SRP is about reasons to change, not about size.
  • Using OCP as an excuse for premature abstraction. YAGNI still applies — an interface with one implementation for two years is a guess, not a design.
  • Misapplying LSP to value objects. Immutable types like Money or DateRange aren’t meant to be substitutable; forcing substitutability creates the Square/Rectangle trap.
  • Splitting interfaces until the system fragments. Ten interfaces with one method each is often harder to navigate than the original fat interface.
  • Injecting dependencies everywhere. Trivial, stable utilities (JSON.stringify, uuid) don’t need injection; inject the things that change, fail, or hit the network.

Further Reading

Frequently Asked Questions

Should I apply all SOLID principles to every class?

No — treat them as guidelines, not laws, and apply them where they reduce complexity and coupling. Small scripts and CRUD operations often don't need full SOLID treatment — the cost of extra indirection outweighs the benefit when change is unlikely.

Do SOLID principles apply only to OOP?

No, the concepts translate well. Functional programming gets DIP through higher-order functions, and SRP applies to modules and functions in any model. See the design patterns guide for practical examples across paradigms.

How do SOLID principles come up in interviews?

Expect a per-principle definition plus a "spot the violation" exercise. The two most common prompts are the Square/Rectangle problem for LSP and a fat Worker-style interface for ISP. Being able to refactor a before/after example — like the payment service above — is what separates a memorized answer from a working one.

How do SOLID principles apply to microservices?

SRP maps to service boundaries, OCP to shipping new services instead of editing stable ones, LSP to API contract compatibility across versions, ISP to client-specific endpoints (the BFF pattern), and DIP to depending on published contracts rather than internals.

How do I convince my team to refactor toward SOLID?

Don't refactor for the sake of the principles. Wait until a change is needed, then use them to guide a cleaner design. Before/after comparisons in PRs — especially ones that show a test that was impossible to write before — convince faster than any principle definition.