Skip to content
BytePatterns

SOLID Principles Explained With One Refactor, Letter by Letter

8 min readBytePatterns

SOLID principles explained through one report exporter: split the reasons to change, add formats without edits, keep subtypes honest, and know when to stop.

SOLID is usually presented as five definitions to memorise, and interviewers can tell when that is all a candidate has. The five letters are really five answers to one question: when a requirement changes next month, how many files have to be reopened? Walking one small piece of code through each letter shows what each principle buys, and where applying it stops paying.

The problem it solves

A class that mixes concerns turns every change into a risk. Fix the database query and you are editing the file that also lays out the PDF; add a CSV export and you are adding a branch to code that already works. The acronym dates from the early 2000s and groups principles popularised by Robert C. Martin. It names five habits that keep a change local:

  • S, Single Responsibility: a class should have one reason to change.
  • O, Open/Closed: extend behaviour by adding code, not by editing working code.
  • L, Liskov Substitution: a subtype must work anywhere its parent is expected.
  • I, Interface Segregation: several small contracts beat one fat one.
  • D, Dependency Inversion: high-level policy depends on abstractions, and details implement them.

The intuition

Start with a sales report that filters rows and renders them as a PDF, all in one class. It has two reasons to change: the query and the layout. S says split them.

Then a CSV export is requested. Adding an if fmt == "csv" branch reopens the rendering code that PDF users depend on. O says put the varying part, the writer, behind a seam, so CSV arrives as a new class and nothing that works is reopened.

The seam is only useful if every writer really can stand in for any other. A preview writer that refuses empty input breaks every caller that assumed writers accept what the contract allows. That is an L violation: the substitute type-checks, then fails at runtime.

If the writer contract also demanded encrypt, stream and paginate, a CSV writer would have to implement methods it has no use for. I says cut the contract down to what every implementation really does.

Finally, the report should receive a Writer, not construct a PdfWriter itself. That is D: the policy, "filter, then write", owns the abstraction, and the detail plugs into it, which is also what makes the report testable with a fake writer.

Watch it run

The animation follows the same exporter. Five habits, one goal: make tomorrow's change touch one file instead of twenty. The class is edited when the query changes and when the PDF layout changes: two unrelated reasons to reopen one file, so it is split. Then a CSV export is asked for, and editing PdfWriter is how you break PDF, so the varying part goes behind a seam the caller depends on. CsvWriter plugs in, and nothing that already worked was reopened. A substitute must work everywhere the contract is expected, with no surprise refusals; fix that and every writer is interchangeable again. A fat contract forces every writer to implement things it does not do, so it is cut down until a writer implements only what it really does. The report now depends on Writer, never on PdfWriter: the detail plugs into the policy. The last frame is the warning: applied early and everywhere, this buries three lines of logic under four files.

SOLID in One Pass

Step 1 of 12

Five habits, one goal: make tomorrow's change touch one file instead of twenty.

The same interactive animation as the lesson — step through it with the controls.

The code

The starting point, with both reasons to change in one method:

class SalesReport:
    """Before: one class, two reasons to change (the query and the layout)."""
    def __init__(self, rows):
        self.rows = rows

    def export(self, fmt):
        data = sorted((r for r in self.rows if r["amount"] > 0), key=lambda r: r["region"])
        if fmt == "pdf":
            return "\n".join(f"[PDF] {r['region']:<6}{r['amount']:>6}" for r in data)
        if fmt == "csv":
            return "\n".join(f"{r['region']},{r['amount']}" for r in data)
        raise ValueError(fmt)

After the refactor. Each class has one job, the contract is one method, and the report receives its writer:

from abc import ABC, abstractmethod

class Writer(ABC):                         # I: the contract is one method, nothing more
    @abstractmethod
    def write(self, data): ...

class PdfWriter(Writer):
    def write(self, data):
        return "\n".join(f"[PDF] {r['region']:<6}{r['amount']:>6}" for r in data)

class CsvWriter(Writer):                   # O: added later, nothing above reopened
    def write(self, data):
        return "\n".join(f"{r['region']},{r['amount']}" for r in data)

def positive_by_region(rows):              # S: the query has its own home
    return sorted((r for r in rows if r["amount"] > 0), key=lambda r: r["region"])

class Report:                              # D: depends on Writer, never on PdfWriter
    def __init__(self, query, writer):
        self.query, self.writer = query, writer

    def export(self, rows):
        return self.writer.write(self.query(rows))

rows = [{"region": "west", "amount": 120}, {"region": "east", "amount": 75},
        {"region": "north", "amount": 0}]
print(Report(positive_by_region, CsvWriter()).export(rows))
# east,75
# west,120
print(Report(positive_by_region, PdfWriter()).export(rows).splitlines()[0])
# [PDF] east      75

A Liskov violation compiles, passes a type check, and fails the first caller that relied on the contract. Here an empty report, which every other writer handles, makes the preview writer throw:

class PreviewWriter(Writer):               # breaks L: a surprise refusal
    def write(self, data):
        if not data:
            raise ValueError("nothing to preview")
        return f"{len(data)} rows"

def export_all(writers, data):
    results = []
    for w in writers:
        try:
            w.write(data)
            results.append(type(w).__name__ + ": ok")
        except Exception as e:
            results.append(type(w).__name__ + ": " + type(e).__name__)
    return results

print(export_all([PdfWriter(), CsvWriter(), PreviewWriter()], []))
# ['PdfWriter: ok', 'CsvWriter: ok', 'PreviewWriter: ValueError']

class FixedPreviewWriter(Writer):          # L restored: empty input, empty output
    def write(self, data):
        return f"{len(data)} rows" if data else ""

print(export_all([PdfWriter(), CsvWriter(), FixedPreviewWriter()], []))
# ['PdfWriter: ok', 'CsvWriter: ok', 'FixedPreviewWriter: ok']

A refactor is only a refactor if behaviour is unchanged. On 1,000 seeded random row sets, including negative amounts, zeros and empty input, the old class and the new composition must produce identical output in both formats, and every writer must honour the contract:

import random

random.seed(27)
ok = True
for _ in range(1_000):
    data = [{"region": random.choice(["east", "west", "north", "south"]),
             "amount": random.randint(-50, 500)} for _ in range(random.randint(0, 12))]
    for fmt, writer in (("pdf", PdfWriter()), ("csv", CsvWriter())):
        ok &= SalesReport(data).export(fmt) == Report(positive_by_region, writer).export(data)
    for w in (PdfWriter(), CsvWriter(), FixedPreviewWriter()):
        ok &= isinstance(w.write(positive_by_region(data)), str)   # every writer honours the contract
print(ok)                                  # True

The complexity

SOLID has no Big-O, but it has a cost model. Each letter adds indirection: a class, an interface, a constructor parameter. That cost is paid once, on every read of the code. The benefit is paid out only when the axis it protects actually changes. The seam in this example earns its keep the day a third format arrives; if the report will only ever print PDF, it is three extra names for nothing.

Where it goes wrong

  • Reading S as "one method" or "one field". It means one reason to change, measured by who asks for changes.
  • Interfaces with one implementation, everywhere. Speculative seams make code harder to follow. Add the seam when the second variant is real or clearly coming.
  • Treating L as a type-system property. A subtype that throws, ignores arguments or tightens preconditions passes every type check and still breaks callers.
  • Confusing D with dependency injection frameworks. Passing the writer into the constructor is enough; the principle is about which way the dependency points.
  • Duplicating to avoid abstraction, or abstracting to avoid duplication, by reflex. Some duplication costs less than an abstraction built along the wrong axis.

When it shows up in interviews

Low-level design rounds use SOLID as vocabulary rather than as a quiz. When you design a parking lot or an elevator controller, the interviewer asks how a new vehicle type or scheduling rule would be added, and the good answer is an open/closed seam. The strategy pattern and the observer pattern are the two most common ways to build that seam. Expect a follow-up asking for a Liskov example and for a case where you would not apply the principles.

How to say it in an interview

"All five letters aim at keeping a change local. Single responsibility: one reason to change per class, so I split the query from the rendering. Open/closed: new formats arrive as new writer classes behind one interface, without editing working code. Liskov: every writer has to accept whatever the contract allows, including empty input, or callers break at runtime. Interface segregation: the writer contract is one method, not every capability any writer might have. Dependency inversion: the report receives a Writer instead of building a PdfWriter. And I only add a seam where I have evidence the axis changes, because every abstraction has a reading cost."