OOP Foundations for Design
Almost everyone can recite "encapsulation, abstraction, inheritance, polymorphism" from memory. Far fewer can say why encapsulation actually matters when someone is reviewing their code, or when inheritance is the wrong tool, or what "tight coupling" costs in a real bug report. This lesson closes that gap. You'll leave it able to defend each pillar as a decision, not just name it.
The four pillars, as decisions you can defend
Encapsulation — hide the data, expose safe actions. This isn't about typing private in front of a field. The real point is: an object should never let outside code put it into a broken state.
Picture a bank account. If any code anywhere can just set balance = -500, the rule "balance never goes negative" has to be remembered and re-checked by every single piece of code that touches an account. That's fragile. Move the rule inside the account instead, and it only has to be right once.
# Weak: the rule "balance never goes negative" lives in every caller's head.
class BadAccount:
def __init__(self):
self.balance = 0 # anyone can write anything here
# Strong: the rule lives in one place, and the class enforces it.
class Account:
def __init__(self, opening_paise: int = 0):
if opening_paise < 0:
raise ValueError("opening balance cannot be negative")
self._balance = opening_paise # private by convention
@property
def balance(self) -> int: # read-only view
return self._balance
def deposit(self, paise: int) -> None:
if paise <= 0:
raise ValueError("deposit must be positive")
self._balance += paise
def withdraw(self, paise: int) -> None:
if paise > self._balance:
raise ValueError("insufficient funds")
self._balance -= paise
acct = Account(10_000)
acct.deposit(5_000)
acct.withdraw(2_000)
assert acct.balance == 13_000
for bad in (lambda: acct.withdraw(999_999), lambda: acct.deposit(-1), lambda: Account(-5)):
try:
bad()
raise AssertionError("invalid operation was allowed")
except ValueError:
pass
print("invariants held; balance =", acct.balance)Here's the payoff: if a bug report says "balance went negative," you now have exactly three methods to check, not the whole codebase. And here's a trap worth knowing: writing a public getter and a public setter for every field is not encapsulation. A setter that accepts anything is just a public field wearing a disguise. Real encapsulation means exposing actions (withdraw) that keep the object's rules intact, not raw access to its fields.
Abstraction — show what something does, hide how. PaymentProcessor.charge(amount) tells you nothing about whether it's Stripe or Razorpay underneath, so your code never has to care. Abstraction is what lets you swap one for the other later without touching anything else.
People mix up abstraction and encapsulation constantly, so here's the one line that separates them: encapsulation hides data. Abstraction hides implementation.
| Abstraction | Encapsulation | |
|---|---|---|
| Hides | Complexity — the how | Data — the internal state |
| Goal | Give a simple front door to something complicated | Stop invalid changes to internal state |
| Typical tool | An interface, an abstract class, a well-named method | Private fields, properties |
| Example | car.start() hides the ignition sequence and fuel injection |
self.__balance can't be poked from outside the class |
class BankAccount:
def __init__(self, balance):
self.__balance = balance # encapsulation: name-mangled, not reachable from outside
def deposit(self, amount): # abstraction: the caller never sees HOW this is checked
if amount <= 0: # or stored — just that deposit() does the right thing
raise ValueError("Deposit must be positive")
self.__balance += amount
@property
def balance(self):
return self.__balance # controlled read access — this is the encapsulation partdeposit() is abstraction: it gives the caller one simple verb and hides the validation underneath. __balance plus the read-only balance property is encapsulation: it stops account.__balance = -1000 from skipping the rules entirely (Python renames __balance internally so outside code can't reach it directly). The two work as a pair: abstraction shapes the interface, encapsulation decides who gets to touch what behind it.
Inheritance — share a contract, not a shortcut to reuse code. The one legitimate reason to inherit is that the child is genuinely, always substitutable for the parent — a SavingsAccount really is an Account everywhere an Account is expected. Inheriting purely to avoid retyping a method is the single most common design mistake in this whole topic, and the next section shows exactly why.
Polymorphism — one call, many behaviours. This is the thing that gets rid of long chains of if type == ....
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Circle(Shape):
def __init__(self, r: float): self.r = r
def area(self) -> float: return 3.14159 * self.r ** 2
class Rectangle(Shape):
def __init__(self, w: float, h: float): self.w, self.h = w, h
def area(self) -> float: return self.w * self.h
# One line calls .area() on anything. Adding Triangle means adding a class — nothing here changes.
shapes: list[Shape] = [Circle(1), Rectangle(2, 3)]
total = sum(s.area() for s in shapes)
assert abs(total - 9.14159) < 1e-6
print(f"total area = {total:.5f}")Here's a quick test for whether polymorphism is doing real work: can you add a new type without touching any existing code? If adding Triangle means hunting down an if shape_type == ... chain somewhere and editing it, you've written types without actually using polymorphism.
Python doesn't even require a shared parent for this to work. This is called duck typing: "if it walks like a duck and quacks like a duck, treat it like a duck."
class Circle:
def __init__(self, r): self.r = r
def area(self): return 3.14159 * self.r ** 2
class Square: # note: does NOT inherit from anything
def __init__(self, side): self.side = side
def area(self): return self.side ** 2
for shape in [Circle(2), Square(3)]:
print(shape.area()) # works on both — Python never checks a shared ancestorWhen that loop runs, Python doesn't ask "is this a Shape?" anywhere. It just calls whatever .area() method the object happens to have. This is looser than Java, where the compiler checks a declared interface before your code even runs.
Operator overloading is polymorphism applied to symbols like + and ==. Python routes those symbols to methods with special names:
class Vector:
def __init__(self, x, y): self.x, self.y = x, y
def __add__(self, other): return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other): return self.x == other.x and self.y == other.y
v = Vector(1, 2) + Vector(3, 4) # this line is really Vector(1, 2).__add__(Vector(3, 4))Python has no special-case rule for + on a Vector. The + symbol is defined to call .__add__() on whatever sits on its left, and Vector happens to provide one. That call returns a brand new Vector(4, 6).
Remember: encapsulation hides data, abstraction hides how something works. A public getter-and-setter pair is not encapsulation — it's a field wearing a disguise.
Composition over inheritance
This is the single most useful habit in this whole topic, and it's the one interviewers lean on hardest.
Here's the problem with inheritance. It's the tightest coupling a language offers — a subclass depends on the parent's actual implementation, not just its promises. Change the parent, and every child might quietly break. This is called the "fragile base class" problem. Worse, inheritance can only express one dimension of change at a time. The moment two things vary independently, your class tree explodes.
Vehicle
├── ElectricCar
├── PetrolCar
├── ElectricTruck this explodes:
├── PetrolTruck (fuel type) × (body type) needs one class PER combination
├── ElectricMotorcycle
└── ...The fix: make the varying part something the object has, not something it is.
from abc import ABC, abstractmethod
class Engine(ABC):
@abstractmethod
def start(self) -> str: ...
class PetrolEngine(Engine):
def start(self) -> str: return "vroom"
class ElectricMotor(Engine):
def start(self) -> str: return "hum"
class Vehicle:
"""One class. The thing that varies is a field, not a branch of a class tree."""
def __init__(self, name: str, engine: Engine, wheels: int):
self.name, self.engine, self.wheels = name, engine, wheels
def start(self) -> str:
return f"{self.name} ({self.wheels} wheels): {self.engine.start()}"
car = Vehicle("car", PetrolEngine(), 4)
bike = Vehicle("bike", ElectricMotor(), 2)
assert car.start().endswith("vroom") and bike.start().endswith("hum")
# You can even swap the engine while the program is running — inheritance can't do this.
car.engine = ElectricMotor()
assert car.start().endswith("hum")
print(car.start(), "|", bike.start())| Inheritance | Composition | |
|---|---|---|
| Relationship | IS-A | HAS-A |
| Decided | When the code is written | While the program runs — swappable |
| Coupling | Tight — to the parent's actual code | Loose — only to an interface |
| Two things varying at once | Class count explodes | One field per varying thing |
| Testing | Must build the whole family tree | Just hand it a fake collaborator |
When inheritance is genuinely the right call: a real IS-A relationship where the child can stand in for the parent everywhere (a SavingsAccount really is an Account), a stable parent you control, and only one thing varying. A shallow tree — one or two levels — is usually fine and often the clearest option.
Here's a sentence worth having ready: "I'd use composition here, because fuel type and body type vary independently. Inheritance would need one class per combination."
Remember: the moment two things vary independently, stop reaching for subclasses and start reaching for a field.
Coupling and cohesion
These are the two words a reviewer actually means when they say a design is "clean."
Coupling — how much one piece of code has to know about another. Lower is better.
| Level | What it looks like | Verdict |
|---|---|---|
| Data coupling | Passing a plain number | Best |
| Stamp coupling | Passing a whole object when only one field gets used | Fine, a little wasteful |
| Control coupling | Passing a flag that changes what the method does (save(dryRun=True)) |
A smell — usually should be two methods |
| Common coupling | Shared global state anyone can change | Bad — dependencies become invisible |
| Content coupling | Reaching straight into another object's insides (order.items[0].price = 0) |
Worst |
Two quick tests to run on any design: if I change class A, how many other classes have to change too? And: how many objects do I need to build just to test A on its own? Both answers should be small.
Cohesion — how well the contents of one class actually belong together. Higher is better. A cohesive class has a name that tells you exactly what it does. A class with low cohesion tends to be named Utils, Manager, or Helper, and different methods inside it use completely different subsets of its fields.
There's a rule that makes low coupling concrete, called the Law of Demeter: only talk to your immediate friends.
# A long chain — this method now depends on the shape of Order, Customer, AND Address.
def bad(order):
return order.get_customer().get_address().get_city().upper()
# Ask the object you already have, and let IT answer the question.
def good(order):
return order.shipping_city().upper()Every extra . past the first one is one more thing that can break your code later.
A five-item checklist you can run against any design, with the standard fix for each:
| Smell | What it looks like | Fix |
|---|---|---|
| God class | OrderManager with 40 methods |
Split it up by why each part would change |
| Feature envy | A method that mostly reads some other class's fields | Move the method to the class that owns the data |
| Primitive obsession | A plain string standing in for money, an id, a phone number | Wrap it in a value object: Money, PhoneNumber, TicketId |
| Shotgun surgery | One small change means editing seven different files | The idea is scattered — pull it into one class |
| Long parameter list | create(a, b, c, d, e, f, g) |
Bundle the parameters into an object, or use a builder |
Remember: every extra dot past the first one in a chain is a dependency you just took on. Ask the thing you already hold, don't reach through it.
Interfaces, abstract classes, and dependency injection
Interface vs. abstract class shows up in almost every LLD interview, and the answer really comes down to one question: what are you sharing?
Abstract class means "IS-A." Use it when the things involved share both a type relationship and some actual code. A Dog is an Animal.
Interface means "CAN-DO." Use it when unrelated things need to promise the same capability, with no code in common. A Dog can Swim; so can a Boat, and a Boat is definitely not an Animal.
| Interface | Abstract class | |
|---|---|---|
| Shares | Just a promise — "here's what I can do" | A promise, plus some real code |
| Holds state | No (constants only) | Yes — fields, a constructor |
| How many | A class can implement many | One parent only, in most languages |
| Answers | "What can you do, no matter what you are?" | "What kind of thing are you, and what do you already do?" |
| Cost of a change | Adding a method breaks everything that implements it | Adding a concrete method breaks nobody |
In Python specifically, there's no separate abstract class keyword. abc.ABC plus @abstractmethod gives you the same guarantee:
from abc import ABC, abstractmethod
class Shape(ABC):
def __init__(self, color: str):
self.color = color # shared state — every Shape has a colour
def describe(self) -> str: # a real method, shared by every subclass
return f"A {self.color} shape with area {self.area():.2f}"
@abstractmethod
def area(self) -> float: ... # every subclass MUST provide this
class Circle(Shape):
def __init__(self, color: str, radius: float):
super().__init__(color)
self.radius = radius
def area(self) -> float:
return 3.14159 * self.radius ** 2
Shape("red") # TypeError: Can't instantiate abstract class Shape
Circle("red", 2).describe() # "A red shape with area 12.57"Trying to build a plain Shape raises a TypeError the instant you try. Python checks this while the program is running, not before, since there's no separate compile step. A subclass that forgets to write area() is also unbuildable, and the error only shows up the first time someone actually tries to construct it.
Python's real answer to "CAN-DO" is typing.Protocol, not a keyword. Java's implements requires you to declare the relationship up front — that's called nominal typing. Python's Protocol just checks the shape of an object — that's structural typing:
from typing import Protocol
class Swimmer(Protocol):
def swim(self) -> str: ...
class Dog:
def swim(self) -> str:
return "paddles"
class Boat:
def swim(self) -> str:
return "cuts through water"
def race(swimmer: Swimmer) -> str:
return swimmer.swim()
race(Dog()) # works — Dog never said "I implement Swimmer", it just has the method
race(Boat()) # also works — Boat has nothing to do with Dog at allNeither Dog nor Boat inherits from Swimmer. They satisfy it just by happening to have a matching method. When race(Dog()) runs, Python doesn't check any declared relationship at all — it just calls .swim() and trusts that whatever was handed to it has one. That's the concrete Python answer to "CAN-DO": no shared parent, no shared code, just a shared ability, checked either by a type checker or simply by trying it at runtime.
Abstract classes earn their keep through the Template Method pattern: the abstract class nails down the order of a series of steps, and each subclass only fills in the steps that actually change.
class DataPipeline(ABC):
def run(self) -> None: # the fixed order — never overridden
data = self.extract()
cleaned = self.transform(data)
self.load(cleaned)
@abstractmethod
def extract(self): ...
@abstractmethod
def transform(self, data): ...
@abstractmethod
def load(self, data): ...
class CsvPipeline(DataPipeline):
def extract(self): return open("in.csv").read()
def transform(self, data): return data.upper()
def load(self, data): open("out.csv", "w").write(data)run() never changes. The shape of the process is fixed in the parent class, and only the individual steps vary per subclass. Calling CsvPipeline().run() runs DataPipeline.run, which calls extract, transform, and load in that exact order — but each one resolves to CsvPipeline's own version. This is the answer to "why not just give me three methods and let me call them in the right order myself?" The abstract class owns and guarantees the order, so nobody can call the steps out of sequence or skip one by mistake.
Two interview questions worth having answers ready for:
"Can an abstract class have a constructor if you can't build one directly?" Yes — it runs whenever a subclass is built, through super().__init__(). You can't construct the parent alone, but its constructor is still part of building every child.
"Why not just use Strategy instead of Template Method?" Strategy swaps out a whole algorithm through composition — you hand in a different object. Template Method fixes the order of steps at write-time and only lets subclasses change individual steps. Pick Template Method when the sequence must never change. Pick Strategy when you need to swap the entire thing while the program runs.
Dependency injection is what turns "depend on an interface" from advice into working code: a class receives the things it needs from outside, instead of building them itself.
class SmtpMailer:
def send(self, to: str, body: str) -> None:
print(f"[smtp] to {to}: {body}")
class FakeMailer:
def __init__(self): self.sent: list[tuple[str, str]] = []
def send(self, to: str, body: str) -> None: self.sent.append((to, body))
class SignupService:
def __init__(self, mailer): # handed in — not `self.mailer = SmtpMailer()`
self.mailer = mailer
def register(self, email: str) -> None:
self.mailer.send(email, "Welcome!")
fake = FakeMailer()
SignupService(fake).register("a@mindforge.test")
assert fake.sent == [("a@mindforge.test", "Welcome!")] # tested with no real mail server
SignupService(SmtpMailer()).register("b@mindforge.test")
print("sent:", fake.sent)Building your own dependency inside the class (self.mailer = SmtpMailer()) locks the class to one implementation, makes it impossible to test without real infrastructure, and hides what the class actually needs from anyone reading its constructor. Injecting it instead puts every dependency right there in the signature — which is also why a constructor with eight parameters is a warning sign: that class is doing eight different jobs.
Remember: interface means "can do," abstract class means "is a." When in doubt, ask what's actually being shared — a promise, or a promise plus real code.
Quick recap
Encapsulation : hide state, expose actions that keep the object valid.
A public setter is a public field. Validate in the constructor.
Abstraction : hide HOW something works behind a simple interface
(encapsulation hides DATA, abstraction hides HOW)
Inheritance : share a contract, not just code. IS-A + substitutable + one axis + shallow.
Polymorphism : one call, many behaviours. Test: can I add a type with zero edits elsewhere?
Python also does this via duck typing — no shared parent required.
COMPOSITION OVER INHERITANCE
Inheritance = fixed at write-time, tight coupling, one axis → class count explodes
Composition = swappable while running, loose coupling, one field per axis, easy to fake
Coupling (low is good): data < stamp < control < common < content ← worst
Law of Demeter: every dot past the first is a new dependency. Ask, don't reach through.
Cohesion (high is good): a class whose name tells you exactly what it does.
Smells: god class · feature envy · primitive obsession · shotgun surgery · long param list
Interface = a promise only, many per class, unrelated types (CAN-DO)
Abstract class = a promise plus real code, one per class (IS-A)
Python: ABC + @abstractmethod for "IS-A"; Protocol (structural) for "CAN-DO"
Template Method: the abstract class fixes the ORDER, subclasses fill in the STEPS
Dependency injection: receive collaborators, never build them yourself.
A constructor with 8 parameters means that class is doing 8 jobs.- Encapsulation is about who's responsible for keeping the object valid — and the answer has to be the class itself, never its callers.
- Reach for composition first, every time, and be ready to say why: independent things that vary, swapping while the program runs, easier testing.
- Coupling and cohesion are the words reviewers actually use. "This
Managerclass has low cohesion — I'd split pricing away from saving to disk" is exactly what a real design conversation sounds like. - Every smell in that checklist has a standard fix, and knowing the pair — smell, then fix — is what lets you improve a design live, on the spot, when the interviewer pushes back.