People often ask whether they should learn SOLID or design patterns first, as if they were competing topics. They are not. Patterns are specific, named solutions. Principles are the reasons those solutions are good. A useful way to hold them: principles are the grammar of design, patterns are the idioms. You can memorise idioms without grammar, but you will use them in the wrong places.

This chapter maps one onto the other, then takes a real question that comes up on every team: how strictly should you apply the Single Responsibility Principle?

Five principles in one paragraph each

S: Single Responsibility. A module should have one reason to change. Robert C. Martin later sharpened this to "one actor": a class should answer to one group of people (finance, operations, the marketing team), so that a change requested by one group cannot break what another depends on.

O: Open/Closed. Software entities should be open for extension and closed for modification. Bertrand Meyer stated it in 1988; Martin recast it around polymorphism: add new behaviour by adding new code, not by editing code that already works.

L: Liskov Substitution. Barbara Liskov's 1987 rule: anything that uses a base type must keep working when given a subtype. A ReadonlyRepository that throws on save() while pretending to be a Repository breaks it.

I: Interface Segregation. Clients should not depend on methods they do not use. Five small interfaces beat one interface with twenty methods that every implementation half-fills.

D: Dependency Inversion. High-level policy should not depend on low-level details; both should depend on abstractions. Your order logic depends on an OrderSender interface, and the HTTP implementation depends on that interface too, not the other way around.

The principles were collected by Martin around 2000; the SOLID acronym came a few years later from Michael Feathers. They are not specific to Java or to classes. They apply to modules, functions and services as much as to objects.

Which comes first?

Historically the patterns came first. The Gang of Four book (1994) predates the SOLID acronym by a decade, and it states its own two principles in the introduction: program to an interface, not an implementation, and favor object composition over class inheritance. SOLID later named more of the ideas the patterns were already using.

For learning, though, principles first is the better order. When you know why a design is good, patterns stop being 23 things to memorise and become obvious consequences. Here is how the patterns in this guide map to the principles they mostly serve:

Pattern Main principles Why
Simple Factory, Factory Method S, D Creation logic gets one home; callers depend on an interface, not a constructor
Singleton (often violates D) Global access is convenient and hides dependencies; use with care
Builder S Construction steps move out of the object being built
Prototype O New variants come from copying configured instances, not new subclasses
Adapter D, O Your code depends on your interface; third-party APIs are adapted to it
Bridge S, O Two dimensions of change vary independently instead of multiplying classes
Proxy S, O Cross-cutting concerns are added without touching the real object
Facade I, D Callers get the narrow interface they need instead of five subsystems
Strategy O, S Each rule is its own unit; adding a rule adds code instead of editing a switch
Observer D, O The subject depends on an abstract listener; new reactions are new subscribers

The pragmatic case against strict SRP

A common objection from experienced developers goes like this: strict SRP says every new variant should get its own class, that leads to class explosion, and in practice a small if is often cheaper. The objection deserves a fair hearing, because it is half right. Let us work through it with code.

A small reporting module exports sales as CSV:

type Sale = { sku: string; quantity: number; amount: number };

export class SalesReport {
  constructor(private readonly sales: Sale[]) {}

  export(): string {
    const rows = this.sales.map((s) => `${s.sku},${s.quantity},${s.amount}`);
    return ["sku,quantity,amount", ...rows].join("\n");
  }
}

Then the finance team asks for JSON as well. There are four ways to respond.

Option A: one class per variant

export class CsvSalesReport {
  constructor(private readonly sales: Sale[]) {}
  export() {
    return ["sku,quantity,amount", ...this.sales.map((s) => `${s.sku},${s.quantity},${s.amount}`)].join("\n");
  }
}

export class JsonSalesReport {
  constructor(private readonly sales: Sale[]) {}
  export() {
    return JSON.stringify(this.sales);
  }
}

This is the version the objection is aimed at. The rename breaks every caller of SalesReport, both classes duplicate the constructor and data handling, and if the next request is "add a total row", you implement it twice. Splitting by output format is not what SRP asks for, and it does feel like ceremony.

Option B: an if inside the method

export class SalesReport {
  constructor(private readonly sales: Sale[]) {}

  export(format: "csv" | "json" = "csv"): string {
    if (format === "json") return JSON.stringify(this.sales);
    const rows = this.sales.map((s) => `${s.sku},${s.quantity},${s.amount}`);
    return ["sku,quantity,amount", ...rows].join("\n");
  }
}

Cheap, no caller breaks, and for two formats it is honestly fine. The cost shows up on the third and fourth request: Excel, then PDF, then "CSV but with semicolons for the German office". Every request edits the same method, and every edit risks breaking a format someone else relies on. That is an Open/Closed violation growing quietly.

Option C: a new method, old code untouched

export class SalesReport {
  constructor(private readonly sales: Sale[]) {}

  export(): string {
    const rows = this.sales.map((s) => `${s.sku},${s.quantity},${s.amount}`);
    return ["sku,quantity,amount", ...rows].join("\n");
  }

  exportJson(): string {
    return JSON.stringify(this.sales);
  }
}

This is the pragmatic middle ground many experienced developers prefer, and it has a real virtue: the existing, tested export() is never edited, so nothing that worked can break. It is a reasonable choice for a small, stable set of variants. Its limit is that the class keeps growing one method per format, and callers that pick a format at runtime end up writing their own if.

Option D: separate what changes from what does not

The real question SRP asks is not "how many classes?" but "what are the reasons this code changes?". Here there are two: what data is in the report (owned by whoever defines sales reporting) and how it is formatted (owned by whoever consumes it). Split along that line, not along each format:

type Formatter = (rows: Sale[]) => string;

export const formatters = {
  csv: (rows) => ["sku,quantity,amount", ...rows.map((s) => `${s.sku},${s.quantity},${s.amount}`)].join("\n"),
  json: (rows) => JSON.stringify(rows),
} satisfies Record<string, Formatter>;

export type ReportFormat = keyof typeof formatters;

export class SalesReport {
  constructor(private readonly sales: Sale[]) {}

  rows(): Sale[] {
    return this.sales.filter((s) => s.quantity > 0); // the report's own rules live here
  }

  export(format: ReportFormat = "csv"): string {
    return formatters[format](this.rows());
  }
}

const report = new SalesReport([
  { sku: "MUG", quantity: 2, amount: 240_000 },
  { sku: "CAP", quantity: 0, amount: 0 },
]);
console.log(report.export());
// sku,quantity,amount
// MUG,2,240000
console.log(report.export("json"));
// [{"sku":"MUG","quantity":2,"amount":240000}]

Callers of export() did not break. Adding a format is adding one entry, with no edit to SalesReport. A change to the report's rules (exclude zero-quantity lines) is made once and applies to every format. That is SRP and OCP together, and the shape is the Strategy pattern from chapter 13, with two small pieces instead of a class per format.

So who is right?

The objection is right that splitting by every variant is ceremony, and right that context matters: for two formats in an internal tool, option B or C is a perfectly good engineering decision. It is wrong if it concludes that SRP itself is the problem. SRP done properly is about separating reasons to change, and that usually produces fewer, better-placed pieces, not more.

A practical rule that reconciles both views:

  1. Start simple. One class, maybe an if, when there is one variant and a vague hint of a second.
  2. Refactor on the third variant, or as soon as two variants are owned by different people.
  3. Never edit working code for an unrelated reason if adding code would do. Option C is a fine stepping stone; option D is where you go when the set keeps growing.

Principles at module scale

Everything above applies to modules and services, not just classes:

  • A utils.ts file imported by everything violates SRP at the module level; any change to it risks everyone.
  • A microservice that owns orders and also generates invoice PDFs has two reasons to change and two teams waiting on each other's deploys.
  • A shared types package that every service depends on is Dependency Inversion done wrong: everyone depends on one concrete module.

Exercises

  1. Add an "excel-csv" format (semicolon separator, UTF-8 BOM) to option B and to option D. Count the lines of existing code you had to edit in each.
  2. List the actors (people or teams) who could request changes to a class you work on. If there are more than two, sketch how you would split it.
  3. Find an interface in your codebase where some implementations throw Not implemented for a method. Which principle does that break, and how would you split the interface?
  4. Write down, for each pattern in the mapping table, one sentence on how it would look as a plain function instead of a class.