My Learning Notes
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.
Why I Revisited OOP
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.
1. Encapsulation: Why Medicine Comes in Capsules
The Confusion
At first I wondered: "Why bother with encapsulation?" Making variables public seemed easier and shorter. Why use private and write getters/setters?
The Aha Moment
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."
Encapsulated Code
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.
Bank Vault Analogy
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.
Team Collaboration Benefits
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.
2. Inheritance: Passing Down Parent Code
The Confusion
Inheritance seemed intuitive: "Child classes inherit parent functionality." But in practice I wondered: "How deep should inheritance trees go?" "Is 5 levels okay?"
The Aha Moment
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.
Inheritance Trap: Deep Hierarchies Are Dangerous
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.
Extended Gene Analogy
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.
3. Polymorphism: Same Command, Different Results
The Confusion
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?"
The Aha Moment
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)
Method Overloading vs Overriding
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.
Extended Transformer Analogy
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.
4. Abstraction: The Art of Hiding Complexity
The Confusion
Abstraction was conceptually hardest. "Keep what's important, hide the rest" sounded vague. What criteria determine what to keep vs hide?
The Aha Moment
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."
Abstract Class vs Interface
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.
Extended Remote Control Analogy
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.
Connecting OOP to SOLID Principles
Studying OOP's 4 pillars naturally led to SOLID:
- S (Single Responsibility): Encapsulation makes each class have one responsibility.
- O (Open-Closed): Polymorphism enables extension without modification.
- L (Liskov Substitution): In inheritance, children must be substitutable for parents.
- I (Interface Segregation): Abstraction defines only needed methods in interfaces.
- D (Dependency Inversion): Depend on abstractions, not implementations.
Ultimately, OOP's 4 pillars are tools for realizing SOLID principles. Understanding this connection clarified "why code should be written this way."
Real-World Application: Before and After Refactoring
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:
- Adding new payment methods is easy (polymorphism)
- Order data can't be corrupted externally (encapsulation)
- Variations like express orders are simple (inheritance)
- Changing payment methods doesn't affect order logic (abstraction)
Wrapping Up
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.