Vasu Patel logoVasu Patel
← Objects First: OOP Fundamentals
All chapters in this track
  1. 01Chapter 1: Why Object Orientation Exists
  2. 02Chapter 2: Classes, Objects, and Instances, Precisely Defined
  3. 03Chapter 3: The Four Pillars I: Encapsulation
  4. 04Chapter 4: The Four Pillars II: Abstraction
  5. 05Chapter 5: The Four Pillars III: Inheritance
  6. 06Chapter 6: The Four Pillars IV: Polymorphism
  7. 07Chapter 7: Composition vs. Inheritance
  8. 08Chapter 8: Interfaces and Abstract Classes: When to Use Which
  9. 09Chapter 9: SOLID, Part 1: Single Responsibility & Open/Closed
  10. 10Chapter 10: SOLID, Part 2: Liskov Substitution, Interface Segregation, Dependency Inversion
  11. 11Chapter 11: Basic UML for Communicating Design
  12. 12Chapter 12: Anti-Patterns: God Objects, Tight Coupling, Deep Hierarchies, the Fragile Base Class, Anemic Domain Models
  13. 13Chapter 13: OOP Idioms Across TypeScript and Python — and Where OOP Is the Wrong Tool
  14. 14Chapter 14: When OOP Breaks Down: Real-World Failure Modes
  15. 15Chapter 15: Design Exercise: Parking Lot System
  16. 16Chapter 16: Design Exercise: Library Management System
  17. 17Chapter 17: Design Exercise: Elevator Controller
  18. 18Chapter 18: OOP in Interviews: What a Strong Answer Looks Like

Chapter 9 of 18

Chapter 9: SOLID, Part 1: Single Responsibility & Open/Closed

Why this matters

SOLID is the most quoted acronym in OOP, and also the most quoted without evidence of understanding — engineers who can recite "S is Single Responsibility" often can't apply it to a real class under review. Each principle in SOLID is a codified answer to a specific, recurring way OOP designs go wrong. This chapter and the next work through all five with a before/after refactor for each, so the principle is something you can recognize in code, not just recite.

Single Responsibility Principle (SRP)

[BEGINNER] A class should have one reason to change. Not "one method" — one reason, meaning one axis of responsibility. A class that handles both business logic and how that logic gets persisted has two reasons to change: a change to the business rule, and a change to the storage mechanism. Either one forces you to touch the same class.

Before — two responsibilities tangled together:

class Invoice {
  constructor(public items: { name: string; priceCents: number }[]) {}

  totalCents(): number {
    return this.items.reduce((sum, item) => sum + item.priceCents, 0);
  }

  saveToFile(path: string): void {
    const data = JSON.stringify({ items: this.items, total: this.totalCents() });
    require("fs").writeFileSync(path, data); // persistence logic living inside the domain class
  }

  printReceipt(): string {
    return this.items.map(i => `${i.name}: $${(i.priceCents / 100).toFixed(2)}`).join("\n");
  }
}

Invoice now changes for three unrelated reasons: a change to how totals are computed, a change to where invoices are saved (file, database, cloud storage), and a change to receipt formatting. A team member fixing a file-storage bug has to edit — and risk breaking — the same class responsible for computing totals.

After — each responsibility in its own class:

class Invoice {
  constructor(public items: { name: string; priceCents: number }[]) {}

  totalCents(): number {
    return this.items.reduce((sum, item) => sum + item.priceCents, 0);
  }
}

class InvoiceFileStorage {
  save(invoice: Invoice, path: string): void {
    const data = JSON.stringify({ items: invoice.items, total: invoice.totalCents() });
    require("fs").writeFileSync(path, data);
  }
}

class ReceiptPrinter {
  print(invoice: Invoice): string {
    return invoice.items.map(i => `${i.name}: $${(i.priceCents / 100).toFixed(2)}`).join("\n");
  }
}

Now a storage change touches only InvoiceFileStorage; a formatting change touches only ReceiptPrinter; Invoice itself changes only if the definition of an invoice's total changes. Each class has exactly one reason to change.

class Invoice:
    def __init__(self, items: list[dict]) -> None:
        self.items = items

    def total_cents(self) -> int:
        return sum(item["price_cents"] for item in self.items)

class InvoiceFileStorage:
    def save(self, invoice: Invoice, path: str) -> None:
        import json
        with open(path, "w") as f:
            json.dump({"items": invoice.items, "total": invoice.total_cents()}, f)

class ReceiptPrinter:
    def print(self, invoice: Invoice) -> str:
        return "\n".join(f"{i['name']}: ${i['price_cents']/100:.2f}" for i in invoice.items)

[SENIOR INSIGHT] SRP is easy to over-apply: splitting a class per method rather than per reason to change produces a maze of tiny classes that all change together anyway — which is its own maintenance cost. The test isn't "does this class do more than one thing" (almost every useful class does several small things), it's "does this class serve more than one stakeholder or axis of change." Invoice's total calculation and item list are one cohesive responsibility even though it's "more than one thing" in a naive sense.

Open/Closed Principle (OCP)

[BEGINNER] A class should be open for extension, but closed for modification — you should be able to add new behavior without editing existing, already-tested code. This is polymorphism's payoff (Chapter 6) applied as a design principle.

Before — every new case requires editing existing code:

class DiscountCalculator {
  calculate(customerType: string, totalCents: number): number {
    if (customerType === "regular") return totalCents;
    if (customerType === "member") return totalCents * 0.9;
    if (customerType === "vip") return totalCents * 0.8;
    // Adding a new customer type means editing this method and re-testing all existing branches.
    throw new Error("Unknown customer type");
  }
}

After — new cases extend the system without touching existing code:

interface DiscountPolicy {
  apply(totalCents: number): number;
}

class RegularDiscount implements DiscountPolicy {
  apply(totalCents: number): number { return totalCents; }
}
class MemberDiscount implements DiscountPolicy {
  apply(totalCents: number): number { return totalCents * 0.9; }
}
class VipDiscount implements DiscountPolicy {
  apply(totalCents: number): number { return totalCents * 0.8; }
}

class DiscountCalculator {
  calculate(policy: DiscountPolicy, totalCents: number): number {
    return policy.apply(totalCents); // never changes when a new policy is added
  }
}

// Adding "platinum" tier: write a new class. DiscountCalculator is untouched.
class PlatinumDiscount implements DiscountPolicy {
  apply(totalCents: number): number { return totalCents * 0.7; }
}
from typing import Protocol

class DiscountPolicy(Protocol):
    def apply(self, total_cents: int) -> int: ...

class MemberDiscount:
    def apply(self, total_cents: int) -> int:
        return int(total_cents * 0.9)

class DiscountCalculator:
    def calculate(self, policy: DiscountPolicy, total_cents: int) -> int:
        return policy.apply(total_cents)

[IN PRACTICE] OCP doesn't mean "never modify any code, ever" — it means the stable core (here, DiscountCalculator) shouldn't need modification when the variable part (which discount applies) grows. Code that's genuinely expected to never vary again doesn't need this treatment; applying OCP everywhere pre-emptively is the same premature-abstraction trap covered in Chapter 14.

Common mistakes

  1. Judging SRP by counting methods instead of reasons to change. A class with ten methods that all serve one cohesive purpose is fine; a class with two methods serving two unrelated stakeholders violates SRP.
  2. Over-splitting classes to chase SRP, producing many tiny classes with no independent reason to change, which just relocates complexity into more files and more indirection.
  3. Applying OCP to code with no evidence it will ever need a second case. Building an interface and a policy class for something that has exactly one implementation and no planned second one is premature (see Chapter 14).
  4. Confusing OCP with "never change a file." OCP is about not needing to modify already-correct, already-tested logic to add new variation — it doesn't forbid legitimate bug fixes or genuine requirement changes to that logic itself.

Checklist: applying SRP and OCP

  1. For SRP: ask "who would ask for a change to this class, and why?" If you can name two unrelated stakeholders or reasons, split it.
  2. Don't split below the level of a cohesive responsibility — a handful of related methods serving one purpose is a single responsibility, not multiple.
  3. For OCP: look for if/else or switch chains keyed on a type or category — that's the shape most in need of an extension point (an interface + polymorphism, per Chapter 6).
  4. Only build the extension point (interface, policy classes) once there's a real, current need for more than one case — not speculatively.
  5. Re-check SRP after adding a feature, not just at initial design time — responsibilities tend to accrete onto existing classes over a project's life ("just add one more method here") until a review catches it.

Discussion / interview questions

  1. "What does 'one reason to change' actually mean, in a class you'd consider well-designed?" Strong answer: Ties every public method/behavior in the class back to a single stakeholder or axis of variation; if two unrelated changes (e.g., a business rule change and a storage change) would both require editing the same class, it violates SRP.

  2. "What's wrong with judging SRP by 'this class should only have one method'?" Strong answer: SRP is about reasons to change, not method count — a class can have several methods that are all part of one cohesive responsibility (like Invoice.totalCents() plus related item-handling methods).

  3. "Explain Open/Closed with a concrete before/after example." Strong answer: Uses an example like the discount calculator — a growing if/else chain replaced by an interface and one class per case, so adding a new case means adding a class, not editing existing tested code.

  4. "Does Open/Closed mean you should never modify existing code?" Strong answer: No — it means the stable, already-tested core shouldn't need to change to support new variation; legitimate bug fixes and real requirement changes to that core logic are unaffected by OCP.

  5. "When would applying SOLID principles aggressively make a design worse, not better?" Strong answer: Over-splitting classes to satisfy a narrow reading of SRP, or building OCP-style extension points (interfaces, policy classes) for a case with no second implementation in sight — both add indirection without a corresponding benefit yet.

Chapter summary / cheat sheet

  • SRP: a class should have one reason to change — judged by stakeholders/axes of change, not method count. Over-splitting is a real failure mode, not just under-splitting.
  • OCP: new behavior should be addable by extension (new classes implementing a shared interface), without modifying already-tested existing code. The classic fix for a growing if/else/switch on type.
  • Both principles are about judgment, not mechanical rules — apply them where there's a real, current need, not speculatively everywhere.