Fanpilot

System design

Design principles: SOLID

14 min read

Object design interviews often ask about SOLID: five principles for organizing classes. They're useful, but they're heuristics, not laws, and each one has a cost when you overuse it. So for each principle, this page answers three questions: what problem does it prevent, what does overusing it cost, and when should you break it?

Principle What it prevents Overuse looks like
Single responsibility Unrelated changes landing in the same code. Eight tiny classes for a 40-line feature.
Open/closed Editing working code every time a new variant arrives. Plugin systems built before there's a second variant.
Liskov substitution A replacement that breaks what callers rely on. Rarely overused. Breaking it is the usual mistake.
Interface segregation Callers depending on methods they don't use. An interface for every field.
Dependency inversion Business rules tied to one vendor or database. Six layers of wrappers around one API call.

Single responsibility: keep unrelated things apart

"A class should have only one reason to change." Here's a class with four:

class UserService:
    def create_user(self, name, email): ...
    def send_welcome_email(self, user): ...
    def generate_invoice_pdf(self, user): ...
    def write_audit_log(self, event): ...

Email templates change when marketing asks. PDF layout changes when finance asks. Audit logging changes when security asks. Each change risks breaking the others, and every test has to set up all four. Splitting them apart means each concern changes on its own.

A "reason to change" isn't a count of methods or lines. Robert Martin, who named the principle, ties it to who asks for changes: group code that the same people would ask you to change (his clarification).

Overusing it turns a small feature into UserValidator, UserCreator, UserRepository, UserMapper, UserEmailNotifier, UserAuditLogger, UserFactory, and UserBuilder. Now reading one request means jumping through eight files. The cost of splitting is indirection: every split adds a boundary someone has to cross to understand the code. And splitting doesn't remove the need for something to run the steps in order. A register_user function that validates, saves, and sends the email is fine; coordinating is a job too.

Break it when keeping things together is easier to follow. A 500-line parser where every function works on the same tokens is often clearer than fifteen tiny objects passing control back and forth. The better test is cohesion: do these pieces change together and use the same data? If so, they belong together, whatever the class size.

Open/closed: add variants without editing working code

"Open for extension, closed for modification." In practice: when new variants keep arriving, add them as new code instead of editing a growing if chain.

class CreditCard:
    def charge(self, amount): ...


class BankTransfer:
    def charge(self, amount): ...


def checkout(payment_method, amount):
    payment_method.charge(amount)  # A new method of payment needs no change here.

Adding Apple Pay means writing one new class. checkout doesn't change, so it can't break.

"Closed" doesn't mean you can never edit checkout. You still fix its bugs. It means one kind of change, adding a payment method, no longer needs to touch it. No design protects against every future change; pick the kind that keeps happening.

Overusing it means guessing at future variants. AbstractPaymentProcessorFactory, PaymentStrategyRegistry, and PaymentStrategyAdapter, built before there are two ways to pay, are abstractions with nothing to abstract. Worse, if every new variant still forces a change to the shared interface, the "stable" part isn't stable, and you're paying for the abstraction without getting anything back.

Break it while the variants are few and settled. This is fine code:

def export(rows, format):
    if format == "json":
        ...
    elif format == "csv":
        ...
    elif format == "xml":
        ...

Three branches in one place are easier to read than a class hierarchy spread over nine files. Editing it to add a fourth format is cheap. The principle starts to pay off when the operations stay fixed but variants keep coming, like a new payment method every quarter. If both the variants and the operations keep changing, the abstraction buys you nothing.

The rule worth keeping: don't abstract until you've seen the variation. A written requirement counts as seeing it. If the product plan says third parties will add their own exporters, build the extension point now.

Liskov substitution: a subtype must keep its parent's promises

If code works with a Rectangle, it should keep working when you hand it any subtype of Rectangle. The classic counterexample is the square:

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

    def set_width(self, width):
        self.width = width

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


class Square(Rectangle):
    def __init__(self, side):
        super().__init__(side, side)

    def set_width(self, width):  # A square must stay square.
        self.width = self.height = width


def widen(shape):
    old_height = shape.height
    shape.set_width(10)
    assert shape.area() == 10 * old_height  # True for every Rectangle; false for a 4 x 4 Square.

Mathematically, a square is a rectangle. But Rectangle promises that setting the width leaves the height alone, and Square can't keep that promise. So Square shouldn't inherit from Rectangle, even though the words suggest it should. The real problem is the setter: if shapes couldn't be changed after creation, a square and a rectangle could both safely offer area().

The rule is about behavior, not just method names: a subtype must keep every promise the parent makes. In practice, a replacement must not:

  • demand more from callers, such as rejecting inputs the parent accepts,
  • promise less, such as returning results the parent rules out,
  • or break rules the parent keeps, such as "the height never changes on its own".

This applies to duck typing and Protocol types in Python too, not only inheritance. An object with a read() method that sometimes deletes the file it reads matches the shape of a reader, but it still breaks every caller. Barbara Liskov and Jeannette Wing's paper is the formal version.

One practical check: write the caller's expectations as a test, like widen above, and run it against every implementation. Tests can't prove an implementation correct, but they catch the broken promise fast.

Overusing it isn't really a risk. This is the one SOLID principle that's close to a correctness rule.

Breaking it on purpose is almost never right. If you feel forced to, the abstraction is wrong. Change the interface, split it into separate capabilities, or use composition. Writing "this subclass doesn't support set_width" in a comment doesn't help a caller that was promised it works.

Interface segregation: don't make callers depend on what they don't use

Here's an interface too big for most of the classes that implement it:

from typing import Protocol


class Worker(Protocol):
    def write_code(self): ...
    def drive_forklift(self): ...
    def cook(self): ...
    def approve_budget(self): ...


class Programmer:
    def write_code(self): ...

    def drive_forklift(self):
        raise NotImplementedError("programmers don't drive forklifts")

Every class implements methods it can't support, and every caller depends on all four even if it needs one. Split it by what callers actually need:

class Coder(Protocol):
    def write_code(self): ...


class ForkliftDriver(Protocol):
    def drive_forklift(self): ...

A function that needs code written asks for a Coder, and nothing else. It also makes tests simpler: a report that only reads orders needs a fake with one get_order method, not one that also fakes refunds and deletes.

Overusing it gives you HasId, HasName, HasEmail, and HasAddress: so many tiny interfaces that nobody can find the real types. An interface should name a meaningful capability, like "can be charged" or "can be stored". Some capabilities need several methods that only make sense together, like beginning, committing, and rolling back a database transaction. Keep those in one interface.

Break it, or rather skip it, when there's one implementation and no need to swap it in tests. Then a plain class is clearer than a class plus an interface that only it implements. And a narrow interface isn't a security boundary: hiding delete from a caller doesn't stop it from deleting. Check permissions where the operation happens.

Dependency inversion: keep business rules apart from details that change

Your business rules shouldn't be welded to one vendor's library. Compare these two:

class Checkout:
    def __init__(self):
        self.gateway = StripeGateway()  # Checkout is tied to Stripe forever.


class Checkout:
    def __init__(self, gateway):
        self.gateway = gateway  # Anything that can charge money.

In the second version, Checkout can work with any payment provider, and a test can pass in a fake. Passing a dependency in like this is dependency injection. You don't need a framework for it.

But injection alone isn't inversion. If the injected object is the Stripe client, and Stripe's response objects and error codes spread through Checkout, checkout is still tied to Stripe. Inversion means checkout defines what it needs, such as "charge this many cents, and tell me: accepted, declined, or unknown". A small adapter turns Stripe's responses into those answers:

Checkout depends on…
Before Stripe's library, directly.
After Its own payment contract. The Stripe adapter depends on that contract and on Stripe.

At run time, the call still goes from checkout through the adapter to Stripe. What flips is who defines the contract. Martin Fowler explains both dependency injection and inversion in practice.

Overusing it turns one API call into a stack: Checkout → PaymentGatewayInterface → PaymentGatewayAdapter → StripePaymentGatewayAdapterImpl → StripeClientWrapper → the Stripe library. Every layer adds a file to read and a place for bugs, and none of them adds behavior. A boundary earns its place when it translates meaning (Stripe's error codes into "declined" or "unknown"), lets you test without the network, or contains a change you expect. You don't need two providers to justify one.

Break it for things that won't change and don't need isolating. Separate yourself from databases, outside APIs, the clock, and the network: they're slow, they fail, and you'll want to fake them in tests. Don't wrap a small, predictable helper function. Calling it directly is simpler.

What SOLID leaves out

SOLID comes from a time when most design meant class hierarchies. Much good code today leans on other tools: plain functions, immutable data, modules, composition instead of inheritance, and messages between services. None of those need SOLID's machinery. This needs no classes, interfaces, or factories:

def order_total(items):
    return sum(item.price * item.quantity for item in items)

It's a function, and that's the best design for it. The same ideas still apply, though: a function has a contract too (units, errors, side effects), and a pure function like this one is the easiest code there is to test.

SOLID also says nothing about performance, data layout, transactions, concurrency, or security. And moving a boundary onto a message queue doesn't remove it; it adds delivery, retry, and versioning questions. Don't turn a local separation into a distributed system unless the requirements call for it.

One principle matters more than any of the five: don't add complexity you don't need. SOLID can reduce complexity when it fits the problem. Applied mechanically, it adds a lot.

Does this abstraction pay for itself?

Before you add an interface, a layer, or a new class, answer four questions:

  1. Which specific change or failure does it isolate?
  2. What can the caller rely on, including errors and side effects?
  3. How many extra concepts does someone need to learn to follow one request?
  4. What would make you merge it back, replace it, or delete it?

If you can't answer the first one, don't add it. Start with the smallest design that meets the real requirements, where "smallest" counts the work of reading, testing, and changing it, not just lines of code.

The short version

  • Keep related things together, and unrelated things apart.
  • Add abstractions where you've seen variation, not where you predict it.
  • Prefer composition to inheritance, and make every subtype keep its parent's promises.
  • Don't make callers depend on things they don't need.
  • Keep business rules apart from details that change, like databases and outside APIs, and let the rules define the contract.
  • Count the cost of every abstraction: each one is another place to read and another place to break.

In an interview

If you're asked "what do you think of SOLID?", a strong answer sounds like this:

"I treat SOLID as design heuristics, not rules. They help manage coupling and change, but overusing them leads to too much abstraction and indirection. I usually wait until I've seen real variation before adding an abstraction."

Then back it up with an example from your own work: a split that made code easier to change, or an abstraction you removed because nothing ever varied.

In a design question, show the principles instead of naming them, and tie each choice to its reason:

"I'd pass the payment operation into checkout, so its tests never call the provider. I'd keep the two receipt formats as an if for now. If formats start being built by different teams, I'd add a renderer interface."

That says what you gained, what you avoided, and what would change your mind. In the in-memory file system, search keeps the walk through the tree separate from the conditions it checks, so a new condition, like "modified this week", needs no change to the walk. That's open/closed, and it's earned, because search conditions are exactly what keeps growing.

Practice

  1. A 40-line importer validates rows, converts them, and writes a file. Must each step become its own class?
  2. A new payment provider sometimes times out after it has already accepted a charge. Can its adapter report that as a decline?
  3. A report only needs get_order(id). Should its test fake implement refunds and deletes too?
  4. A pricing rule changes every week, and there's still only one payment provider. Where should the boundary go?
  5. Change the worked example below to support a second receipt format. Does it need an interface yet?
Worked refactor and exercise answers

Worked refactor: one boundary around payment

Keep the arithmetic pure, pass the payment operation into checkout, and keep the provider adapter outside the workflow. Here is the contract checkout relies on:

  • Amounts are positive whole numbers of cents, in one currency.
  • An accepted payment returns a receipt ID.
  • A definite rejection raises Declined.
  • An unclear outcome, like a lost response, raises PaymentUnknown. The caller must not treat it as a rejection, because the charge may have gone through.

The injected function is the payment capability. No base class, registry, or framework is needed.

class Declined(Exception):
    pass


class PaymentUnknown(Exception):
    pass


def total_cents(items):
    total = 0
    for price_cents, quantity in items:
        if price_cents < 0 or quantity <= 0:
            raise ValueError("price must be nonnegative; quantity must be positive")
        total += price_cents * quantity
    return total


def checkout(items, charge):
    amount = total_cents(items)
    if amount <= 0:
        raise ValueError("checkout total must be positive")
    receipt_id = charge(amount)
    return {"receipt_id": receipt_id, "amount_cents": amount}


calls = []


def accept_payment(amount_cents):
    calls.append(amount_cents)
    return "receipt-123"


assert total_cents([(125, 2), (50, 1)]) == 300
assert checkout([(125, 2), (50, 1)], accept_payment) == {
    "receipt_id": "receipt-123",
    "amount_cents": 300,
}
assert calls == [300]


def decline_payment(amount_cents):
    raise Declined("confirmed rejection")


def uncertain_payment(amount_cents):
    raise PaymentUnknown("response lost")


for charge, expected_error in [
    (decline_payment, Declined),
    (uncertain_payment, PaymentUnknown),
]:
    try:
        checkout([(100, 1)], charge)
    except expected_error:
        pass
    else:
        raise AssertionError("checkout hid the payment outcome")

for invalid_items in [[], [(100, 0)], [(-100, 1)]]:
    try:
        checkout(invalid_items, accept_payment)
    except ValueError:
        pass
    else:
        raise AssertionError("invalid checkout reached payment")
assert calls == [300]

Using whole cents avoids rounding here. Real pricing also needs explicit rules for currency, tax, discounts, and rounding.

Trace each outcome

On success, checkout computes 300 cents, calls the payment function once, and returns the receipt. On a definite decline, Declined reaches the caller unchanged. On an unclear outcome, PaymentUnknown reaches the caller too; checkout doesn't guess success or failure.

The provider adapter's job is to turn the provider's responses into these three outcomes. If the provider can't tell a decline from a lost response, the adapter can't either, and must report "unknown".

This isn't a production payment flow. It has no retries, stored order state, reconciliation, or idempotency keys. Those solve other requirements, and adding interfaces wouldn't solve them. The system design guide covers failures and retries.

A fake that always succeeds proves checkout's own behavior, not that a real provider works. Test each real adapter against the same contract, separately.

Exercise answers

  1. No. Keep the importer together if it's one cohesive workflow. Pull out parsing or output when they change independently or need separate tests.
  2. No. A decline tells the caller it's safe to retry or try another card; after a timeout, the charge may have gone through. Report "unknown", and reconcile with the provider before retrying.
  3. No. A reader with get_order is enough. A full fake can hide that the production interface is broader than this caller needs.
  4. Keep the pricing rule apart from talking to the provider. One provider is enough reason to isolate outside calls, and the pricing rule stays easy to change and test.
  5. An if is fine for two simple formats. Add a renderer interface when formats change independently or different teams own them. Either way, keep the receipt's content and error behavior the same.