"Good code is not written. It's designed."
OOP is how you design it.
What Even Is OOP?
Before OOP, code was a long scroll of functions calling other functions — data flying around freely, anyone could modify anything, and one bug could silently corrupt everything else. We called it spaghetti code for a reason.
Object-Oriented Programming (OOP) is a programming paradigm that organizes software around objects — units that bundle related data and behavior together — rather than around loose functions and logic.
Think of it this way:
Instead of asking "what should happen?", OOP asks "who is responsible for making it happen?"
Why Does It Matter?
| Problem (Without OOP) | Solution (With OOP) |
|---|---|
| One change breaks everything | Maintainability — isolated changes |
| Copy-pasting logic everywhere | Reusability — base class, used everywhere |
| Can't work in parallel | Modularity — independent, self-contained units |
| Hard to grow the codebase | Scalability — extend via inheritance |
The Four Pillars
OOP stands on four pillars. Each solves a specific design problem:
┌─────────────────┐ ┌─────────────────┐
│ Encapsulation │ │ Abstraction │
│ "hide the data"│ │ "hide the how" │
└─────────────────┘ └─────────────────┘
┌─────────────────┐ ┌─────────────────┐
│ Inheritance │ │ Polymorphism │
│ "reuse & extend"│ │ "one call, many │
│ │ │ behaviors" │
└─────────────────┘ └─────────────────┘
Let's break each one down — crisp, technical, and with real code.
Pillar 1 — Encapsulation
What
Wrapping data (attributes) and the methods that act on that data into a single class, and restricting direct external access to the internal state.
Why
- Prevents accidental or malicious modification of internal state
- Forces all mutations to go through validated, controlled methods
- Internal implementation can change without breaking external callers
How
Using access modifiers:
| Modifier | Python Syntax | Accessible From |
|---|---|---|
| Public | balance |
Anywhere |
| Protected | _balance |
Class + subclasses (convention) |
| Private | __balance |
Class only (name-mangled) |
Combined with getter and setter methods that validate before mutating.
Where
- Banking and fintech systems
- Any domain where data integrity must be enforced before an update
- User authentication — password should never be readable directly
Analogy
An ATM machine — you interact via buttons (deposit, withdraw, check balance). The internal cash logic, encryption, and vault mechanism? Completely hidden from you.
Example
class BankAccount:
def __init__(self, owner):
self.__balance = 0 # private — no direct access
self.owner = owner
def deposit(self, amount):
if amount > 0: # validation lives here, not outside
self.__balance += amount
def withdraw(self, amount):
if 0 < amount <= self.__balance:
self.__balance -= amount
def get_balance(self): # controlled read access
return self.__balance
acc = BankAccount("Jeremy")
acc.deposit(500)
print(acc.get_balance()) # 500
acc.__balance = 99999 # creates a NEW unrelated attribute
print(acc.get_balance()) # still 500 — original is safe
Key insight: Python's
__(double underscore) triggers name mangling —__balanceis stored internally as_BankAccount__balance. Any attempt to accessacc.__balancefrom outside touches a completely different (or non-existent) variable.
Pillar 2 — Abstraction
What
- Show only essential details, hide unnecessary implementation
- Define WHAT a class must do — not HOW it does it
- Enforced via abstract classes and interfaces
- An abstract class cannot be instantiated directly — it is a contract, not an object
Why
- Reduces cognitive complexity for the caller — they only need to know the interface
- Enables the Open/Closed Principle (OCP): open for extension, closed for modification
- New business logic (new payment provider, new notification channel) can be added without touching existing code
How
In Python, using the abc module:
from abc import ABC, abstractmethod
Abstract methods have a signature but no body — subclasses are forced to implement them.
Where
- Payment gateways (Stripe, Razorpay, GPay — all follow one contract)
- API design and service layers
- Database drivers (MySQL, PostgreSQL — same interface, different internals)
- Anywhere you want implementation independence
Analogy
A car's steering wheel — you turn it, the car turns. Whether it's hydraulic steering or electric — you don't know, and you don't need to. The interface is stable; the internals can vary.
Example
from abc import ABC, abstractmethod
class PaymentGateway(ABC): # abstract — defines the contract
@abstractmethod
def charge(self, amt): pass # signature only, no body
@abstractmethod
def refund(self, txn_id): pass
class GPay(PaymentGateway): # concrete implementation
def charge(self, amt):
print(f"GPay: charging ₹{amt}")
def refund(self, txn_id):
print(f"GPay: refunding {txn_id}")
class Stripe(PaymentGateway): # another implementation
def charge(self, amt):
print(f"Stripe: charging ₹{amt}")
def refund(self, txn_id):
print(f"Stripe: refunding {txn_id}")
# caller only knows PaymentGateway — not GPay or Stripe
def process_payment(gateway: PaymentGateway, amt):
gateway.charge(amt) # works for ANY gateway
process_payment(GPay(), 999) # GPay: charging ₹999
process_payment(Stripe(), 999) # Stripe: charging ₹999
# Tomorrow you add PhonePe — zero changes to process_payment()
Key insight:
PaymentGateway()alone throws aTypeError— you cannot instantiate an abstract class. It forces every subclass to honour the contract.
Pillar 3 — Inheritance
What
A child class (subclass) acquires the state and behaviour of a parent class (superclass) — and can extend or override it as needed.
- Child class inherits all attributes and methods of the parent
- Can add new fields/methods
- Can override existing methods with its own version
Why
- Eliminates code duplication — shared logic lives in one place
- Models real-world "is-a" relationships naturally (Manager is an Employee)
- A bug fixed in the parent is fixed everywhere
How
class Child(Parent): # Python
class Child extends Parent {} # Java / JavaScript
Child calls super() to invoke the parent's constructor, then adds its own initialization on top.
Where
- Entity hierarchies:
Employee → Manager → Director - UI component libraries:
BaseButton → PrimaryButton → DangerButton - Framework base classes: Django's
Model, React'sComponent
Analogy
A smartphone inherits from a basic phone — it can call and SMS out of the box. On top of that, it adds camera, apps, GPS. Base features reused; new ones extended.
Example
class Employee: # parent / base class
def __init__(self, name, salary):
self.name = name
self.salary = salary
def describe(self):
return f"{self.name} | Salary: ₹{self.salary}"
class Manager(Employee): # child class
def __init__(self, name, salary, team_size):
super().__init__(name, salary) # reuse parent __init__
self.team_size = team_size # extend with new attribute
def describe(self): # override parent method
base = super().describe()
return f"{base} | Team: {self.team_size} people"
class Director(Manager): # grandchild — another level
def __init__(self, name, salary, team_size, budget):
super().__init__(name, salary, team_size)
self.budget = budget
def describe(self):
base = super().describe()
return f"{base} | Budget: ₹{self.budget}"
d = Director("Priya", 500000, 40, 10000000)
print(d.describe())
# Priya | Salary: ₹500000 | Team: 40 people | Budget: ₹10000000
Key insight:
super()is the bridge to the parent. Each level in the chain adds its own context without rewriting what came before.
Pillar 4 — Polymorphism
What
"Many forms" — one interface, many implementations.
The same method call behaves differently depending on the actual object type at runtime.
Two forms:
- Runtime polymorphism (method overriding) — resolved at runtime via dynamic dispatch
- Compile-time polymorphism (method overloading) — resolved at compile time (Java/C++; Python achieves this via default args)
Why
- Write generic code that works on any subtype — no
if isinstance(...)chains - Add new types without modifying existing calling code (OCP again)
- The calling code becomes elegantly type-agnostic
How
Override a parent method in a subclass. The runtime uses the actual object type (dynamic dispatch) to pick the right method — not the declared type.
Where
- Rendering engines — each
Shapedraws itself:circle.draw(),square.draw() - Notification systems —
Email,SMS,Pushall implementsend() - Serializers, event handlers, plugin architectures
Analogy
The
+operator —2 + 3is arithmetic addition,"hello" + " world"is string concatenation. Same symbol, completely different behaviour based on the operand type.
Example
class Notification:
def send(self, msg): pass # base — meant to be overridden
class Email(Notification):
def send(self, msg):
print(f" Email → {msg}")
class SMS(Notification):
def send(self, msg):
print(f" SMS → {msg}")
class PushAlert(Notification):
def send(self, msg):
print(f" Push → {msg}")
# one loop, one call — works for any Notification subtype
channels = [Email(), SMS(), PushAlert()]
for channel in channels:
channel.send("Server is down!")
# Email → Server is down!
# SMS → Server is down!
# Push → Server is down!
Tomorrow you add SlackAlert(Notification) — the loop doesn't change. Not a single line of calling code is modified.
Key insight: This is the power of dynamic dispatch — Python doesn't look at the declared type. It looks at the actual object in memory and calls its version of
send().
How All Four Work Together
The four pillars aren't isolated ideas — they reinforce each other in every well-designed system:
Encapsulation → protects the data inside each object
Abstraction → defines the contract each object must follow
Inheritance → lets new objects reuse and extend existing ones
Polymorphism → lets one piece of code work with all of them
A real example — the notification system, fully composed:
from abc import ABC, abstractmethod
class Notification(ABC): # Abstraction — defines contract
def __init__(self, recipient):
self.__recipient = recipient # Encapsulation — private data
def get_recipient(self):
return self.__recipient
@abstractmethod
def send(self, msg): pass # enforced contract
class Email(Notification): # Inheritance — reuses __init__
def send(self, msg): # Polymorphism — own behaviour
print(f"Email to {self.get_recipient()}: {msg}")
class SMS(Notification):
def send(self, msg):
print(f"SMS to {self.get_recipient()}: {msg}")
def notify_all(channels, msg): # works for ANY Notification
for ch in channels:
ch.send(msg)
notify_all([Email("dev@co.com"), SMS("+91-9999999999")], "Deploy done!")
# Email to dev@co.com: Deploy done!
# SMS to +91-9999999999: Deploy done!
All four pillars, twelve lines of clean code.
Quick Reference Cheat Sheet
| Pillar | Core Idea | Python Mechanism | Real-World Anchor |
|---|---|---|---|
| Encapsulation | Hide data, expose behaviour |
__private, getters/setters |
ATM, Bank Account |
| Abstraction | Hide implementation, show interface |
ABC, @abstractmethod
|
Payment Gateway |
| Inheritance | Reuse and extend |
class Child(Parent), super()
|
Employee → Manager |
| Polymorphism | One call, many behaviours | Method overriding, dynamic dispatch | Notification channels |
TL;DR
- Encapsulation = your data has a bodyguard
- Abstraction = your caller doesn't need to know the internals
- Inheritance = don't repeat yourself across related classes
- Polymorphism = write once, works for every type
OOP isn't just a set of rules. It's a way of thinking — modeling the real world in code, with clear responsibilities, clean contracts, and zero unnecessary coupling.
Found this useful? Drop a ❤️ and follow for more backend and system design content.
Next up → SOLID Principles: the rules that make OOP actually work at scale.
Top comments (0)