Low-level design is mostly one question asked repeatedly: what is each piece of code allowed to know about the others? Every class you draw is a line between what a caller may depend on and what you reserve the right to change.
Object orientation is one well-worn set of answers to that question. Its four ideas are usually taught as language features — private fields, abstract classes, extends, method overriding. That framing makes them feel like syntax to memorise. They are more useful read as boundary-drawing tools.
| Idea | The boundary it draws | What it buys you |
|---|---|---|
| Encapsulation | Callers touch behaviour, not state | Rules that cannot be broken from outside |
| Abstraction | Callers depend on a contract, not a class | Freedom to swap implementations |
| Inheritance | A subtype must honour its parent's contract | Shared behaviour, paid for in coupling |
| Polymorphism | One call site, many implementations | New cases without editing old code |
Encapsulation: protecting a rule, not hiding a field
An account balance must never go negative. If the field is public, that rule lives nowhere:
account.balance = -500 # no error, no log, no way to find out laterNothing is broken yet. It breaks three days later in a report, and the code that caused it is long gone from the stack trace. This is the real cost of exposed state: the failure surfaces far from its cause.
Encapsulation means the only way to change the state is through code that enforces the rule:
class Account:
def __init__(self):
self._balance = 0 # internal by convention
def withdraw(self, amount):
if amount <= 0:
raise ValueError("amount must be positive")
if amount > self._balance:
raise InsufficientFunds(available=self._balance, requested=amount)
self._balance -= amount
@property
def balance(self):
return self._balance # readable, not writableNow the invariant is checked in exactly one place, and every caller in the codebase gets it for free.
Python has no real privacy: a leading underscore is a convention, and the double underscore in __balance only mangles the name to _Account__balance, which any determined caller can still reach. That is fine. Encapsulation is a design boundary the team agrees to respect; the compiler enforcing it, as in Java or C#, is a convenience rather than the point.
Abstraction: naming what something does, not how
Abstraction is deciding what a caller is allowed to depend on, and writing that down as a contract.
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
@abstractmethod
def charge(self, amount_cents: int) -> str: ... # returns a transaction id
@abstractmethod
def refund(self, transaction_id: str) -> None: ...Checkout code written against PaymentProcessor has no idea whether the money moves through Stripe's REST API or a bank file uploaded overnight. Swapping one for the other touches one line where the object is constructed.
The value is not that the details are secret — you can read them. It is that nothing depends on them, so changing them cannot break anything.
Inheritance: a promise, not a shortcut
Inheritance is taught as code reuse: put shared fields in a parent, save typing in the children. That framing produces the worst class hierarchies in software.
Inheritance is really a substitutability promise. Writing class Truck(Vehicle) claims that anywhere the program expects a Vehicle, a Truck will behave acceptably. This is the Liskov Substitution Principle, and violating it produces bugs that read as impossible:
class Bird:
def fly(self): ...
class Penguin(Bird):
def fly(self):
raise NotImplementedError("penguins cannot fly")Every function that safely calls bird.fly() is now a crash waiting for the wrong input. The hierarchy models a biological fact rather than a shared behaviour, and the code pays for it.
The other cost is coupling. A subclass depends on its parent's internals, not just its interface, so changing the parent can silently break children you have never read — the fragile base class problem. And inheritance is fixed at compile time: an object cannot change its class at runtime.
Composition avoids both. Instead of being a thing, hold one:
class Truck:
def __init__(self, engine, cargo_hold):
self.engine = engine # has-a, chosen at runtime
self.cargo_hold = cargo_hold| Inheritance | Composition | |
|---|---|---|
| Relationship | is-a | has-a |
| Chosen at | Compile time, permanently | Runtime, swappable |
| Coupling | Tight — subclass sees internals | Loose — only the public interface |
| Reuse across hierarchies | One parent chain only | Any combination |
| Reach for it when | The subtype genuinely substitutes for the parent | You want behaviour, not identity |
The rule of thumb — prefer composition to inheritance — is not a ban. Inheritance earns its place when the subtype truly substitutes for the parent and the shared behaviour is stable, which is why framework base classes work well and business-object hierarchies usually do not.
Polymorphism: deleting the conditional
The four ideas build on one another: encapsulation protects a rule, abstraction names it, inheritance promises it, and polymorphism lets callers depend on the promise rather than the type.
classDiagram
class PaymentMethod {
<<interface>>
+charge(amount) Receipt
}
class Card {
+charge(amount) Receipt
}
class BankTransfer {
+charge(amount) Receipt
}
class Checkout {
-method: PaymentMethod
+complete() Receipt
}
PaymentMethod <|.. Card
PaymentMethod <|.. BankTransfer
Checkout --> PaymentMethodPolymorphism is what makes abstraction pay off. One call site, many behaviours, resolved by the object's actual type.
Its most practical use is removing type-checking chains:
# Every new shipping method edits this function
def cost(order, method):
if method == "standard":
return order.weight * 5
elif method == "express":
return order.weight * 12 + 30
elif method == "freight":
return order.pallets * 400That elif chain will be copied — badly — into tax calculation, delivery estimates and the tracking page. Each new method means finding and editing all of them, and the compiler will not tell you if you miss one.
Give each method a class implementing a common interface, and the chain collapses to a call:
def cost(order, shipper):
return shipper.cost(order) # correct implementation chosen by typeAdding freight now means adding a class, not editing existing functions — the open/closed principle in its most concrete form. Notice that the four ideas are not independent: polymorphism needs a shared contract (abstraction), which is usually declared on a base type (inheritance), over state the caller cannot corrupt (encapsulation).
Check your understanding
Common mistakes
Deep hierarchies. Five levels of inheritance means understanding a method requires reading five files. Two or three levels is usually the practical limit.
Inheriting for reuse rather than substitutability. If you would never pass the child where the parent is expected, you wanted a helper class or composition.
Anaemic classes. Fields plus getters and setters, with all the logic in a separate service, is a struct with ceremony. The rules should live with the data they constrain.
Abstracting one implementation. An interface written before a second implementation exists usually fits the first one exactly and has to be redesigned anyway. Wait for the second case.
Modelling nouns instead of behaviour. Real-world taxonomies rarely make good hierarchies — the penguin problem. Group things by what they do in your system.
Practice
You find a method containing a long chain of `if (type == 'card') … else if (type == 'transfer') …` repeated in four different places. Describe the refactor and what specifically improves.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Ask what has to happen today when a fifth payment type is added.
Show a strong answer
The problem, stated precisely: adding a fifth type means finding and editing four separate conditionals. Nothing tells you where they are, and missing one produces a bug that only appears for the new type. The knowledge of "what types exist" is duplicated four times.
The refactor: define an interface with the operation each type must support, give each type its own implementation, and let the caller hold the interface.
- Each branch of the conditional becomes a method on one class.
- The four call sites become one call through the interface.
- Adding a fifth type becomes adding one class — no existing file changes.
What specifically improves:
- The compiler now enforces completeness. A new type that fails to implement a required method does not compile. Previously, a forgotten branch compiled fine and failed at runtime for one type of customer.
- Each type's logic is together in one place, rather than scattered across four methods that each know a fragment.
- The call sites stop depending on the set of types. They depend on the operation, which does not change when the set does.
What does not improve, and is worth saying: if the four conditionals branch on different things — one on payment type, one on currency, one on region — collapsing them into one hierarchy forces unrelated axes together and makes things worse. Check that the four are genuinely the same distinction before unifying them.
A colleague says 'we use inheritance to avoid duplicating code'. Explain why this is the wrong reason, and what the right reason is.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Ask what a caller is entitled to assume when they see the base type.
Show a strong answer
Why it is the wrong reason: inheritance is not a code-sharing mechanism, it is a substitutability claim. Writing class B extends A tells every reader and every caller that a B can be used wherever an A is expected, and that it honours everything A promises. That claim is a permanent constraint on B.
Using it merely to share three helper methods buys a little deduplication and pays for it with:
- A coupling that is hard to undo. The subclass depends on the base class's internals, so changing the base can break subclasses in ways the compiler does not catch — the fragile base class problem.
- Inherited surface nobody wanted. The subclass now exposes every public method of the base, including ones that make no sense for it, and callers may use them.
- A single axis. Most languages permit one base class, so the axis you spent on code sharing is unavailable for the distinction you actually needed later.
The right reason to use inheritance: when the subtype genuinely is a kind of the supertype and can satisfy every promise the supertype makes — so that a caller holding the base type never needs to know which subtype they have. That is what enables polymorphism, which is the actual payoff.
What to use for code sharing instead: composition. Put the shared behaviour in its own class and hold an instance of it. It shares exactly the code you meant to share, adds no substitutability claim, can be swapped in tests, and can be used along more than one axis at a time.
Key takeaways
- Every OOP idea is a boundary decision: what may callers depend on, and what stays yours to change.
- Encapsulation protects an invariant, not a field. Expose the operations that keep the guarantee true, not accessors for every attribute.
- Abstraction is a contract. Its value is that nothing depends on the implementation, so the implementation can change safely.
- Inheritance is a substitutability promise. A subclass that cannot stand in for its parent is a bug waiting to be triggered.
- Prefer composition to inheritance — looser coupling, chosen at runtime, and not limited to one parent chain.
- Polymorphism deletes conditional chains, so new cases become new classes rather than edits to old code.
- The four work together: polymorphism needs a contract, over state the caller cannot corrupt.
Further reading
- Barbara Liskov and Jeannette Wing, "A Behavioral Notion of Subtyping" (ACM TOPLAS, 1994) — the formal basis for the square/rectangle problem.
- Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns (Addison-Wesley, 1994) — including "favour composition over inheritance".
- Joshua Bloch, Effective Java, third edition (Addison-Wesley, 2018), items 18–20 — inheritance versus composition, in practice.
- Alan Kay, "The Early History of Smalltalk" (ACM HOPL, 1993) — what object orientation originally meant by messaging.
- Martin Fowler, Refactoring, second edition (Addison-Wesley, 2018) — "Replace Conditional with Polymorphism".