
4 Pillars of OOP
Encapsulation, Inheritance, Polymorphism, Abstraction. Understanding via Lego Robots.

Encapsulation, Inheritance, Polymorphism, Abstraction. Understanding via Lego Robots.
Why does my server crash? OS's desperate struggle to manage limited memory. War against Fragmentation.

Two ways to escape a maze. Spread out wide (BFS) or dig deep (DFS)? Who finds the shortest path?

Fast by name. Partitioning around a Pivot. Why is it the standard library choice despite O(N²) worst case?

Establishing TCP connection is expensive. Reuse it for multiple requests.

I didn't truly understand OOP until I had to maintain spaghetti code for years. Sure, I could recite "What's OOP?" from textbooks—"Encapsulation, Inheritance, Polymorphism, Abstraction"—but I didn't really get why these principles mattered in real production code. They were just buzzwords I memorized.
Running a startup changed that. As my codebase grew and the complexity ballooned, the cracks started showing. Changing one thing would break something unexpected. Adding new features became scary. That's when it hit me: this mess was the result of ignoring OOP principles.
Here's what I eventually realized: OOP isn't academic theory from textbooks. It's a survival strategy for long-term code maintenance. These are my notes from that journey.
My payment system started simple. Just credit cards. Then customers asked: "Can we do bank transfers?" "What about PayPal?" "Add crypto later?"
The code looked like this:
function processPayment(method: string, amount: number) {
if (method === 'card') {
// 50 lines of card logic
} else if (method === 'bank') {
// 60 lines of bank transfer logic
} else if (method === 'paypal') {
// 70 lines of PayPal logic
}
// ... kept growing
}
This function hit 200+ lines. Every new payment method meant touching existing code. Bugs loved hiding here. My CTO mentor said: "This is classic OOP-less code. Apply the 4 pillars properly."
So I studied again. Not to memorize, but to fix my code.
At first I wondered: "Why bother with encapsulation?" Making variables public seemed easier and shorter. Why use private and write getters/setters?
My user data handling was broken. The User object's balance field was public, and code everywhere directly modified it:
user.balance = user.balance - 1000; // File A
user.balance -= 500; // File B
user.balance = user.balance - amount; // File C
One day we got a bug: negative balances. Someone had deducted without validation. The problem? Finding where was hell. We had to search through 100 files.
That's when encapsulation clicked. "Oh, I need to control balance modifications from one place."
class User {
private _balance: number; // Block external direct access
constructor(initialBalance: number) {
this._balance = initialBalance;
}
public getBalance(): number {
return this._balance;
}
public withdraw(amount: number): boolean {
if (amount <= 0) {
throw new Error('Withdrawal amount must be positive');
}
if (this._balance < amount) {
return false; // Insufficient funds
}
this._balance -= amount;
this.logTransaction('withdraw', amount); // Auto-log
return true;
}
public deposit(amount: number): void {
if (amount <= 0) {
throw new Error('Deposit amount must be positive');
}
this._balance += amount;
this.logTransaction('deposit', amount);
}
private logTransaction(type: string, amount: number): void {
console.log(`[${new Date().toISOString()}] ${type}: ${amount}`);
}
}
Now balance modifications only go through withdraw() and deposit(). Validation logic is centralized. When bugs happen, I know exactly where to look. That's the core of encapsulation: bundle data with the logic that operates on it, and expose only safe interfaces to the outside.
The medicine capsule metaphor works, but I prefer a bank vault. Money sits in the vault, but not everyone can just walk in and take it. You must go through a teller (public method). The teller checks ID, verifies balance, logs transactions. That's encapsulation.
As my codebase grew, encapsulation's value became obvious. I couldn't misuse the User class even if I tried. I didn't need to remember "don't directly modify this variable" every time I opened the file. The compiler enforces it.
Inheritance seemed intuitive: "Child classes inherit parent functionality." But in practice I wondered: "How deep should inheritance trees go?" "Is 5 levels okay?"
Building a notification system. Started with just email:
class EmailNotification {
send(message: string) {
console.log(`Sending email: ${message}`);
this.logToDatabase('email', message);
}
logToDatabase(type: string, message: string) {
// DB logging logic
}
}
Then SMS, push, and Slack notifications got added. I noticed a pattern: all notifications had "send message + log to database." I was copy-pasting this.
Inheritance was the answer:
class Notification {
protected logToDatabase(type: string, message: string): void {
console.log(`[DB] ${type}: ${message} saved at ${new Date()}`);
}
send(message: string): void {
// Subclasses must override
throw new Error('send() must be implemented by subclass');
}
}
class EmailNotification extends Notification {
send(message: string): void {
console.log(`📧 Email: ${message}`);
this.logToDatabase('email', message);
}
}
class SMSNotification extends Notification {
send(message: string): void {
console.log(`📱 SMS: ${message}`);
this.logToDatabase('sms', message);
}
}
class SlackNotification extends Notification {
send(message: string): void {
console.log(`💬 Slack: ${message}`);
this.logToDatabase('slack', message);
}
}
logToDatabase() implemented once, each child only implements its own send() logic. That's when inheritance shines: common functionality in parent, differences in children.
But as projects grew, I experienced inheritance hell:
Notification (grandparent)
└─ PushNotification (parent)
└─ AndroidPushNotification (child)
└─ SamsungPushNotification (grandchild)
4 levels deep meant modifying SamsungPushNotification required understanding all 3 parent classes. Maintenance nightmare.
My mentor said: "Favor composition over inheritance." Use inheritance for 2-3 levels max. Beyond that, consider composition. Later studying design patterns, I realized how crucial this advice was.
You inherit height and eye color from parents (inheritance), but personality comes from education and environment (composition). Code's the same. Inherit basic functionality, but compose complex behaviors from other objects.
Polymorphism was the hardest. "Same method name, different behavior" made sense, but I didn't see why it mattered. "Why not just use different method names?"
Refactoring the payment system revealed polymorphism's power. I transformed the earlier if-else hell:
// Define interface (abstraction)
interface PaymentMethod {
process(amount: number): boolean;
getTransactionFee(amount: number): number;
}
// Separate each payment method into classes
class CardPayment implements PaymentMethod {
process(amount: number): boolean {
console.log(`💳 Processing card payment: ${amount}`);
// Card payment logic
return true;
}
getTransactionFee(amount: number): number {
return amount * 0.03; // 3% fee
}
}
class BankTransfer implements PaymentMethod {
process(amount: number): boolean {
console.log(`🏦 Processing bank transfer: ${amount}`);
// Bank transfer logic
return true;
}
getTransactionFee(amount: number): number {
return 1.5; // Fixed fee
}
}
class CryptoPayment implements PaymentMethod {
process(amount: number): boolean {
console.log(`₿ Processing crypto payment: ${amount}`);
// Crypto payment logic
return true;
}
getTransactionFee(amount: number): number {
return amount * 0.01; // 1% fee
}
}
// Payment processing system
class PaymentProcessor {
executePayment(method: PaymentMethod, amount: number): void {
const fee = method.getTransactionFee(amount);
const total = amount + fee;
console.log(`Total amount with fee: ${total.toFixed(2)}`);
const success = method.process(total);
if (success) {
console.log('✅ Payment successful');
} else {
console.log('❌ Payment failed');
}
}
}
// Usage example
const processor = new PaymentProcessor();
processor.executePayment(new CardPayment(), 100);
processor.executePayment(new BankTransfer(), 100);
processor.executePayment(new CryptoPayment(), 100);
The key: PaymentProcessor doesn't need to know which payment method arrives. If it implements PaymentMethod interface, it can process anything. Adding new payment methods doesn't touch PaymentProcessor.
That's polymorphism's magic. "Same interface, different implementations." Code becomes open for extension, closed for modification. (SOLID's O - Open-Closed Principle)
Polymorphism comes in two flavors:
Overloading: Same name, different parameters
class Calculator {
add(a: number, b: number): number {
return a + b;
}
add(a: number, b: number, c: number): number {
return a + b + c;
}
}
Overriding: Child redefines parent method
class Animal {
makeSound(): void {
console.log('Some sound');
}
}
class Dog extends Animal {
makeSound(): void {
console.log('Woof!');
}
}
class Cat extends Animal {
makeSound(): void {
console.log('Meow!');
}
}
I use overriding more in production. Polymorphism's core is runtime flexibility: call the same method without knowing which object you're dealing with.
A Transformer has robot mode, car mode, plane mode. All receive the Transform() command, but each changes into different forms. Code's the same. Issue the same process() command, but card payment hits the card company API while crypto submits a blockchain transaction. One command, diverse actions.
Abstraction was conceptually hardest. "Keep what's important, hide the rest" sounded vague. What criteria determine what to keep vs hide?
Writing database connection code taught me abstraction. Our service used SQLite for development, PostgreSQL for production. Initially:
// Database-specific code invades business logic
function getUser(id: string) {
if (process.env.NODE_ENV === 'production') {
// PostgreSQL query
const result = pgClient.query('SELECT * FROM users WHERE id = $1', [id]);
return result.rows[0];
} else {
// SQLite query
const result = sqliteDb.prepare('SELECT * FROM users WHERE id = ?').get(id);
return result;
}
}
Business logic was coupled to database implementation. Changing DBs meant modifying all code.
Applied abstraction:
// Abstract interface
interface Database {
query(sql: string, params: any[]): Promise<any>;
close(): void;
}
// PostgreSQL implementation
class PostgresDatabase implements Database {
private client: any;
async query(sql: string, params: any[]): Promise<any> {
const result = await this.client.query(sql, params);
return result.rows;
}
close(): void {
this.client.end();
}
}
// SQLite implementation
class SQLiteDatabase implements Database {
private db: any;
async query(sql: string, params: any[]): Promise<any> {
return new Promise((resolve, reject) => {
this.db.all(sql, params, (err: any, rows: any) => {
if (err) reject(err);
else resolve(rows);
});
});
}
close(): void {
this.db.close();
}
}
// Business logic only uses abstracted interface
class UserService {
constructor(private db: Database) {}
async getUser(id: string): Promise<any> {
const users = await this.db.query(
'SELECT * FROM users WHERE id = ?',
[id]
);
return users[0];
}
}
// Swap implementation based on environment
const db = process.env.NODE_ENV === 'production'
? new PostgresDatabase()
: new SQLiteDatabase();
const userService = new UserService(db);
UserService only knows the Database interface, not whether it's Postgres or SQLite. That's abstraction. Hide complex DB connection logic, keep only the essence: "execute query."
TypeScript uses both for abstraction:
Interface: Pure contract. No implementation.
interface Logger {
log(message: string): void;
error(message: string): void;
}
Abstract Class: Can provide partial implementation.
abstract class BaseLogger {
abstract log(message: string): void; // Force implementation
protected formatMessage(message: string): string {
return `[${new Date().toISOString()}] ${message}`;
}
}
class ConsoleLogger extends BaseLogger {
log(message: string): void {
console.log(this.formatMessage(message));
}
}
I mainly use interfaces. TypeScript allows multiple interface implementation but not multiple inheritance.
Driving a car, you only know steering wheel, gas pedal, brake. You don't need to understand internal combustion, transmission gear shifts. Abstraction left only the essential "driving" interface. Same with code. Just know save() and load(), don't care if it's filesystem or S3.
Studying OOP's 4 pillars naturally led to SOLID:
Ultimately, OOP's 4 pillars are tools for realizing SOLID principles. Understanding this connection clarified "why code should be written this way."
Here's my order processing system refactored with OOP principles.
Before (procedural code):
def process_order(order_data):
# Validation
if not order_data.get('items'):
return {'error': 'No items'}
# Stock check
for item in order_data['items']:
stock = db.query('SELECT stock FROM products WHERE id = ?', [item['id']])
if stock < item['quantity']:
return {'error': 'Out of stock'}
# Price calculation
total = 0
for item in order_data['items']:
price = db.query('SELECT price FROM products WHERE id = ?', [item['id']])
total += price * item['quantity']
# Payment processing
if order_data['payment_method'] == 'card':
# Card payment logic
pass
elif order_data['payment_method'] == 'bank':
# Bank transfer logic
pass
# Shipping processing
# ...
return {'success': True}
After (OOP):
from abc import ABC, abstractmethod
from typing import List
# Abstraction
class PaymentMethod(ABC):
@abstractmethod
def process(self, amount: float) -> bool:
pass
# Polymorphism
class CardPayment(PaymentMethod):
def process(self, amount: float) -> bool:
print(f"Processing card payment: ${amount}")
return True
class BankTransfer(PaymentMethod):
def process(self, amount: float) -> bool:
print(f"Processing bank transfer: ${amount}")
return True
# Encapsulation
class Order:
def __init__(self, items: List[dict]):
self._items = items # private
self._total = 0.0
self._status = 'pending'
def validate(self) -> bool:
if not self._items:
raise ValueError('Order has no items')
return True
def calculate_total(self) -> float:
self._total = sum(item['price'] * item['quantity'] for item in self._items)
return self._total
def get_total(self) -> float:
return self._total
def complete(self):
self._status = 'completed'
# Inheritance
class ExpressOrder(Order):
def calculate_total(self) -> float:
base_total = super().calculate_total()
express_fee = 10.0
self._total = base_total + express_fee
return self._total
# Main logic
class OrderProcessor:
def __init__(self, payment_method: PaymentMethod):
self.payment = payment_method # Dependency injection
def process(self, order: Order) -> dict:
order.validate()
total = order.calculate_total()
if self.payment.process(total):
order.complete()
return {'success': True, 'total': total}
else:
return {'success': False, 'error': 'Payment failed'}
# Usage
order = Order([{'price': 100, 'quantity': 2}])
processor = OrderProcessor(CardPayment())
result = processor.process(order)
After refactoring:
OOP's 4 pillars aren't independent. Encapsulation protects data, inheritance reuses common functionality, polymorphism enables flexible extension, abstraction reduces complexity. When all four work together, you get maintainable, extensible, readable code.
As a non-CS major learning OOP, I complained: "Why make it so complicated?" But after the codebase grew, features piled up, and the service ran for over a year, I realized: OOP is an investment in future me.
Now I'm excited adding new features. Don't need to touch existing code. Bugs don't panic me. I know where to look. That's the confidence OOP gives.
When future me rereads this, I hope I nod thinking "yeah, that's how I understood it." And if someone else reads this, I hope they realize OOP isn't textbook trivia but survival skills for making code last.