Skip to content
Commitwise
Menu
Design Patterns

SOLID Principles

The five design principles behind most patterns, shown on one growing checkout codebase, with what each fixes, when it's overkill, and code in Swift, Kotlin, Java and TypeScript.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The checkout code worked fine for a year. Then marketing wanted a new discount, finance wanted a different tax rule, and the email team wanted new wording, all in the same week, all in the same 600-line class. Every change broke something unrelated, and nobody dared to write a test for it.

SOLID is five rules of thumb for keeping code like that changeable. They aren’t laws, and applying all five everywhere is its own mistake. But almost every design pattern on this site is one of them made concrete, and interviewers ask about them constantly.

New to some of these? Open what you need, or skip ahead.

Basics:What are classes and objects?

A class is a blueprint; an object (or instance) is one thing built from it. A User class says every user has a name and an email; User(name: "Ana", email: "ana@example.com") creates one specific user.

  • Properties (fields) are the data each object holds.
  • Methods are functions that belong to the class and work with that data.
  • Swift also has struct, Kotlin has data class and Java has record: shorter ways to define types that mostly hold data.
Basics:What is an interface (or protocol)?

An interface (Kotlin, Java) or protocol (Swift) is a list of things a type promises it can do, without saying how. For example, “anything that is a PaymentMethod has a pay(amount) function”.

Different classes can implement the same interface in different ways (card, PayPal, cash), and code that only knows the interface can use any of them. That’s what lets you swap one implementation for another, for example a fake one in tests.

Basics:What is inheritance?

Inheritance lets one class build on another: class Square extends Rectangle (Java, TypeScript), class Square : Rectangle() (Kotlin), class Square: Rectangle (Swift). The subclass gets the parent’s properties and methods and can override some of them to behave differently.

Code that expects a Rectangle also accepts a Square. Whether that’s actually safe is what the “L” in SOLID is about.

Basics:What are unit tests and fakes?

A unit test is a small piece of code that checks one piece of your app automatically: call a function with known input, and compare the result with what you expect. Tests run in seconds, so you run them after every change.

A fake is a simple stand-in for something slow or risky, like a payment provider or a server. It returns prepared answers instead of charging a card, so the test is fast, repeatable and safe.

Part Not started

S and O: one reason to change, new code instead of edits

Think of a restaurant. The chef cooks, the waiter takes orders, the cashier handles payments. When the menu changes, only the chef’s work changes; when the card machine is replaced, only the cashier’s. If one person did all three jobs, every change would land on them, and a new card machine could somehow ruin dinner.

The single responsibility principleSingle responsibility principleThe S in SOLID: a class should have one reason to change, meaning one group of people whose requests change it.Open in glossary → says the same about code: a class should have one reason to change. Not “one method”, but one job, owned by one group of people who might ask you to change it.

SRP is about reasons to change, which in practice means stakeholders (Robert C. Martin later phrased it as “one actor”). Code that changes for different people at different rates should live in different modules, so a tax change can’t break email wording. It’s cohesion and coupling with a practical test: list who would ask for a change to this class.

OCP, the open/closed principleOpen/closed principleCode should be open for extension but closed for modification: add new behavior without editing what already works.Open in glossary →: extend behavior by adding code, not by editing working code. You can’t be closed against every change; you choose the axis of expected variation (discount rules, payment providers) and put an abstraction there. Guessing the axis wrong produces abstractions nobody uses.

Single responsibility

Before: One class, four reasons to change

final class OrderService {
    func placeOrder(_ order: Order) async throws {
        guard !order.items.isEmpty else { throw OrderError.empty }                 // business rules
        let total = order.items.reduce(0) { $0 + $1.priceCents * $1.quantity }   // pricing
        let db = try await Database.connect("postgres://prod/shop")                // storage
        try await db.insert("orders", id: order.id, totalCents: total)
        try await SMTPClient(host: "smtp.shop.com")                                // email
            .send(to: order.email, body: "Thanks! Your total is \(total) cents.")
    }
}
class OrderService {
    fun placeOrder(order: Order) {
        require(order.items.isNotEmpty()) { "Empty order" }                       // business rules
        val total = order.items.sumOf { it.priceCents * it.quantity }             // pricing
        val db = Database.connect("jdbc:postgresql://prod/shop")                  // storage
        db.insert("orders", order.id, total)
        SmtpClient("smtp.shop.com")                                               // email
            .send(order.email, "Thanks! Your total is $total cents.")
    }
}
class OrderService {
    void placeOrder(Order order) {
        if (order.items().isEmpty()) throw new IllegalArgumentException("Empty order"); // business rules
        int total = order.items().stream()                                              // pricing
                .mapToInt(i -> i.priceCents() * i.quantity()).sum();
        var db = Database.connect("jdbc:postgresql://prod/shop");                       // storage
        db.insert("orders", order.id(), total);
        new SmtpClient("smtp.shop.com")                                                 // email
                .send(order.email(), "Thanks! Your total is " + total + " cents.");
    }
}
class OrderService {
  async placeOrder(order: Order): Promise<void> {
    if (order.items.length === 0) throw new Error("Empty order");                   // business rules
    const total = order.items.reduce((sum, i) => sum + i.priceCents * i.quantity, 0); // pricing
    const db = await Database.connect("postgres://prod/shop");                       // storage
    await db.insert("orders", order.id, total);
    await new SmtpClient("smtp.shop.com")                                            // email
      .send(order.email, `Thanks! Your total is ${total} cents.`);
  }
}

After: One job each; OrderService only coordinates

final class OrderService {
    private let pricing: PricingPolicy
    private let orders: OrderRepository
    private let notifier: OrderNotifier

    init(pricing: PricingPolicy, orders: OrderRepository, notifier: OrderNotifier) {
        self.pricing = pricing
        self.orders = orders
        self.notifier = notifier
    }

    func placeOrder(_ order: Order) async throws {
        guard !order.items.isEmpty else { throw OrderError.empty }
        let total = pricing.totalCents(for: order)
        try await orders.save(order, totalCents: total)
        try await notifier.orderPlaced(order, totalCents: total)
    }
}
class OrderService(
    private val pricing: PricingPolicy,
    private val orders: OrderRepository,
    private val notifier: OrderNotifier,
) {
    fun placeOrder(order: Order) {
        require(order.items.isNotEmpty()) { "Empty order" }
        val total = pricing.totalCents(order)
        orders.save(order, total)
        notifier.orderPlaced(order, total)
    }
}
class OrderService {
    private final PricingPolicy pricing;
    private final OrderRepository orders;
    private final OrderNotifier notifier;

    OrderService(PricingPolicy pricing, OrderRepository orders, OrderNotifier notifier) {
        this.pricing = pricing;
        this.orders = orders;
        this.notifier = notifier;
    }

    void placeOrder(Order order) {
        if (order.items().isEmpty()) throw new IllegalArgumentException("Empty order");
        int total = pricing.totalCents(order);
        orders.save(order, total);
        notifier.orderPlaced(order, total);
    }
}
class OrderService {
  constructor(
    private readonly pricing: PricingPolicy,
    private readonly orders: OrderRepository,
    private readonly notifier: OrderNotifier,
  ) {}

  async placeOrder(order: Order): Promise<void> {
    if (order.items.length === 0) throw new Error("Empty order");
    const total = this.pricing.totalCents(order);
    await this.orders.save(order, total);
    await this.notifier.orderPlaced(order, total);
  }
}

Ask of the first version: who could ask me to change this class? Product managers (the rules), finance (the pricing), the database team (storage), and marketing (the email). Four groups, four reasons to change, and each change risks breaking the other three.

After the split, a new email template touches only OrderNotifier. And because OrderService receives its helpers instead of creating them, a test can give it fakes and check the order logic without a database or an email server.

Open/closed

The next request: discounts. First 5 EUR off orders with three or more items, then a student discount, then a weekend sale. Instead of growing an if chain inside PricingPolicy, make each discount a rule and let the policy apply whatever rules it’s given:

protocol DiscountRule {
    func discountCents(for order: Order) -> Int
}

struct BulkDiscount: DiscountRule {
    func discountCents(for order: Order) -> Int { order.items.count >= 3 ? 500 : 0 }
}

struct PricingPolicy {
    let rules: [any DiscountRule]

    func totalCents(for order: Order) -> Int {
        let subtotal = order.items.reduce(0) { $0 + $1.priceCents * $1.quantity }
        let discount = rules.reduce(0) { $0 + $1.discountCents(for: order) }
        return max(0, subtotal - discount)
    }
}
fun interface DiscountRule {
    fun discountCents(order: Order): Int
}

val bulkDiscount = DiscountRule { order -> if (order.items.size >= 3) 500 else 0 }

class PricingPolicy(private val rules: List<DiscountRule>) {
    fun totalCents(order: Order): Int {
        val subtotal = order.items.sumOf { it.priceCents * it.quantity }
        val discount = rules.sumOf { it.discountCents(order) }
        return maxOf(0, subtotal - discount)
    }
}
@FunctionalInterface
interface DiscountRule {
    int discountCents(Order order);
}

DiscountRule bulkDiscount = order -> order.items().size() >= 3 ? 500 : 0;

class PricingPolicy {
    private final List<DiscountRule> rules;

    PricingPolicy(List<DiscountRule> rules) {
        this.rules = List.copyOf(rules);
    }

    int totalCents(Order order) {
        int subtotal = order.items().stream().mapToInt(i -> i.priceCents() * i.quantity()).sum();
        int discount = rules.stream().mapToInt(r -> r.discountCents(order)).sum();
        return Math.max(0, subtotal - discount);
    }
}
type DiscountRule = (order: Order) => number;

const bulkDiscount: DiscountRule = (order) => (order.items.length >= 3 ? 500 : 0);

class PricingPolicy {
  constructor(private readonly rules: readonly DiscountRule[]) {}

  totalCents(order: Order): number {
    const subtotal = order.items.reduce((sum, i) => sum + i.priceCents * i.quantity, 0);
    const discount = this.rules.reduce((sum, rule) => sum + rule(order), 0);
    return Math.max(0, subtotal - discount);
  }
}

The student discount is now a new rule added to the list, and PricingPolicy, which already works and is tested, doesn’t change. That’s what “open for extension, closed for modification” means. Swapping one algorithm for another like this has its own pattern, Strategy.

Don’t split too far

SRP doesn’t mean “one method per class”. A PricingPolicy with subtotal, discount and total methods has one reason to change (the pricing rules), so it stays one class. Splitting it into SubtotalCalculator, DiscountCalculator and TotalCalculator only means three files to open for every pricing change.

A good test: if two pieces of code always change together, they belong together. If they change for different people, at different times, separate them.

Where SRP and OCP go wrong

  • SRP taken to the method level produces anemic, scattered code: a dozen one-method classes whose behavior you can only follow with a debugger. Cohesion matters as much as separation; a class that does one job may well have ten methods.
  • OCP everywhere is speculative generality. A when/switch over a closed set you own (three order states) is fine, and a sealed type makes the compiler list every place to update. Introduce the extension point the second or third time the same axis changes, not on day one.
  • The expression problem decides the shape: interfaces make new variants cheap and new operations expensive; sealed types with exhaustive switch are the opposite. Pick by which one you expect to grow.
Deep diveWhere SOLID came fromInterview favorite

Robert C. Martin collected the principles in essays around 2000, and Michael Feathers coined the acronym. The open/closed principle is older: Bertrand Meyer described it in 1988, originally in terms of inheritance (extend a class without editing it). The modern reading, through interfaces and composition, is Martin’s. Liskov’s principle comes from Barbara Liskov’s 1987 keynote on data abstraction, formalized with Jeannette Wing in 1994.

Interviewers rarely want the history. They want an example of each principle from code you’ve written, and a case where applying one would have made things worse.

  • SRP: one reason to change = one stakeholder. Split code that changes for different people at different rates; keep together what always changes together.
  • OCP: choose the axis of expected variation and put an abstraction there (a rule list, a strategy, a plugin). Don’t abstract axes that never changed.
  • Interfaces make new variants cheap; sealed types + exhaustive switch make new operations cheap and variant edits compiler-checked.
SwiftKotlinJavaTypeScript
Lightweight extension pointClosure (Order) -> Int or protocolfun interface / lambda@FunctionalInterface + lambdaFunction type
Closed set, checked by compilerenum + switchsealed interface + whensealed interface + switch (21+)Discriminated union + never check
struct PricingPolicy {
    let rules: [(Order) -> Int]
    func totalCents(_ o: Order) -> Int { max(0, o.subtotalCents - rules.reduce(0) { $0 + $1(o) }) }
}
class PricingPolicy(private val rules: List<(Order) -> Int>) {
    fun totalCents(o: Order) = maxOf(0, o.subtotalCents - rules.sumOf { it(o) })
}
record PricingPolicy(List<ToIntFunction<Order>> rules) {
    int totalCents(Order o) { return Math.max(0, o.subtotalCents() - rules.stream().mapToInt(r -> r.applyAsInt(o)).sum()); }
}
const totalCents = (rules: ((o: Order) => number)[]) => (o: Order) =>
  Math.max(0, o.subtotalCents - rules.reduce((s, r) => s + r(o), 0));

Check this part

  1. What does "a class should have one reason to change" mean?

  2. Marketing adds a student discount. With discount rules, what changes?

  3. An order has three fixed states that you own and rarely change. Should you replace the switch with a State interface for OCP?

  4. What is the practical test for SRP?

Single responsibility: a class should have one reason to change, meaning one group of people who ask for changes. Open/closed: add new behavior by adding code (a new rule, a new type), not by editing code that works.

Part Not started

L and I: subtypes that keep promises, interfaces that stay small

Imagine renting a car. The booking says “a compact car”, and you get a different model than in the picture. That’s fine: it has a steering wheel, pedals and four seats, and you can drive it like any compact car. If it turned out to need a special license, the substitution would be broken.

The Liskov substitution principleLiskov substitution principleThe L in SOLID: a subtype must work everywhere its parent type is expected, without surprising the caller.Open in glossary → says the same about types: anywhere your code expects a type, any subtype of it must work too, without the caller having to know which one it got.

LSP is about behavioral subtyping: a subtype must honor the supertype’s contract, not just its signatures. It may not strengthen preconditions (accept less), weaken postconditions (promise less), or break invariants. Contracts include the unwritten parts too: “doesn’t throw for valid input”, “thread-safe”, “charges at most once”, “returns quickly”. The compiler checks signatures; LSP violations show up as instanceof checks and UnsupportedOperationException in callers.

ISP: clients shouldn’t depend on methods they don’t use. Fat interfaces force implementers to stub methods (an LSP violation waiting to happen) and couple unrelated clients, so a change for one recompiles, retests and redeploys the others.

A subtype that breaks its promise

protocol DocumentStorage {
    func load(_ id: String) async throws -> Document?
    func save(_ document: Document) async throws
}

struct CloudStorage: DocumentStorage { /* loads and saves */ }

struct ReadOnlyBackup: DocumentStorage {
    func load(_ id: String) async throws -> Document? { /* reads the backup */ }
    func save(_ document: Document) async throws {
        throw StorageError.readOnly                  // can't keep the promise
    }
}

func archive(_ documents: [Document], to storage: any DocumentStorage) async throws {
    for document in documents {
        try await storage.save(document)              // fails halfway with ReadOnlyBackup
    }
}
interface DocumentStorage {
    fun load(id: String): Document?
    fun save(document: Document)
}

class CloudStorage : DocumentStorage { /* loads and saves */ }

class ReadOnlyBackup : DocumentStorage {
    override fun load(id: String): Document? { /* reads the backup */ }
    override fun save(document: Document) {
        throw UnsupportedOperationException("read-only")   // can't keep the promise
    }
}

fun archive(documents: List<Document>, storage: DocumentStorage) {
    documents.forEach { storage.save(it) }                  // fails halfway with ReadOnlyBackup
}
interface DocumentStorage {
    Optional<Document> load(String id);
    void save(Document document);
}

class CloudStorage implements DocumentStorage { /* loads and saves */ }

class ReadOnlyBackup implements DocumentStorage {
    public Optional<Document> load(String id) { /* reads the backup */ }
    public void save(Document document) {
        throw new UnsupportedOperationException("read-only");   // can't keep the promise
    }
}

static void archive(List<Document> documents, DocumentStorage storage) {
    documents.forEach(storage::save);                           // fails halfway with ReadOnlyBackup
}
interface DocumentStorage {
  load(id: string): Promise<Document | undefined>;
  save(document: Document): Promise<void>;
}

class CloudStorage implements DocumentStorage { /* loads and saves */ }

class ReadOnlyBackup implements DocumentStorage {
  async load(id: string) { /* reads the backup */ }
  async save(document: Document): Promise<void> {
    throw new Error("read-only");                     // can't keep the promise
  }
}

async function archive(documents: Document[], storage: DocumentStorage) {
  for (const d of documents) await storage.save(d);   // fails halfway with ReadOnlyBackup
}

ReadOnlyBackup says it’s a DocumentStorage, so the compiler happily lets you pass it to archive. But it can’t do half of what a DocumentStorage promises. The caller finds out at runtime, in the middle of the loop, with some documents saved and some not.

The usual “fix” makes it worse: archive starts checking if storage is ReadOnlyBackup, and now every caller must know about every subtype.

Small interfaces fix it

The real problem is that DocumentStorage mixes two abilities. The interface segregation principleInterface segregation principleThe I in SOLID: prefer small interfaces shaped by what each client needs over one large interface.Open in glossary → says: split it, so each type promises only what it can do, and each function asks only for what it needs.

protocol DocumentReader { func load(_ id: String) async throws -> Document? }
protocol DocumentWriter { func save(_ document: Document) async throws }

struct CloudStorage: DocumentReader, DocumentWriter { /* both */ }
struct ReadOnlyBackup: DocumentReader { /* only load */ }

func archive(_ documents: [Document], to writer: any DocumentWriter) async throws { /* … */ }
func restore(_ id: String, from reader: any DocumentReader) async throws -> Document? { /* … */ }
interface DocumentReader { fun load(id: String): Document? }
interface DocumentWriter { fun save(document: Document) }

class CloudStorage : DocumentReader, DocumentWriter { /* both */ }
class ReadOnlyBackup : DocumentReader { /* only load */ }

fun archive(documents: List<Document>, writer: DocumentWriter) { /* … */ }
fun restore(id: String, reader: DocumentReader): Document? { /* … */ }
interface DocumentReader { Optional<Document> load(String id); }
interface DocumentWriter { void save(Document document); }

class CloudStorage implements DocumentReader, DocumentWriter { /* both */ }
class ReadOnlyBackup implements DocumentReader { /* only load */ }

static void archive(List<Document> documents, DocumentWriter writer) { /* … */ }
static Optional<Document> restore(String id, DocumentReader reader) { /* … */ }
interface DocumentReader { load(id: string): Promise<Document | undefined>; }
interface DocumentWriter { save(document: Document): Promise<void>; }

class CloudStorage implements DocumentReader, DocumentWriter { /* both */ }
class ReadOnlyBackup implements DocumentReader { /* only load */ }

async function archive(documents: Document[], writer: DocumentWriter) { /* … */ }
async function restore(id: string, reader: DocumentReader) { /* … */ }

Now passing ReadOnlyBackup to archive is a compile error instead of a crash at 2 a.m. The two principles work together: small interfaces (I) make it easy for every type to keep all its promises (L).

Kotlin note

Kotlin’s standard library does exactly this: List only reads, MutableList adds add and remove. A function that takes List<T> promises not to change it.

Java note

Java’s own collections break this rule: List.of(…) and Collections.unmodifiableList return a List whose add throws UnsupportedOperationException. The List interface calls these “optional operations”, which is a polite name for an LSP problem. Document whether your methods modify a list they receive.

The square that isn’t a rectangle

A favorite interview question: in math, a square is a rectangle. So Square should inherit from Rectangle, right? Here’s what happens when a function written for rectangles gets a square:

Predict the output

What does this print?

class Rectangle {
    var width = 0
    var height = 0
    var area: Int { width * height }
}

class Square: Rectangle {
    override var width: Int { didSet { if height != width { height = width } } }
    override var height: Int { didSet { if width != height { width = height } } }
}

func resize(_ r: Rectangle) {
    r.width = 5
    r.height = 2
    print(r.area)
}

resize(Square())
open class Rectangle {
    open var width = 0
    open var height = 0
    val area get() = width * height
}

class Square : Rectangle() {
    override var width: Int
        get() = super.width
        set(value) { super.width = value; super.height = value }
    override var height: Int
        get() = super.height
        set(value) { super.width = value; super.height = value }
}

fun resize(r: Rectangle) {
    r.width = 5
    r.height = 2
    println(r.area)
}

fun main() = resize(Square())
class Rectangle {
    protected int width, height;
    void setWidth(int w) { width = w; }
    void setHeight(int h) { height = h; }
    int area() { return width * height; }
}

class Square extends Rectangle {
    @Override void setWidth(int w) { width = w; height = w; }
    @Override void setHeight(int h) { width = h; height = h; }
}

static void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(2);
    System.out.println(r.area());
}

resize(new Square());
class Rectangle {
  constructor(protected w = 0, protected h = 0) {}
  setWidth(w: number) { this.w = w; }
  setHeight(h: number) { this.h = h; }
  area() { return this.w * this.h; }
}

class Square extends Rectangle {
  override setWidth(w: number) { this.w = w; this.h = w; }
  override setHeight(h: number) { this.w = h; this.h = h; }
}

function resize(r: Rectangle) {
  r.setWidth(5);
  r.setHeight(2);
  console.log(r.area());
}

resize(new Square());

“Is a” in the real world isn’t enough. The question is: can a subtype do everything the parent promises? A mutable Rectangle promises “width and height change independently”, and a square can’t.

Spotting LSP and ISP problems in review

SmellUsually means
throw UnsupportedOperationException / fatalError("not supported") in an overrideThe type doesn’t fit the interface: split it (ISP)
if (x is Subtype) / instanceof in a callerCallers depend on a subtype’s different contract
Empty method bodies to satisfy an interfaceFat interface; the implementer only needs part
A subclass that adds a precondition (“call open() first”)Strengthened precondition; existing callers will break
A decorator that retries, caches or batchesPossibly weakened postconditions: at-most-once, freshness, ordering
One interface used by five clients, each calling two methodsRole interfaces per client, implemented by one class

ISP doesn’t mean one method per interface. It means interfaces shaped by clients’ needs (role interfaces), and one class may implement several of them.

  • LSP: subtypes honor the contract, not just signatures. No stronger preconditions, no weaker postconditions, invariants kept, including unwritten ones (no throws for valid input, thread safety, at-most-once side effects).
  • Symptoms: UnsupportedOperationException in overrides, instanceof in callers, decorators that silently change semantics.
  • ISP: role interfaces shaped by clients; one class may implement several. Splitting read/write often removes the LSP violation.
  • Mutable Square extends Rectangle is the canonical violation: equal sides break independent setters.
SwiftKotlinJavaTypeScript
Read-only vs mutable collectionslet / var on value-type ArrayList vs MutableListList with optional ops (List.of throws on add)ReadonlyArray<T> vs T[] (compile-time only)
Prevent unintended subclassingfinal classClasses final by defaultfinal, sealedNo final; prefer composition
How conformance is checkedNominal (declared)NominalNominalStructural: any object with the right shape fits

Check this part

  1. ReadOnlyBackup implements DocumentStorage and throws in save. What principle does it break?

  2. How does splitting the interface into DocumentReader and DocumentWriter help?

  3. A subclass of Connection requires calling open() before send(), which the base class never needed. What did it do?

  4. What usually signals an ISP problem in review?

Liskov substitution: a subtype must work everywhere its parent type is expected, without surprises like exceptions or changed results. Interface segregation: keep interfaces small, so nobody must implement methods they can't support.

Part Not started

D: business code doesn’t depend on details

A lamp doesn’t care which power plant made its electricity. It has a plug, the wall has a socket, and any plant, solar panel or generator that delivers through that socket will do. The socket is the agreement, and both sides depend on it.

The dependency inversion principleDependency inversion principleThe D in SOLID: business code defines the interfaces it needs, and details like databases and vendors implement them.Open in glossary → says your important code (the business rules) should work the same way: it defines the socket, an interface like PaymentGateway, and the details (Stripe, PayPal, a fake in tests) plug into it.

DIP: high-level policy shouldn’t depend on low-level details; both depend on abstractions, and the abstraction is owned by the policy side. The “inversion” is of source-code dependencies against the flow of control: at runtime checkout calls Stripe, but in the source, StripeGateway imports the domain’s PaymentGateway, never the other way round.

DIP is a design rule; dependency injectionDependency injectionGiving an object the things it needs from outside, instead of letting it create them itself.Open in glossary → is one mechanism to satisfy it (passing implementations in from outside). You can inject concrete classes and still violate DIP; see Dependency Injection.

Before: Checkout creates and names Stripe

final class CheckoutService {
    private let stripe = StripeClient(apiKey: Secrets.stripeKey)

    func pay(_ cart: Cart) async throws -> Receipt {
        let charge = try await stripe.createCharge(cents: cart.totalCents, token: cart.cardToken)
        return Receipt(paymentID: charge.id, totalCents: cart.totalCents)
    }
}
class CheckoutService {
    private val stripe = StripeClient(apiKey = Secrets.stripeKey)

    fun pay(cart: Cart): Receipt {
        val charge = stripe.createCharge(cents = cart.totalCents, token = cart.cardToken)
        return Receipt(paymentId = charge.id, totalCents = cart.totalCents)
    }
}
class CheckoutService {
    private final StripeClient stripe = new StripeClient(Secrets.stripeKey());

    Receipt pay(Cart cart) {
        StripeCharge charge = stripe.createCharge(cart.totalCents(), cart.cardToken());
        return new Receipt(charge.id(), cart.totalCents());
    }
}
class CheckoutService {
  private readonly stripe = new StripeClient(Secrets.stripeKey);

  async pay(cart: Cart): Promise<Receipt> {
    const charge = await this.stripe.createCharge(cart.totalCents, cart.cardToken);
    return { paymentId: charge.id, totalCents: cart.totalCents };
  }
}

After: Checkout owns the interface; Stripe plugs in

// Business code: defines what it needs
protocol PaymentGateway {
    func charge(cents: Int, cardToken: String) async throws -> String   // returns a payment id
}

final class CheckoutService {
    private let payments: any PaymentGateway
    init(payments: any PaymentGateway) { self.payments = payments }

    func pay(_ cart: Cart) async throws -> Receipt {
        let id = try await payments.charge(cents: cart.totalCents, cardToken: cart.cardToken)
        return Receipt(paymentID: id, totalCents: cart.totalCents)
    }
}

// Infrastructure: depends on the business interface
struct StripeGateway: PaymentGateway {
    let client: StripeClient
    func charge(cents: Int, cardToken: String) async throws -> String {
        try await client.createCharge(cents: cents, token: cardToken).id
    }
}
// Business code: defines what it needs
interface PaymentGateway {
    fun charge(cents: Int, cardToken: String): String   // returns a payment id
}

class CheckoutService(private val payments: PaymentGateway) {
    fun pay(cart: Cart): Receipt {
        val id = payments.charge(cart.totalCents, cart.cardToken)
        return Receipt(paymentId = id, totalCents = cart.totalCents)
    }
}

// Infrastructure: depends on the business interface
class StripeGateway(private val client: StripeClient) : PaymentGateway {
    override fun charge(cents: Int, cardToken: String) =
        client.createCharge(cents = cents, token = cardToken).id
}
// Business code: defines what it needs
interface PaymentGateway {
    String charge(int cents, String cardToken);   // returns a payment id
}

class CheckoutService {
    private final PaymentGateway payments;
    CheckoutService(PaymentGateway payments) { this.payments = payments; }

    Receipt pay(Cart cart) {
        String id = payments.charge(cart.totalCents(), cart.cardToken());
        return new Receipt(id, cart.totalCents());
    }
}

// Infrastructure: depends on the business interface
class StripeGateway implements PaymentGateway {
    private final StripeClient client;
    StripeGateway(StripeClient client) { this.client = client; }

    public String charge(int cents, String cardToken) {
        return client.createCharge(cents, cardToken).id();
    }
}
// Business code: defines what it needs
interface PaymentGateway {
  charge(cents: number, cardToken: string): Promise<string>; // returns a payment id
}

class CheckoutService {
  constructor(private readonly payments: PaymentGateway) {}

  async pay(cart: Cart): Promise<Receipt> {
    const id = await this.payments.charge(cart.totalCents, cart.cardToken);
    return { paymentId: id, totalCents: cart.totalCents };
  }
}

// Infrastructure: depends on the business interface
class StripeGateway implements PaymentGateway {
  constructor(private readonly client: StripeClient) {}
  async charge(cents: number, cardToken: string) {
    return (await this.client.createCharge(cents, cardToken)).id;
  }
}

Three things changed:

  1. The interface belongs to checkout. PaymentGateway speaks checkout’s language (cents, a card token, a payment id), not Stripe’s.
  2. Checkout never creates or names Stripe. It gets “some payment gateway” in its constructor.
  3. Stripe code adapts to checkout, not the other way around. Switching to another provider means one new class; CheckoutService and its tests don’t change.

In a test, you pass a fake gateway that returns "test-payment-1" and records what it was asked to charge. No network, no real cards, and the test runs in milliseconds.

Practice

Find the responsibilities, step by step

Here’s a method from a real-looking app. Before you change anything, list who would ask for changes:

fun sendWeeklyReport() {
    val rows = Database.query("SELECT product, SUM(qty) FROM sales WHERE ...")   // data access
    val top = rows.sortedByDescending { it.qty }.take(10)                        // business rule
    val html = "<h1>Top products</h1>" + top.joinToString("") { "<li>${it.product}</li>" } // formatting
    SmtpClient("smtp.shop.com").send("team@shop.com", "Weekly report", html)      // delivery
}
  1. Data access: the database team changes the schema. → a SalesRepository.
  2. Business rule: product managers want the top 20, or exclude gift cards. → a TopProducts function.
  3. Formatting: the design team wants a new layout or a PDF. → a ReportFormatter.
  4. Delivery: ops moves from SMTP to an email API, or adds Slack. → a ReportSender interface (DIP), with an email implementation.
  5. What’s left is a short coordinator that calls the four, and a test that gives it fakes and checks what was “sent”.
Spot the bugA unit test that charged a real card

Tap the line with the bug.

final class CheckoutService {
    private let payments = StripeClient(apiKey: Secrets.stripeKey)

    func pay(_ cart: Cart) async throws -> Receipt {
        let charge = try await payments.createCharge(cents: cart.totalCents, token: cart.cardToken)
        return Receipt(paymentID: charge.id, totalCents: cart.totalCents)
    }
}
class CheckoutService {
    private val payments = StripeClient(apiKey = Secrets.stripeKey)

    fun pay(cart: Cart): Receipt {
        val charge = payments.createCharge(cents = cart.totalCents, token = cart.cardToken)
        return Receipt(paymentId = charge.id, totalCents = cart.totalCents)
    }
}
class CheckoutService {
    private final StripeClient payments = new StripeClient(Secrets.stripeKey());

    Receipt pay(Cart cart) {
        StripeCharge charge = payments.createCharge(cart.totalCents(), cart.cardToken());
        return new Receipt(charge.id(), cart.totalCents());
    }
}
class CheckoutService {
  private readonly payments = new StripeClient(Secrets.stripeKey);

  async pay(cart: Cart): Promise<Receipt> {
    const charge = await this.payments.createCharge(cart.totalCents, cart.cardToken);
    return { paymentId: charge.id, totalCents: cart.totalCents };
  }
}

Exerciseeasy

Notifications without the if chain

This service sends a message on the user’s preferred channel:

class NotificationService {
    fun notify(user: User, message: String) {
        if (user.channel == "email") {
            SmtpClient("smtp.shop.com").send(user.email, message)
        } else if (user.channel == "sms") {
            SmsApi(Secrets.smsKey).send(user.phone, message)
        } else if (user.channel == "push") {
            PushService.shared.send(user.deviceToken, message)
        }
    }
}

Product wants WhatsApp next month, and the team wants tests that don’t send real messages. Redesign it using the principles from this lesson. Which ones apply?

›

Each channel is a variant that can be added later (OCP), and NotificationService shouldn’t create real clients (DIP). What interface would every channel implement?

›

NotificationService can receive a list (or a map) of channels and pick the one whose id matches user.channel.

SolutionTry it yourself first
protocol NotificationChannel {
    var id: String { get }
    func send(_ message: String, to user: User) async throws
}

struct EmailChannel: NotificationChannel {
    let id = "email"
    let smtp: SMTPClient
    func send(_ message: String, to user: User) async throws {
        try await smtp.send(to: user.email, body: message)
    }
}

final class NotificationService {
    private let channels: [String: any NotificationChannel]

    init(channels: [any NotificationChannel]) {
        self.channels = Dictionary(uniqueKeysWithValues: channels.map { ($0.id, $0) })
    }

    func notify(_ user: User, _ message: String) async throws {
        guard let channel = channels[user.channel] else { throw NotificationError.unknownChannel(user.channel) }
        try await channel.send(message, to: user)
    }
}
interface NotificationChannel {
    val id: String
    fun send(user: User, message: String)
}

class EmailChannel(private val smtp: SmtpClient) : NotificationChannel {
    override val id = "email"
    override fun send(user: User, message: String) = smtp.send(user.email, message)
}

class NotificationService(channels: List<NotificationChannel>) {
    private val byId = channels.associateBy { it.id }

    fun notify(user: User, message: String) {
        val channel = byId[user.channel] ?: error("Unknown channel ${user.channel}")
        channel.send(user, message)
    }
}
interface NotificationChannel {
    String id();
    void send(User user, String message);
}

class EmailChannel implements NotificationChannel {
    private final SmtpClient smtp;
    EmailChannel(SmtpClient smtp) { this.smtp = smtp; }
    public String id() { return "email"; }
    public void send(User user, String message) { smtp.send(user.email(), message); }
}

class NotificationService {
    private final Map<String, NotificationChannel> byId;

    NotificationService(List<NotificationChannel> channels) {
        this.byId = channels.stream().collect(Collectors.toMap(NotificationChannel::id, c -> c));
    }

    void notify(User user, String message) {
        var channel = byId.get(user.channel());
        if (channel == null) throw new IllegalArgumentException("Unknown channel " + user.channel());
        channel.send(user, message);
    }
}
interface NotificationChannel {
  readonly id: string;
  send(user: User, message: string): Promise<void>;
}

class EmailChannel implements NotificationChannel {
  readonly id = "email";
  constructor(private readonly smtp: SmtpClient) {}
  send(user: User, message: string) { return this.smtp.send(user.email, message); }
}

class NotificationService {
  private readonly byId: Map<string, NotificationChannel>;

  constructor(channels: NotificationChannel[]) {
    this.byId = new Map(channels.map((c) => [c.id, c]));
  }

  async notify(user: User, message: string) {
    const channel = this.byId.get(user.channel);
    if (!channel) throw new Error(`Unknown channel ${user.channel}`);
    await channel.send(user, message);
  }
}
  • O: WhatsApp is a new WhatsAppChannel added to the list where the app is assembled. NotificationService doesn’t change.
  • D: the service depends on NotificationChannel, which it owns; real clients are created outside. A test passes a FakeChannel that records messages.
  • S: each channel class has one reason to change, its own provider’s API.
  • An unknown channel is now an explicit error instead of a silent no-op, which the original if chain hid.

Where the interface lives

DIP is mostly about ownership and packaging. If PaymentGateway lives in the stripe module, the domain still depends on that module, and the inversion didn’t happen. Put the interface next to the code that uses it (the domain or application layer), and the implementation in an adapter module that depends on the domain. That’s the core of hexagonal (ports and adapters) and clean architecture: dependencies point inward, toward policy.

Don’t invert everything. Depending directly on stable, well-designed libraries (the standard library, java.time, a JSON library behind a small seam) is fine. Invert the dependencies that are volatile (vendors, I/O, frameworks you might replace) or hard to test (network, clock, randomness).

Production puzzles

Predict the output

cart.addAll with three items. What does it print?

class Cart {
    private(set) var items: [Item] = []
    func add(_ item: Item) { items.append(item) }
    func addAll(_ newItems: [Item]) { newItems.forEach { add($0) } }
}

final class CountingCart: Cart {
    private(set) var added = 0
    override func add(_ item: Item) { added += 1; super.add(item) }
    override func addAll(_ newItems: [Item]) { added += newItems.count; super.addAll(newItems) }
}

let cart = CountingCart()
cart.addAll([coffee, tea, cake])
print(cart.added)
open class Cart {
    val items = mutableListOf<Item>()
    open fun add(item: Item) { items += item }
    open fun addAll(newItems: List<Item>) = newItems.forEach { add(it) }
}

class CountingCart : Cart() {
    var added = 0
        private set
    override fun add(item: Item) { added++; super.add(item) }
    override fun addAll(newItems: List<Item>) { added += newItems.size; super.addAll(newItems) }
}

val cart = CountingCart()
cart.addAll(listOf(coffee, tea, cake))
println(cart.added)
class Cart {
    protected final List<Item> items = new ArrayList<>();
    void add(Item item) { items.add(item); }
    void addAll(List<Item> newItems) { newItems.forEach(this::add); }
}

class CountingCart extends Cart {
    int added = 0;
    @Override void add(Item item) { added++; super.add(item); }
    @Override void addAll(List<Item> newItems) { added += newItems.size(); super.addAll(newItems); }
}

var cart = new CountingCart();
cart.addAll(List.of(coffee, tea, cake));
System.out.println(cart.added);
class Cart {
  readonly items: Item[] = [];
  add(item: Item) { this.items.push(item); }
  addAll(newItems: Item[]) { newItems.forEach((i) => this.add(i)); }
}

class CountingCart extends Cart {
  added = 0;
  override add(item: Item) { this.added++; super.add(item); }
  override addAll(newItems: Item[]) { this.added += newItems.length; super.addAll(newItems); }
}

const cart = new CountingCart();
cart.addAll([coffee, tea, cake]);
console.log(cart.added);
Spot the bugCustomers charged twice

Tap the line with the bug.

struct RetryingGateway: PaymentGateway {
    let inner: any PaymentGateway

    func charge(cents: Int, cardToken: String) async throws -> String {
        for attempt in 1...3 {
            do {
                return try await inner.charge(cents: cents, cardToken: cardToken)
            } catch PaymentError.timeout where attempt < 3 {
                continue
            }
        }
        throw PaymentError.timeout
    }
}
class RetryingGateway(private val inner: PaymentGateway) : PaymentGateway {
    override fun charge(cents: Int, cardToken: String): String {
        repeat(2) {
            try {
                return inner.charge(cents, cardToken)
            } catch (e: PaymentTimeoutException) {
                // try again
            }
        }
        return inner.charge(cents, cardToken)
    }
}
class RetryingGateway implements PaymentGateway {
    private final PaymentGateway inner;
    RetryingGateway(PaymentGateway inner) { this.inner = inner; }

    public String charge(int cents, String cardToken) {
        for (int attempt = 1; ; attempt++) {
            try {
                return inner.charge(cents, cardToken);
            } catch (PaymentTimeoutException e) {
                if (attempt == 3) throw e;
            }
        }
    }
}
class RetryingGateway implements PaymentGateway {
  constructor(private readonly inner: PaymentGateway) {}

  async charge(cents: number, cardToken: string): Promise<string> {
    for (let attempt = 1; ; attempt++) {
      try {
        return await this.inner.charge(cents, cardToken);
      } catch (e) {
        if (!(e instanceof PaymentTimeoutError) || attempt === 3) throw e;
      }
    }
  }
}

Exercisemedium

Review a reporting module

A 900-line ReportService builds sales, inventory and tax reports. It queries the database with raw SQL, formats HTML and CSV, emails the result, and has if (reportType == "tax" && country == "DE") branches for country rules. It has no tests. Tax rules change every quarter; a new export format (PDF) is planned; nothing else is expected to change.

Write a review: which principles apply, what you’d change first, and what you’d deliberately not change.

›

Start from change frequency. What changes every quarter, what’s planned, and what’s stable? SOLID is worth applying where change happens.

›

You can’t refactor safely without tests, and you can’t test while the class talks to a real database and mail server. What’s the smallest seam that lets you write the first test?

SolutionTry it yourself first

First, a seam for tests (DIP). Introduce SalesData and ReportDelivery interfaces owned by the reporting code, with the existing SQL and SMTP code moved behind them unchanged. Now characterization tests can pin the current output with fakes. No behavior changes yet.

Then the axis that actually varies: tax rules (OCP + SRP). Quarterly changes go in one place, one rule per country, selected by a map:

protocol TaxRule { func tax(for line: SaleLine) -> Int }
let taxRules: [String: any TaxRule] = ["DE": GermanTax(), "RS": SerbianTax()]
fun interface TaxRule { fun tax(line: SaleLine): Int }
val taxRules: Map<String, TaxRule> = mapOf("DE" to GermanTax(), "RS" to SerbianTax())
@FunctionalInterface interface TaxRule { int tax(SaleLine line); }
Map<String, TaxRule> taxRules = Map.of("DE", new GermanTax(), "RS", new SerbianTax());
type TaxRule = (line: SaleLine) => number;
const taxRules: Record<string, TaxRule> = { DE: germanTax, RS: serbianTax };

Then the planned change: formats (OCP). A ReportFormatter interface with HTML and CSV implementations extracted from the existing code; PDF becomes a new class.

Deliberately not:

  • No interface per report type or per query: the three report types are a closed, stable set, and an enum or sealed type is clearer.
  • No repository abstraction beyond the SalesData seam: the database isn’t expected to change.
  • No splitting ReportService further “for SRP” until a second axis of change shows up.

In an interview, that last list matters as much as the first. Seniors are expected to know when the principles cost more than they save.

  • DIP: policy owns the abstraction; adapters implement it. Source dependencies point inward, against the flow of control. Packaging matters: an interface in the vendor module inverts nothing.
  • DIP is the rule, dependency injection a mechanism. Invert volatile or hard-to-test dependencies (vendors, I/O, clock, randomness), not stable libraries.
  • Decorators and subclasses must keep contracts: a retrying gateway without an idempotency key turns “at most once” into “up to N times”.
  • Inheritance couples to implementation details (addAll calling add); prefer composition, and final by default.
protocol PaymentGateway { func charge(cents: Int, cardToken: String, idempotencyKey: String) async throws -> String }
struct StripeGateway: PaymentGateway { let client: StripeClient /* adapts Stripe to the port */ }
interface PaymentGateway { fun charge(cents: Int, cardToken: String, idempotencyKey: String): String }
class StripeGateway(private val client: StripeClient) : PaymentGateway { /* adapts Stripe to the port */ }
interface PaymentGateway { String charge(int cents, String cardToken, String idempotencyKey); }
final class StripeGateway implements PaymentGateway { /* adapts Stripe to the port */ }
interface PaymentGateway { charge(cents: number, cardToken: string, idempotencyKey: string): Promise<string>; }
class StripeGateway implements PaymentGateway { /* adapts Stripe to the port */ }

Check this part

  1. Who should define the PaymentGateway interface?

  2. A service creates StripeClient() inside itself. What's the main problem?

  3. What's the difference between dependency inversion and dependency injection?

  4. A decorator retries charge on timeout. What must the contract include to keep it safe?

Dependency inversion: business code defines the interfaces it needs (PaymentGateway), and infrastructure (Stripe, a database) implements them. Business code never creates or names the concrete classes, so it can be tested and changed in isolation.

Key takeaways

  • S: one job per class, meaning one kind of request changes it.
  • O: add new behavior as new code (a rule, a class), not as edits to code that works.
  • L: a subtype must work everywhere its parent is expected; throwing “not supported” is a warning sign.
  • I: small interfaces, so types only promise what they can do.
  • D: business code defines the interfaces it needs and gets implementations from outside, which makes it testable.
  • SRP is about stakeholders and change rates; OCP is about choosing the right axis, not abstracting everything.
  • LSP covers the whole contract, including unwritten promises like at-most-once and thread safety; decorators break it as easily as subclasses.
  • ISP: role interfaces shaped by clients; splitting read/write often fixes LSP too.
  • DIP is ownership and packaging: policy owns ports, adapters depend inward. Apply all five where change happens, and say where you wouldn’t.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.