InterviewPrepKit

Home / Learn / Object-Oriented Design

03 — Object-Oriented Programming (OOP) Fundamentals

“What is polymorphism?” is a screening question. “Show me the edit it saved you” is the interview.

An object-oriented design (OOD) interview is graded on a short vocabulary: the four pillars, the five SOLID principles (an acronym unpacked in full below), coupling and cohesion, and the Singleton pattern.

Every one of those terms is really a price rather than a definition — the requirement change it makes cheap, and what it charges you in exchange.

By the end you will be able to take any of these terms, name the change it saves you, name its cost, and point at running code where it breaks. That is the form every follow-up takes, because the follow-up is always “why”, and a definition cannot answer why.

You do not need any other chapter to read this one. Everything is defined where it first appears, and every example runs on a stock Python 3.9 install with nothing imported from outside the standard library.


The five words everything below leans on

Read these before anything else. Every later section uses all of them, and none of them is re-defined later.

One more term, and it is the one this chapter keeps returning to. An invariant is a statement that must be true of an object before and after every method runs — “the balance is never negative”, “the two sides of a square are equal”. A surprising amount of what follows is really about where an invariant is allowed to live.

Every one of these ideas exists because some change is expensive without it. Learn them as the change they make cheap and the price they charge, and you can answer the follow-up. Learn them as definitions and you cannot.


What goes in, and what comes out

Before any principle can be judged, fix the units it is measured in — otherwise every claim about design quality is aesthetic instead of checkable.

The input to a design is not a diagram and not a list of nouns. It is a requirement change: a sentence someone says to you three weeks after you shipped.

Each of those arrives as English and has to become a diff.

The output is the size of that diff: how many files you have to open, and how many tests you have to re-run, to satisfy the sentence. That is the number every principle below is trying to reduce, and it is the number an interviewer is silently computing while you talk.

So the loop is: requirement change goes in, count of edited files comes out.

A design that turns “add electric bays” into one new file and zero edited ones has absorbed the change. A design that turns it into four edited files spread across three teams has not.

The catch is that nothing about the second design looks wrong until the sentence arrives. Both designs pass their tests today. The whole skill is predicting that number before you have to pay it.

A principle you cannot attach a count to is a principle you cannot defend in an interview. Each section below therefore ends with a count, or with a cost stated in plain words.


The four pillars, stated as consequences

Encapsulation, abstraction, inheritance and polymorphism are usually taught as definitions. Each is better defined by the edit it prevents — and by the code where its absence becomes a bug.

Start with the one-line versions, so the table below is readable:

Now the same four as trades. Read each row left to right as one sentence: this pillar makes that change cheap, it charges you this, and here is how you spot a codebase that skipped it. The last column is the useful one — it is what you actually see in a pull request.

PillarThe change it makes cheapWhat it costsThe tell it is missing
EncapsulationChanging an internal representation without touching a callerIndirection; a field access becomes a method callCallers reach into fields, so a rename breaks a dozen files
AbstractionSwapping how without renegotiating whatYou freeze a signature before you know all the use casesEvery caller knows which database you use
InheritanceAdding a subtype that all existing callers already handleSubclasses depend on the base forever, including its bugsA subclass overrides a method to raise NotImplementedError
PolymorphismAdding a case without editing the code that dispatchesThe flow is no longer readable top to bottom in one fileA chain of isinstance checks that grows with every feature

Encapsulation: the representation change

Encapsulation is the pillar that lets you change how a value is stored without every caller noticing, and the one requirement that shows up in every money system is its cleanest demonstration.

The requirement

The whole argument fits in one sentence: store money as integer minor units instead of floats.

Two terms in that sentence. A float is a binary approximation of a decimal number, so 0.1 is not exactly one tenth. Minor units means counting in the smallest indivisible unit of the currency — cents rather than dollars — so every amount is a whole number and addition is exact.

The leaky version: every caller owns the bug

Here is the version where the storage decision leaks out to every caller. Watch the field balance: it is public, denominated in dollars, and callers do arithmetic on it directly.

assert 0.1 + 0.2 != 0.3                     # this is why the requirement exists
assert abs((0.1 + 0.2) - 0.3) < 1e-9        # and why it hides in casual tests


class LeakyAccount:
    """Balance is public and denominated in dollars. Callers do arithmetic on it."""

    def __init__(self, dollars: float) -> None:
        self.balance = dollars


a = LeakyAccount(0.1)
a.balance += 0.2
assert a.balance != 0.3                     # every caller inherited the bug
print(f"leaky: {a.balance!r}")

Reading that block:

Every call site that wrote account.balance += x is now a place the rounding error lives, and there is no single line you can change to fix them all.

The encapsulated version: one class owns the representation

With the representation owned by the class, the same requirement is a one-file change and no caller notices.

Two things to watch in the block below. First, the balance is stored as _cents and never handed out for arithmetic. Second, whole_cents is a single gate that both the constructor and deposit must pass through.

def whole_cents(amount: object) -> int:
    """The type gate. Every door into the balance goes through this one line."""
    if type(amount) is not int:
        raise TypeError(f"cents must be a whole number, got {type(amount).__name__}")
    return amount


class Account:
    """Balance is integer cents inside. Dollars are a presentation concern."""

    def __init__(self, cents: int) -> None:
        self._cents = whole_cents(cents)

    @property
    def balance_cents(self) -> int:
        return self._cents

    def deposit(self, cents: int) -> None:
        if whole_cents(cents) <= 0:
            raise ValueError("deposit must be positive")
        self._cents += cents


b = Account(10)
b.deposit(20)
assert b.balance_cents == 30                # exact, and the guard has one home

for bad, expected in ((-5, ValueError), (0.5, TypeError), (True, TypeError)):
    try:
        b.deposit(bad)
        raise SystemExit(f"unreachable: deposit({bad!r}) was accepted")
    except expected:
        pass

try:
    Account(0.5)                            # the constructor is the other front door
    raise SystemExit("unreachable: Account(0.5) was accepted")
except TypeError:
    pass

assert b.balance_cents == 30                # every rejected call left the balance alone
assert type(b.balance_cents) is int         # and a float never got in
print("encapsulated: representation and rule both live in one class")

The three pieces of syntax

Three Python features carry that block. If any of them is new to you, the block will not make sense until you have read these.

The leading underscore in _cents is a convention, not a language feature. It means “this is internal, do not touch it from outside”, and Python will not stop you if you ignore it.

@property is a decorator. A decorator is a line beginning with @, placed above a definition, that wraps that definition in extra behaviour. This particular decorator turns a method into something callers read like a plain field: they write b.balance_cents, with no parentheses, and the method body runs. Because no matching setter is defined, b.balance_cents = 99 raises AttributeError — so the value is readable and not writable.

type(amount) is not int is deliberately stricter than isinstance(amount, int). In Python, bool is a subclass of int, so isinstance(True, int) is True. An isinstance gate would happily bank True as one cent. type(True) is int is False, so the strict check rejects it.

The guard most versions leave out

That type check is the part most versions of this example omit, and omitting it reproduces the exact bug the section exists to prevent.

Write deposit with only the obvious guard — if cents <= 0 — and you have checked the value and not the representation. Now trace deposit(0.5): it is greater than zero, so it passes; it gets added to self._cents; the balance is now a float. Every later addition is inexact, and assert type(b.balance_cents) is int is the line that catches it.

The LeakyAccount defect has walked in through the guarded front door of the encapsulated class.

A guard on the value is not a guard on the representation, and the representation is what this class exists to own.

Four routes at the balance

Encapsulation is not “make fields private”. It is “there is exactly one place that may violate this invariant, and you can name it”.

That is a weaker sentence than the usual one, and it is weaker because Python is. There are four routes at the balance and it is worth walking all four, because an interviewer who knows the language will.

RouteResultWhy
b.balance_cents = 99AttributeErrorThe property has no setter
b.deposit(0.5)TypeErrorwhole_cents gates the method
Account(0.5)TypeErrorwhole_cents gates the constructor too — a class with two doors needs two gates
b._cents = -999succeedsPython has no private; the underscore is a request

The last row is not a bug in the example. It is the language.

So the honest claim is exactly one place that may violate the invariant, and it is named: _cents, inside Account. That is a contract you can point at in a review rather than a wall the runtime enforces.

It is still worth almost everything the strong version promises. When someone asks how a float got into the ledger, there is one file to read instead of every call site.

The deposit must be positive check makes the same point about rules rather than representations. In the leaky version that rule has no home, so it is either absent or copy-pasted into every caller.

Why Python has no private, and why that is fine

The convention is a leading underscore plus a read-only @property. An interviewer who wanted Java-style accessor methods will accept “a property is the getter; there is no setter because nothing outside may mutate it”.

Do not write get_balance() and set_balance() in Python. That is a public field with two extra function calls, and it demonstrates that you learned the pillar as a ritual.

One follow-up you should be ready for: what about a double underscore? Writing self.__cents triggers name mangling — the compiler rewrites the attribute to _Account__cents so a subclass that also uses __cents does not collide with the base class. That is what the feature is for. It is not privacy: b._Account__cents = -999 still works, so the honest claim above does not change.

The assumption this structure encodes

Account assumes that the representation of money will change and that the operations on it will not. Storage is hidden; “deposit an amount” and “read the balance” are frozen into the public surface.

Assume differently and you get a different class.

If the currency can also vary, integer cents is already wrong. You need a Money value carrying both an amount and a currency code, and deposit must take a Money rather than an int, so that adding euros to dollars is a type error rather than a silent corruption.

If instead you assume the operations will churn while the representation is settled, hiding the representation buys you little and the effort belongs somewhere else.

Abstraction: the size of the promise

Abstraction is the decision about which facts callers may depend on, and one question sizes that decision.

The measurement is blunt: the number of things that break when you change your mind is the number of facts you exposed.

Take a cache — a component that stores computed values so they need not be recomputed.

A Cache that promises only get(key) and put(key, value) can move from a Python dictionary to Redis, an external in-memory key-value store running as its own process, with zero caller edits. Both storages can honour those two names.

A Cache that also promises evict_lru() (evict the least-recently-used entry), bucket_count(), and raw_dict() has promised its implementation. Those three names only mean anything if the storage really is an in-process hash table. Redis has no buckets you can count and no dictionary you can hand back, so the move becomes a rewrite of everything that touched them.

Ask of every public method: would I still be able to offer this if I replaced the internals tomorrow?

A promise you cannot keep after a rewrite is not an abstraction; it is your implementation with a docstring.

Inheritance: substitutability, not reuse

Inheritance is the pillar people reach for to share code, and it actually means something much stronger.

class B(A) reads “B inherits from A”, where A is the base class or superclass and B the subclass or subtype.

That line says far more than “B wants A’s code”. It says every function written against A will keep working when handed a B, without reading B’s source.

That is a promise about behaviour, and it is the promise the next section breaks on purpose.

Polymorphism: the dispatcher you stop editing

Polymorphism lets you add a new type without editing the code that decides what to do with types — and the saving can be counted.

Dispatch means “choosing which code runs based on the type of a value”. You can do it by hand, and the block below is what that looks like. Watch how the list of known types lives inside describe rather than inside the types themselves.

def describe(x: object) -> str:                     # the chain that grows forever
    if isinstance(x, int):
        return "int"
    if isinstance(x, str):
        return "str"
    return "unknown"


assert describe(1) == "int"
assert describe(1.5) == "unknown"                   # every new type edits this file
print("isinstance dispatch: the caller owns the type list")

isinstance(x, int) asks at runtime whether x is an integer. Written this way, the list of types the program understands lives in the caller, so a float — a type nobody thought about — falls through to "unknown" silently. No error, no warning.

The cost is not this one function. It is that a real system dispatches on the same type list in several places: render it, price it, serialize it, validate it. Adding a type means finding all of them.

Count it: 4 operations that switch on type, 1 new type, and the switch design costs 4 x 1 = 4 edits in four files, while polymorphic dispatch costs one new class and zero edits.

A static type checker such as mypy cannot rescue you here. It cannot tell you that you missed the third switch, because every branch of that switch is still valid code. A missing method on a new class it can flag, because the new class visibly fails to provide something the interface names.

The word “interface”

One word in that last sentence is doing a lot of work and is used constantly from here on, so pin it down now.

An interface is a named set of method signatures with no code behind them: the names, what they take, and what they give back, and nothing about how.

Several unrelated classes can each keep that promise in their own way. Calling code is written against the interface rather than against any one of them — which is exactly why adding a class costs no edits.

Python spells it three ways, all of them appearing below:

  1. An abc.ABC with @abstractmethod — you must declare that you implement it.
  2. A typing.Protocol — you need only have the methods.
  3. Informally, a plain class that documents which methods it expects.

The word abstraction names the judgement — which facts to promise. Interface names the artifact that records the answer.


Liskov substitution: the one that breaks in running code

Here is the most famous inheritance bug in the discipline, in code that actually fails, followed by the four-line rule that predicts it. It is worth memorising because interviewers ask for it by name.

The Liskov substitution principle (LSP), named for Barbara Liskov, is the rule that a subtype must be usable anywhere its base type is expected, by code that has never heard of the subtype.

The setup

A Rectangle has a mutable width and height. A square is a rectangle — every geometry textbook says so — so Square inherits from Rectangle and keeps its sides equal.

Three things to watch in the block: Square overrides both setters so its sides stay equal, stretch was written against Rectangle alone, and stretch is called twice with the same type annotation satisfied both times.

class Rectangle:
    def __init__(self, width: float, height: float) -> None:
        self._w = width
        self._h = height

    @property
    def width(self) -> float:
        return self._w

    @width.setter
    def width(self, v: float) -> None:
        self._w = v

    @property
    def height(self) -> float:
        return self._h

    @height.setter
    def height(self, v: float) -> None:
        self._h = v

    def area(self) -> float:
        return self._w * self._h


class Square(Rectangle):
    """Keeps its invariant: the sides are always equal."""

    def __init__(self, side: float) -> None:
        super().__init__(side, side)

    @Rectangle.width.setter
    def width(self, v: float) -> None:
        self._w = self._h = v

    @Rectangle.height.setter
    def height(self, v: float) -> None:
        self._w = self._h = v


def stretch(r: Rectangle) -> None:
    """A caller written against Rectangle's contract: height is independent of width."""
    original = r.width
    r.height = r.height + 4
    assert r.width == original, f"width moved from {original} to {r.width}"
    assert r.area() == original * r.height


stretch(Rectangle(3, 5))                    # holds

try:
    stretch(Square(3))                      # same call, same type annotation
    raise SystemExit("unreachable")
except AssertionError as e:
    print("LSP violated:", e)               # width moved from 3 to 7

The syntax to notice:

What actually happens when you run it

stretch(Rectangle(3, 5)) passes. Height goes from 5 to 9, width stays 3, and area() is 3 * 9 = 27, which matches original * r.height.

stretch(Square(3)) fails. The square starts 3 by 3. Setting the height to 3 + 4 = 7 also drags the width from 3 to 7, and the first assert reports the move: width moved from 3 to 7.

Now look at where the blame sits. stretch is correct — it does only what Rectangle invites. Square is correct — it keeps its own invariant.

The bug is the inheritance edge, and nothing in the type system reports it. The annotation says Rectangle, a type checker is satisfied, and the failure appears at runtime inside a function that was written before Square existed.

The rule that predicts it

Two terms first. A precondition is what a method demands of its caller before it will work. A postcondition is what it guarantees on the way out.

Each row below is one way a subtype can break its base’s contract, with the reason it breaks a caller and a concrete instance of it.

A subtype may notBecauseViolation in the wild
Strengthen a preconditionThe caller already checked the base’s weaker oneBase accepts any int, subclass rejects negatives
Weaken a postconditionThe caller relies on the base’s guaranteeBase returns a sorted list, subclass returns any order
Break an invariant of the baseThe caller reasons with it silentlySquare cannot honour “width and height are independent”
Throw a new exception typeThe caller’s except clauses were written for the baseReadOnlyList.append raises NotImplementedError

The version you meet in production

The last row is the one that actually appears in real code. collect below is written against the built-in list, and does the only thing list invites it to do.

class ReadOnlyList(list):
    def append(self, item: object) -> None:
        raise NotImplementedError("read only")


def collect(sink: list, items: list) -> list:
    for i in items:
        sink.append(i)                      # correct against list's contract
    return sink


assert collect([], [1, 2]) == [1, 2]
try:
    collect(ReadOnlyList(), [1, 2])
    raise SystemExit("unreachable")
except NotImplementedError:
    print("LSP violated: a subtype removed a capability its base promised")

ReadOnlyList is a list by declaration and refuses that invitation, so collect explodes on an input whose type annotation is perfectly satisfied.

One caveat about the name, because the class does not live up to it: only append was overridden, so extend, insert, l += [1] and l[0] = x all still mutate it. ReadOnlyList is not read-only, and making it so would mean overriding every mutator list exposes and remembering the ones you forgot.

That does not weaken the point — it sharpens it. Removing one capability the base promised is already enough to break a caller written against the base, and the caller here breaks on the first line that touches it. A subtype does not have to fail comprehensively to fail.

A subclass that removes a capability is not a subclass. Inheritance can only add.

The fix: stop inheriting

The repair is to replace the broken hierarchy with two unrelated types that share a role — and to price what that costs.

The mutable Rectangle was the trap, not the geometry.

Make the shapes immutable value objects — a value object is one whose identity is entirely its contents, so two rectangles with the same width and height are the same rectangle — and relate them by a role they both fill rather than by a hierarchy.

In the block below, notice that Rect and Sq never mention Shape, and that total_area accepts both anyway.

from dataclasses import dataclass
from typing import Protocol


class Shape(Protocol):
    def area(self) -> float: ...


@dataclass(frozen=True)
class Rect:
    width: float
    height: float

    def area(self) -> float:
        return self.width * self.height


@dataclass(frozen=True)
class Sq:
    side: float

    def area(self) -> float:
        return self.side * self.side


def total_area(shapes: list[Shape]) -> float:
    return sum(s.area() for s in shapes)


assert total_area([Rect(3, 5), Sq(4)]) == 31        # 15 + 16
print("shared role: no edge to break, because there is no edge")

Three Python idioms appear here for the first time, and all three recur throughout the chapter.

@dataclass is a decorator that reads the annotated names under the class — width: float, height: float — and writes the boilerplate for you: a constructor that takes those fields in order, an equality test that compares them, and a readable repr. Rect(3, 5) works because the decorator generated __init__.

frozen=True makes instances immutable: after construction, r.width = 9 raises FrozenInstanceError instead of assigning. That single argument is what makes the stretch bug unwritable — there is no setter to hijack.

Protocol is Python’s structural interface. The nominal kind — an ABC you inherit from, seen under ISP below — requires a class to declare that it implements it. A Protocol requires only that the class have the right methods. Rect and Sq inherit nothing from Shape and are still accepted wherever a Shape is expected, because both define area(). The ... in the body is the literal token Ellipsis, used here to mean “no implementation, this is just the shape of the method”.

That last idea has a name you will hear in interviews: duck typing. At runtime Python never checks what a value claims to be, only whether it has the method you called. Protocol is that habit written down so a static type checker can check it too.

Confirm the arithmetic: 3 x 5 = 15 and 4 x 4 = 16, so 15 + 16 = 31. There is no inheritance edge, so there is nothing to violate, and stretch is impossible to write because nothing is mutable.

The cost is real and should be stated. Rect and Sq share no code, so a genuinely common helper has to be a free function or a mixin — a small class that exists only to be inherited alongside another for the methods it carries. And a Protocol gives you no runtime guarantee that a class implements it until you call the method and find out.

The assumption this structure encodes

The Shape protocol assumes the set of shapes will grow and the set of operations will not. Adding Triangle is one new file, and every existing function keeps working.

Assume the reverse — shapes are settled, operations churn — and this structure is the expensive one, because adding perimeter() means editing Rect, Sq, and every other shape.

Under that assumption you would keep the shapes as plain data and write area(shape) and perimeter(shape) as free functions, paying one edit per new shape instead of one per new operation.

No mainstream language makes both directions cheap at once; choosing an object model is choosing which of the two you expect to happen more often.


Composition over inheritance

There is an arithmetic reason to prefer holding a part over inheriting from a base, and a one-line rule that decides between them.

Composition means an object holds another object as a field and delegates work to it — a has-a relationship — as against inheritance’s is-a.

The reason to prefer it is arithmetic: inheritance encodes one axis of variation per hierarchy, and two axes multiply.

Here is that arithmetic for a vehicle model with two independent axes, then three. The last line is composition; the two above it are inheritance.

powertrain kinds (gas, electric, hybrid)                3
body kinds (car, truck)                                 2

subclasses to cover both axes        3 x 2           =  6
now add air/ground                   3 x 2 x 2       =  12
composed parts instead               3 + 2 + 2       =  7

Read it as a sentence. To cover every combination by inheritance you need one class per combination, so three powertrains and two bodies is 3 x 2 = 6 leaf classes — a leaf being a class at the bottom of the hierarchy that you actually instantiate. Adding a third axis with two values takes it to 3 x 2 x 2 = 12.

To cover the same ground by composition you need one part per value, so 3 + 2 + 2 = 7 small classes that you assemble.

Twelve classes for three axes, versus seven parts you assemble.

And the twelve are not just more numerous. ElectricFlyingTruck has one obvious place for its own code and no obvious place for the electric-specific code it shares with ElectricCar. Push that shared code up into a common base and you get a diamond: two classes inheriting from the same base, and a third inheriting from both, so Python has to consult its method resolution order (MRO) to decide whose version wins. Leave it where it is and you get copy-paste. Both start the same way.

The table below is the same trade-off in five rows. The row that decides most real arguments is “Testing a variation”.

InheritanceComposition
Relationshipis-a, fixed at class-definition timehas-a, changeable at runtime
Adds a variation axis byMultiplying the class countAdding one field
Reuse mechanismSharing a superclassDelegating to a part
Testing a variationInstantiate the leaf, get the whole chainInject a fake part
CostSubclasses depend on base internals; changing the base breaks unknown childrenMore constructor wiring, and one more hop to read

(“Inject a fake part” means handing the object a stand-in collaborator from outside instead of the real one. That technique is dependency injection, and it gets its own worked example under D — Dependency inversion.)

The rule that resolves it: inherit to be substitutable, compose to reuse.

If your reason for class B(A) is “B needs A’s save() method”, it is composition. If your reason is “every function that takes an A should accept a B”, it is inheritance.

The assumption this structure encodes

Composing parts assumes the axes vary independently — that any powertrain can go in any body, and that no combination needs special code. That assumption is what makes 3 + 2 + 2 legitimate rather than optimistic.

If electric trucks price differently from electric cars, the axes interact. The combination has behaviour of its own, and neither seven parts nor twelve subclasses is right: you need an explicit object for the combination, or a rules table keyed by the pair.

Whenever the arithmetic says addition rather than multiplication, you have assumed independence — say so out loud, because it is the assumption most likely to be wrong.


What a class structure assumes

Running underneath every design so far is one transferable skill: every arrangement of classes is a bet about what will change, and you can read the bet straight off the code.

An abstraction is not neutral. Choosing where to put a boundary is choosing what is allowed to vary behind it and what is frozen in front of it.

Three questions recover that choice from any design — yours or someone else’s:

  1. What did I assume varies? Whatever became a parameter, a strategy object (defined below), a table entry, or a subtype. Variation is cheap on that axis.
  2. What did I assume is fixed? Whatever became a method signature, an enum member list, a field’s type, or the shape of a return value. Variation is expensive on that axis, because changing it edits every implementation and every caller.
  3. What would the opposite assumption have produced? This is the question that turns a design review into a conversation, and it is the one interviewers reward, because a candidate who can state the alternative was choosing rather than pattern-matching.

Apply those three questions to the five designs in this chapter and you get the table below. Read the last column as the answer to question 3 — what you would have built under the opposite bet.

DesignAssumed to varyAssumed fixedWhat a different assumption produces
AccountHow money is storedThe operations: deposit, read balanceIf currency varies too, integer cents is wrong; you need a Money value with an amount and a currency, and deposit takes a Money
Shape protocolThe set of shapesThe set of operations, here just areaIf operations churn instead, plain data plus free functions is cheaper, because adding perimeter edits every shape class
Composed partsPowertrain and body, independentlyThat the axes never interactIf electric trucks behave unlike electric cars, you need an object or a table for the combination
FeeModel (below)The pricing ruleThe question asked of it: hours in, cents outIf the input varies, the signature is what breaks — the worked case is 04
Notifier (below)The delivery channelOne recipient, one body, fire and forgetIf a message can fan out to many recipients, send becomes send_all on every implementation at once

The failure mode is always the same shape: the thing you froze is the thing that moved.

You cannot avoid it by freezing less — an interface that promises nothing is not an interface. So the skill is not “guess right”, it is “know which bet you placed, and know what the losing case costs”. A bet you can name is a bet you can revise in one conversation.


SOLID, as five violations and their fixes

SOLID is an acronym for five design principles: single responsibility, open/closed, Liskov substitution, interface segregation, and dependency inversion.

Each one is best learned as a violation you can recognise on sight plus the fix, because that is how they arrive in real code and in interviews.

S — Single responsibility

The single responsibility principle (SRP) says a class should have one reason to change.

“One reason to change” is vague until you restate it as one actor: a class should have one source of change requests. An actor here is a person or team who can walk up and ask for a change — finance, design, the database administrator.

The violation: three actors own one class. The comments name them.

class Ticket:                                        # three actors own this class
    def __init__(self, hours: int) -> None:
        self.hours = hours

    def fee(self) -> int:                            # finance changes this
        return self.hours * 200

    def to_html(self) -> str:                        # design changes this
        return f"<b>{self.fee()}</b>"

    def save(self, conn: object) -> None:            # the DBA changes this
        pass


assert Ticket(3).fee() == 600

Three teams own one file. Finance sets the rate (3 x 200 = 600 cents), the design team owns the markup, and the database administrator (DBA) owns how a row is written.

One file means one test suite and one deployment risk. Concretely: the rendering test needs a database connection to construct the object it renders, and the pricing change is a diff that also touches the printer.

The fix is not “three files” as a matter of taste. It is that the fee change and the HTML change can now break each other, and after the split they cannot.

Split by actor: Ticket holds state, a TicketFee prices, a TicketView renders, a TicketRepo persists. Note in the block below that Ticket no longer knows about money, markup, or databases — and that no test needs more than one of the four classes.

class Ticket:                                        # operations owns this
    def __init__(self, hours: int) -> None:
        self.hours = hours


class TicketFee:                                     # finance owns this
    def __init__(self, rate: int) -> None:
        self.rate = rate

    def cents(self, ticket: Ticket) -> int:
        return ticket.hours * self.rate


class TicketView:                                    # design owns this
    def to_html(self, cents: int) -> str:
        return f"<b>{cents}</b>"


class TicketRepo:                                    # the DBA owns this
    def save(self, ticket: Ticket, conn: object) -> None:
        pass


t = Ticket(3)
assert TicketFee(200).cents(t) == 600                # finance's test: no HTML, no DB
assert TicketView().to_html(600) == "<b>600</b>"     # design's test: no Ticket, no DB
print("SRP: one actor per class, and no test needs two of them")

Count what changed. A rate change now edits TicketFee and re-runs one test. Before the split it edited the file that also contained the renderer and the persistence code, so all three had to be re-tested.

TicketFee is a swappable rule object. That shape has a name and gets its own section next.

O — Open/closed

The open/closed principle (OCP) says a module should be open to extension and closed to modification — abstract words, until you see the switch statement that violates it and the object that fixes it.

The violation is a branch on a type tag — a string or enum carried in the data that says which kind of thing this is.

def price(kind: str, hours: int) -> int:
    if kind == "car":
        return hours * 200
    if kind == "truck":
        return hours * 350
    raise ValueError(kind)                           # add EV -> edit this function


assert price("car", 3) == 600

Now the requirement lands: “electric vehicle (EV) bays are billed per kilowatt-hour.”

You edit price, and because you touched that file you must re-test car and truck as well — neither of which changed.

The polymorphic version adds a class and edits nothing. Watch that Hourly and FlatThenHourly never mention each other, and that the dictionary at the bottom is the only place a name is bound to a rule.

from typing import Protocol


class FeeModel(Protocol):
    def fee(self, hours: int) -> int: ...


class Hourly:
    def __init__(self, rate: int) -> None:
        self.rate = rate

    def fee(self, hours: int) -> int:
        return hours * self.rate


class FlatThenHourly:
    """New requirement, new class, zero edits above this line."""

    def __init__(self, flat: int, rate: int) -> None:
        self.flat = flat
        self.rate = rate

    def fee(self, hours: int) -> int:
        return self.flat + max(0, hours - 1) * self.rate


models: dict[str, FeeModel] = {"car": Hourly(200), "ev": FlatThenHourly(500, 100)}
assert models["car"].fee(3) == 600
assert models["ev"].fee(3) == 700                    # 500 + 2 x 100
print("open/closed: the new rule is a new file")

This arrangement has a name worth knowing. It is the strategy pattern: a rule extracted into its own object so it can be swapped without touching the code that applies it.

Say it in the chapter’s own terms. What varies is the fee rule. What stays fixed is the signature fee(hours) -> cents and every caller written against it. What you would otherwise have written is the if kind == ... chain above — one function that grows a branch per rule and has to be edited and re-tested every time a rule is added.

Map the pieces: FeeModel is the promise (“give me hours, I return cents”), Hourly and FlatThenHourly are two rules that keep it, and the dictionary is the registry that picks one.

Check the numbers. Three hours at 200 cents is 600. The flat-then-hourly model charges the flat 500 for the first hour plus 2 x 100 for the remaining two, so 700.

The cost, which you must say out loud: the dispatch now happens through a dictionary, so a bad key is a runtime KeyError rather than a missing elif you could see. Open/closed buys you edit-locality and charges you readability of the end-to-end flow.

L — Liskov substitution

The Liskov substitution principle is the third letter, and it is the one already demonstrated in running code above: the Square/Rectangle break, where a function written against Rectangle fails when handed a Square.

The one-line interview version: “a subtype must be usable by code that has never heard of it.”

I — Interface segregation

The interface segregation principle (ISP) says no client should be forced to depend on methods it does not use. The fat interface below forces exactly that, and then gets split.

A fat interface is one that bundles capabilities that not every implementer has. Below, Machine demands printing, scanning and faxing, and OldPrinter can only print.

from abc import ABC, abstractmethod


class Machine(ABC):                                  # fat interface
    @abstractmethod
    def do_print(self, doc: str) -> str: ...

    @abstractmethod
    def scan(self, doc: str) -> str: ...

    @abstractmethod
    def fax(self, doc: str) -> str: ...


class OldPrinter(Machine):
    def do_print(self, doc: str) -> str:
        return f"printed {doc}"

    def scan(self, doc: str) -> str:
        raise NotImplementedError                    # the tell

    def fax(self, doc: str) -> str:
        raise NotImplementedError

Two new idioms.

ABC stands for abstract base class: a class that exists to be inherited from and cannot itself be instantiated. Writing Machine() raises TypeError.

@abstractmethod marks a method that subclasses are obliged to override. If a subclass leaves one unimplemented, constructing that subclass raises TypeError immediately rather than failing later at the call.

That makes ABC the nominal counterpart to the structural Protocol seen earlier. With an ABC a class must declare its parent and is checked at construction. With a Protocol it need only have the methods, and is checked, if at all, by a static type checker.

Now note how OldPrinter satisfies the obligation dishonestly. It overrides all three methods, so construction succeeds — and two of them raise NotImplementedError the moment anyone calls them.

There are two costs, and the second is the one that gets probed.

First, OldPrinter carries two methods it cannot honour.

Second, the type now lies. A function that takes a Machine is told it may scan, so every caller must either know the concrete class or wrap the call in a try — which is the Liskov violation again, arriving through a different door.

The fix: one interface per capability

Split the fat interface into three small ones. The diagram below is the target shape; the code after it is the same thing you can run.

classDiagram
    class Printable {
        <<interface>>
        +do_print(doc) str
    }
    class Scannable {
        <<interface>>
        +scan(doc) str
    }
    class Faxable {
        <<interface>>
        +fax(doc) str
    }
    class OldPrinter {
        +do_print(doc) str
    }
    class AllInOne {
        +do_print(doc) str
        +scan(doc) str
        +fax(doc) str
    }

    Printable <|.. OldPrinter : implements
    Printable <|.. AllInOne : implements
    Scannable <|.. AllInOne : implements
    Faxable <|.. AllInOne : implements

The notation is a Unified Modeling Language (UML) class diagram, worth decoding once.

Each box is a type. The name sits on top; the methods sit below it, and a leading + means public — visible to any caller — as against - for private.

The <<interface>> marker is a stereotype, UML’s way of saying “this box is not a concrete class, it is a set of methods somebody else must supply”. Here Printable, Scannable and Faxable each declare exactly one method and no implementation.

The dashed arrow with a hollow triangular head, written <|.. in this text syntax, means realizes or implements. It points from the implementer to the interface it satisfies.

So the diagram makes four claims, one per arrow. OldPrinter implements Printable and nothing else, which is the honest statement of what that hardware can do — it has no scan or fax method at all now, rather than two methods that raise. AllInOne implements all three interfaces, printing, scanning and faxing for real. Nothing inherits from anything concrete, so there is no base class whose internals a subclass could depend on.

Here is the diagram as code. OldPrinter is deliberately rebuilt: same name, and now it has one method instead of three.

from typing import Protocol


class Printable(Protocol):
    def do_print(self, doc: str) -> str: ...


class Scannable(Protocol):
    def scan(self, doc: str) -> str: ...


class Faxable(Protocol):
    def fax(self, doc: str) -> str: ...


class OldPrinter:                                    # claims exactly what it can do
    def do_print(self, doc: str) -> str:
        return f"printed {doc}"


class AllInOne:
    def do_print(self, doc: str) -> str:
        return f"printed {doc}"

    def scan(self, doc: str) -> str:
        return f"scanned {doc}"

    def fax(self, doc: str) -> str:
        return f"faxed {doc}"


def print_batch(p: Printable, docs: list) -> list:    # asks for one capability
    return [p.do_print(d) for d in docs]


assert print_batch(OldPrinter(), ["a"]) == ["printed a"]
assert print_batch(AllInOne(), ["a"]) == ["printed a"]
assert not hasattr(OldPrinter(), "scan")             # no method left to lie
print("ISP: nothing raises, because nobody promised what it cannot do")

print_batch needs printing, so it asks for Printable and nothing more. Both classes satisfy it. The NotImplementedError has no place left to live, because the method that raised it no longer exists.

Interfaces belong to the caller, not to the implementer — that is the sentence that shows you understand ISP rather than remembering it. Printable exists because print_batch needed it, not because printers wanted a base class.

D — Dependency inversion

The dependency inversion principle (DIP) says high-level policy must not depend on low-level detail. The concrete consequence is whether your test needs a server running.

Both policy and detail should meet at an abstraction that the high-level module owns and defines.

In the block below, OrderPlacer is the policy and SmtpNotifier is the detail. Watch that OrderPlacer never names SmtpNotifier, and that the test at the bottom runs with no network.

from dataclasses import dataclass, field
from typing import Protocol


class Notifier(Protocol):
    def send(self, to: str, body: str) -> None: ...


class SmtpNotifier:
    def send(self, to: str, body: str) -> None:
        raise RuntimeError("opens a real socket")    # untestable in CI


@dataclass
class FakeNotifier:
    sent: list = field(default_factory=list)

    def send(self, to: str, body: str) -> None:
        self.sent.append((to, body))


@dataclass
class OrderPlacer:
    notifier: Notifier                               # depends on the promise

    def place(self, email: str) -> None:
        self.notifier.send(email, "order received")


fake = FakeNotifier()
OrderPlacer(notifier=fake).place("[email protected]")
assert fake.sent == [("[email protected]", "order received")]
print("DIP: the policy is testable because the detail is injected")

The technique on display is dependency injection: an object does not construct the things it collaborates with, it receives them from outside, usually through its constructor.

OrderPlacer never writes SmtpNotifier(). It declares a field of type Notifier — the promise — and whoever builds it decides which implementation goes in.

The two implementations:

Two more small pieces of syntax. field(default_factory=list) tells @dataclass to build a fresh empty list for each instance; writing sent: list = [] would share one list across every object ever created, which is a classic Python bug. And @dataclass without frozen=True produces a mutable object, which is what FakeNotifier needs in order to append.

Now the payoff. If OrderPlacer had constructed SmtpNotifier() itself, that assert is impossible without a mail server or a monkeypatch — code in the test that reaches in and replaces the class at runtime.

The observable consequence of DIP is that your test needs nothing installed. That is the way to argue it in an interview, because it is checkable rather than aesthetic.

The assumption this structure encodes

Notifier assumes the channel varies — email today, SMS or a push notification tomorrow — while the message shape is fixed at one recipient, one body, no reply.

Change that assumption and the interface is what breaks. If a notification can fan out to a list of recipients, send(to, body) is too narrow, and widening it edits every implementation and every caller at once.

That is the same failure the parking lot chapter walks into with a fee signature (04), and it is worth recognising as a family rather than a one-off.


Coupling and cohesion, made concrete

Coupling and cohesion usually mean no more than “I do not like this code”. Both convert into counts you can put on a whiteboard.

Coupling

Coupling: how many other modules must exist for this one to run, and how many break when it changes.

There are two standard counts, and they point in opposite directions:

Here is a worked example for one module, plus the ratio the two counts feed.

efferent coupling Ce (modules this one imports)         8
afferent coupling Ca (modules that import this one)     1

instability   8 / (8 + 1)                            ~= 0.89

Instability is that ratio, Ce / (Ce + Ca), and it runs from 0.0 to 1.0. Here 8 / 9 = 0.888..., which rounds to 0.89.

What the ends mean:

The defect is a module with high Ce and high Ca: it changes often and everything breaks when it does.

The train wreck

The everyday version of coupling is the train wreck: a chain of attribute accesses that walks through several objects to reach a value.

The block below computes the same string twice. Compare label_bad and label_good and count the dots in each.

class City:
    def __init__(self, name: str) -> None:
        self.name = name


class Address:
    def __init__(self, city: City) -> None:
        self.city = city


class Customer:
    def __init__(self, address: Address) -> None:
        self.address = address

    def shipping_label(self) -> str:
        return self.address.city.name.upper()               # the reach, in ONE class


class Order:
    def __init__(self, customer: Customer) -> None:
        self.customer = customer

    def label_bad(self) -> str:
        return self.customer.address.city.name.upper()      # knows 3 structures

    def label_good(self) -> str:
        return self.customer.shipping_label()               # knows 1 promise


o = Order(Customer(Address(City("berlin"))))
assert o.label_bad() == "BERLIN"
assert o.label_good() == "BERLIN"
print("Demeter: same answer, one dependency instead of three")

The rule being illustrated is the Law of Demeter, usually stated as “only talk to your immediate friends”: a method may use its own fields and its own arguments, and should not go walking through them to reach strangers.

label_bad breaks if Address renames city, if City renames name, or if either becomes optional. label_good breaks only if Customer changes its promise.

Note that Customer.shipping_label still reaches through two objects. The reach did not vanish — it moved into the one class that legitimately knows about addresses.

Count the dots: each one past the first is a class whose internals you just took a dependency on.

Cohesion

Cohesion: what fraction of a class’s methods touch the same fields.

The class below has three methods and three fields, and the methods split cleanly into two groups that never meet.

class UserAccount:                                   # two field groups, one constructor
    def __init__(self, email: str, theme: str) -> None:
        self.email = email                           # group A: identity
        self.token = ""                              # group A
        self.theme = theme                           # group B: presentation

    def log_in(self) -> str:                         # touches A only
        self.token = f"tok-{self.email}"
        return self.token

    def log_out(self) -> None:                       # touches A only
        self.token = ""

    def css_class(self) -> str:                      # touches B only
        return f"theme-{self.theme}"


u = UserAccount("[email protected]", "dark")
assert u.log_in() == "[email protected]"
assert u.css_class() == "theme-dark"
print("cohesion: no test exercises both halves")

log_in and log_out touch email and token. css_class touches theme. Nothing touches both groups, so the two halves share nothing but a constructor.

That is two classes: a Session and a Preferences. The mechanical way to see it is that no test exercises both halves — as the two asserts above show.

Both are the same prediction

The metric that matters in the interview is the one from 02: files edited per requirement change, the output defined in What goes in and what comes out.

Coupling and cohesion are two ways of predicting that number before you have to pay it.


Singleton: the honest answer

The Singleton is the design pattern candidates are most likely to propose and least likely to have priced. It has three costs, and there is a simple thing to do instead.

A Singleton is a class that permits exactly one instance of itself to exist and hands that instance to anyone who asks, through a global access point.

It will come up. You should be the candidate who says what it costs.

A Singleton is a global variable with a constructor attached. Everything wrong with globals is wrong with it, plus two things that are specific to the pattern.

1. It hides the dependency

A function whose signature takes nothing but that calls Config() inside depends on a global.

You cannot tell what it needs by reading its signature, so you cannot tell what breaks when the config changes.

2. It makes tests order-dependent

This is the failure most people have actually met. Both test functions below are individually correct, and they are called in sequence at the bottom.

class Config:
    _inst = None

    def __new__(cls) -> "Config":
        if cls._inst is None:
            cls._inst = super().__new__(cls)
            cls._inst.retries = 3
        return cls._inst


def test_retry_disabled() -> None:
    Config().retries = 0                     # a legitimate thing for a test to do
    assert Config().retries == 0


def test_default_retries() -> None:
    assert Config().retries == 3             # passes alone, fails after the other


test_retry_disabled()
try:
    test_default_retries()
    raise SystemExit("unreachable")
except AssertionError:
    print("singleton: test 2 fails only because test 1 ran first")

__new__ is the method Python calls to allocate an object, before __init__ fills it in. Overriding it is how the single-instance trick is done: the class keeps the one instance in _inst, and every later Config() returns that same object instead of a new one.

So the two tests, which never mention each other, are writing to the same memory. Run the second one alone and it passes. Run it after the first and retries is still 0.

Both tests are correct. The suite is red, and it is red in a way that changes with test ordering, parallelism, and which file you ran.

There is no fix inside the pattern — only a reset() method that exists purely for tests, which is an admission that the design is wrong.

3. Lazy initialization is a race

Lazy means the instance is created on first use rather than at startup.

Look again at if cls._inst is None: cls._inst = .... That is a check and a store with a gap between them. Two threads that both pass the check both construct, and the loser’s instance is silently discarded along with whatever was registered on it. That is a race condition: the result depends on which thread happens to run first.

Python’s global interpreter lock (GIL) — the mechanism that allows only one thread to execute Python instructions at a time — does not save you. The check and the assignment are separate bytecode instructions, the low-level steps the interpreter actually executes, and the interpreter may switch threads between them.

The classic fix, double-checked locking — test, take a lock, test again — is famously easy to get wrong in languages without a memory-model guarantee. That is a lot of complexity to pay for a variable.

What to use instead

Make “there is one of these” a decision about lifetime rather than about access. That is the whole of it, and the block below is the whole of the code.

from dataclasses import dataclass


@dataclass(frozen=True)
class Settings:
    retries: int = 3


def build_app(settings: Settings) -> dict:
    return {"retries": settings.retries}


assert build_app(Settings())["retries"] == 3
assert build_app(Settings(retries=0))["retries"] == 0    # no reset needed
print("DI: one instance is a lifetime decision, not an access decision")

That is dependency injection again — build_app is handed its settings rather than fetching them.

Note what disappeared: no _inst, no __new__, no reset(). The two asserts can run in either order, or at the same time, because each constructs its own frozen Settings.

Construct one instance in main and pass it down. You still have exactly one, which was the actual requirement. You just stopped enforcing it with a global accessor. Tests construct their own, in any order, in parallel.

In Python specifically, if you genuinely want process-wide state, a module-level object is the idiomatic form. import is already cached, so the module body runs once no matter how many files import it, and it is already thread-safe under the import lock. The metaclass gymnastics — a metaclass being a class that controls how other classes are built — and the __new__ override buy nothing over that.

The interview line, and it is a strong one:

“I would not make it a Singleton. There is one parking lot, but that is a lifetime decision I make in main by constructing one and passing it in — not an access decision I impose on every class in the system. Singleton would hide that dependency in every signature, make my tests order-dependent, and add a lazy-init race I would then have to defend.”

When it is defensible: a stateless, immutable, process-wide resource where injection is genuinely impractical — a logger, or a connection pool, meaning a fixed set of already-open database connections that callers borrow and return. Even then, the constructor should be injectable so tests can substitute one.


What interviewers probe

Every question below is one they actually ask. The middle column is the thing they are measuring, and it is the part candidates miss.

They askThey are checkingStrong answer
“What is encapsulation?”Whether you say “private fields”“One named place may violate the invariant” - plus the representation change, and the type gate that keeps a float out of the guarded method
“Inheritance or composition?”Whether you have a rule“Inherit to be substitutable, compose to reuse”, plus the 3 x 2 x 2 = 12 count
“Give me an LSP violation”Whether you have hit oneSquare/Rectangle with the mutable setter, or a subtype that raises on an inherited method
“Why not a switch statement?”Whether you can count the cost“One type times four switches is four edits, and no type checker flags any of them”
“Is this SRP-compliant?”Whether you use one actor per class“Finance, design and the DBA all change this file today”
“Would you use a Singleton?”Almost always whether you say noThe hidden dependency, the ordered tests, the lazy-init race
“What does this interface cost?”Whether you price your own abstractions“One indirection, and a bad registry key is now a runtime error”
“What does this design assume?”Whether you chose or pattern-matched“It assumes shapes vary and operations do not; if that flips, free functions are cheaper”
“How do you know this design is good?”Whether you have a metric“Files edited per requirement change”

Cheat sheet

One row per idea: the sentence to say, and the consequence to show when they ask for evidence. If you can only remember one column, remember the third — it is what turns a definition into an answer.

ConceptThe one sentenceThe consequence to show
EncapsulationExactly one named place may break the invariantFloat dollars to integer cents, with no caller edits — and a type gate on every door
AbstractionYou only promise what you can still offer after a rewriteA Cache that exposes raw_dict() cannot move to Redis
InheritanceA promise of substitutability, not a code-sharing deviceA subtype must work in code that never heard of it
PolymorphismAdd a case without editing the dispatcher4 switches on type means 4 edits per new type
LSPPreconditions may not tighten, postconditions may not weakenstretch(Square(3)) fails an assert stretch(Rectangle(3,5)) passes
SRPOne actor per classFinance, design, and the DBA in one file
OCPNew behaviour is a new classNew fee rule: one file added, zero edited
ISPInterfaces belong to the callerscan() raising NotImplementedError is the tell
DIPPolicy owns the abstraction, detail implements itThe test runs with no mail server
CompositionAxes add instead of multiplying3 + 2 + 2 = 7 parts versus 12 subclasses
CouplingCount what breaks when this changesInstability Ce / (Ce + Ca); dots past the first
CohesionDo the methods touch the same fieldsTwo disjoint field groups is two classes
SingletonA global with a constructorOrder-dependent tests; inject one instance instead
AssumptionsEvery boundary bets on what variesName the frozen axis and what a different bet would cost

Next: 04 — Parking Lot System — the canonical first problem, where the fit rule and the fee rule are both strategies, and where two cars race for the last spot.