Episode 9 — System Design / 9.1 — LLD Foundations
9.1.b — OOP Fundamentals
In one sentence: Object-Oriented Programming organizes code around objects (data + behavior bundled together), giving us encapsulation, inheritance, polymorphism, and abstraction — the four pillars that make large codebases manageable.
Table of Contents
- 1. What Is OOP?
- 2. Classes and Objects
- 3. The Four Pillars of OOP
- 4. OOP vs Procedural Programming
- 5. OOP Principles Applied to Real Systems
- 6. Key Takeaways
1. What Is OOP?
Object-Oriented Programming is a programming paradigm that models software as a collection of objects, each containing:
- State (data / properties / attributes)
- Behavior (methods / functions)
Instead of writing long procedural scripts where functions operate on loose data, OOP bundles related data and behavior together inside objects.
┌─────────────────────────────────────────────────────────┐
│ PROCEDURAL vs OOP │
│ │
│ Procedural: │
│ ───────────── │
│ Data and functions are separate. │
│ Functions operate on data passed to them. │
│ │
│ let name = "Alice"; │
│ let balance = 100; │
│ function deposit(amount) { balance += amount; } │
│ │
│ OOP: │
│ ──── │
│ Data and functions are bundled into objects. │
│ Objects manage their own state. │
│ │
│ class Account { │
│ name = "Alice"; │
│ balance = 100; │
│ deposit(amount) { this.balance += amount; } │
│ } │
└─────────────────────────────────────────────────────────┘
2. Classes and Objects
A class is a blueprint. An object is an instance of that blueprint.
Class = Blueprint Object = Instance
┌──────────────────┐ ┌──────────────────┐
│ Car │ │ myCar │
│ ────────────── │ new Car() │ ─────────────── │
│ brand: string │ ──────────────► │ brand: "Toyota" │
│ speed: number │ │ speed: 0 │
│ accelerate() │ │ accelerate() │
│ brake() │ │ brake() │
└──────────────────┘ └──────────────────┘
┌──────────────────┐
new Car() │ yourCar │
──────────────► │ ─────────────── │
│ brand: "Honda" │
│ speed: 0 │
│ accelerate() │
│ brake() │
└──────────────────┘
JavaScript Example
class Car {
constructor(brand, year) {
this.brand = brand;
this.year = year;
this.speed = 0;
}
accelerate(amount) {
this.speed += amount;
console.log(`${this.brand} is now going ${this.speed} km/h`);
}
brake(amount) {
this.speed = Math.max(0, this.speed - amount);
console.log(`${this.brand} slowed to ${this.speed} km/h`);
}
getInfo() {
return `${this.year} ${this.brand} — current speed: ${this.speed} km/h`;
}
}
// Creating objects (instances)
const myCar = new Car("Toyota", 2023);
const yourCar = new Car("Honda", 2022);
myCar.accelerate(60); // "Toyota is now going 60 km/h"
yourCar.accelerate(80); // "Honda is now going 80 km/h"
myCar.brake(20); // "Toyota slowed to 40 km/h"
TypeScript Example (with types)
class Car {
private brand: string;
private year: number;
private speed: number;
constructor(brand: string, year: number) {
this.brand = brand;
this.year = year;
this.speed = 0;
}
accelerate(amount: number): void {
this.speed += amount;
}
brake(amount: number): void {
this.speed = Math.max(0, this.speed - amount);
}
getSpeed(): number {
return this.speed;
}
getInfo(): string {
return `${this.year} ${this.brand} — current speed: ${this.speed} km/h`;
}
}
3. The Four Pillars of OOP
┌─────────────────────────────────────────────────────────┐
│ THE FOUR PILLARS OF OOP │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌────────────────┐ │
│ │ENCAPSULATION│ │ INHERITANCE │ │ POLYMORPHISM │ │
│ │ │ │ │ │ │ │
│ │ Hide data │ │ Reuse code │ │ Many forms │ │
│ │ behind │ │ from parent │ │ of the same │ │
│ │ methods │ │ classes │ │ interface │ │
│ └─────────────┘ └─────────────┘ └────────────────┘ │
│ │
│ ┌─────────────┐ │
│ │ ABSTRACTION │ │
│ │ │ │
│ │ Show only │ │
│ │ what matters│ │
│ │ hide the │ │
│ │ complexity │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────┘
3.1 Encapsulation
Encapsulation means bundling data and the methods that operate on that data into a single unit (class), and restricting direct access to the internal state.
Why? If any code anywhere can directly change an object's state, bugs become impossible to track. Encapsulation creates controlled access points.
Without Encapsulation: With Encapsulation:
┌──────────────┐ ┌──────────────┐
│ BankAccount │ │ BankAccount │
│ │ │ │
│ balance: 100 │ ← anyone can │ -balance: 100│ ← private
│ │ set this to │ │
│ │ -9999 │ +deposit() │ ← controlled
│ │ │ +withdraw() │ access
│ │ │ +getBalance()│
└──────────────┘ └──────────────┘
JavaScript Example
class BankAccount {
#balance; // Private field (ES2022 private fields with #)
constructor(ownerName, initialBalance) {
this.ownerName = ownerName;
this.#balance = initialBalance;
}
deposit(amount) {
if (amount <= 0) throw new Error("Deposit must be positive");
this.#balance += amount;
return this.#balance;
}
withdraw(amount) {
if (amount <= 0) throw new Error("Withdrawal must be positive");
if (amount > this.#balance) throw new Error("Insufficient funds");
this.#balance -= amount;
return this.#balance;
}
getBalance() {
return this.#balance;
}
}
const account = new BankAccount("Alice", 1000);
account.deposit(500); // OK: balance is now 1500
account.withdraw(200); // OK: balance is now 1300
console.log(account.getBalance()); // 1300
// account.#balance = 999999; // SyntaxError: Private field
// Encapsulation PREVENTS direct manipulation
TypeScript Example
class BankAccount {
private balance: number;
public readonly ownerName: string;
constructor(ownerName: string, initialBalance: number) {
this.ownerName = ownerName;
this.balance = initialBalance;
}
deposit(amount: number): number {
if (amount <= 0) throw new Error("Deposit must be positive");
this.balance += amount;
return this.balance;
}
withdraw(amount: number): number {
if (amount <= 0) throw new Error("Withdrawal must be positive");
if (amount > this.balance) throw new Error("Insufficient funds");
this.balance -= amount;
return this.balance;
}
getBalance(): number {
return this.balance;
}
}
Access Modifiers in TypeScript:
| Modifier | Who Can Access | Use When |
|---|---|---|
public | Anyone | The method is part of the class's public API |
private | Only the class itself | Internal state or helper methods |
protected | The class + its subclasses | State that subclasses need but outsiders should not touch |
readonly | Anyone (read), only constructor (write) | Values set once and never changed |
3.2 Inheritance
Inheritance lets a class (child/subclass) reuse the properties and methods of another class (parent/superclass). It models the "is-a" relationship.
┌──────────┐
│ Animal │ ← Parent / Superclass
│ ──────── │
│ name │
│ speak() │
└────┬─────┘
│ extends
┌────────┼────────┐
│ │ │
┌────▼───┐ ┌─▼──────┐ ┌▼───────┐
│ Dog │ │ Cat │ │ Bird │ ← Child / Subclass
│ bark() │ │ purr() │ │ fly() │
└────────┘ └────────┘ └────────┘
JavaScript Example
class Animal {
constructor(name, sound) {
this.name = name;
this.sound = sound;
}
speak() {
console.log(`${this.name} says ${this.sound}`);
}
eat(food) {
console.log(`${this.name} is eating ${food}`);
}
}
class Dog extends Animal {
constructor(name) {
super(name, "Woof!"); // Call parent constructor
}
fetch(item) {
console.log(`${this.name} fetches the ${item}`);
}
// Override parent method
speak() {
console.log(`${this.name} barks: ${this.sound}`);
}
}
class Cat extends Animal {
constructor(name) {
super(name, "Meow!");
}
purr() {
console.log(`${this.name} is purring...`);
}
}
const dog = new Dog("Rex");
dog.speak(); // "Rex barks: Woof!" — overridden method
dog.eat("bone"); // "Rex is eating bone" — inherited method
dog.fetch("ball"); // "Rex fetches the ball" — own method
const cat = new Cat("Whiskers");
cat.speak(); // "Whiskers says Meow!" — inherited method
cat.purr(); // "Whiskers is purring..." — own method
TypeScript Example
class Animal {
protected name: string;
protected sound: string;
constructor(name: string, sound: string) {
this.name = name;
this.sound = sound;
}
speak(): void {
console.log(`${this.name} says ${this.sound}`);
}
eat(food: string): void {
console.log(`${this.name} is eating ${food}`);
}
}
class Dog extends Animal {
constructor(name: string) {
super(name, "Woof!");
}
fetch(item: string): void {
console.log(`${this.name} fetches the ${item}`);
}
speak(): void {
console.log(`${this.name} barks: ${this.sound}`);
}
}
When to Use Inheritance — and When NOT To
| Use Inheritance When | Avoid Inheritance When |
|---|---|
| Clear "is-a" relationship (Dog IS an Animal) | Relationship is "has-a" (Car HAS an Engine) |
| Child shares most of the parent's behavior | Child only needs a small part of the parent |
| The hierarchy is shallow (2–3 levels max) | Deep hierarchies (5+ levels) — becomes brittle |
| Behavior truly specializes the parent | You just want code reuse (use composition instead) |
Rule of thumb: Favor composition over inheritance. If in doubt, compose; if the relationship is genuinely "is-a", inherit.
3.3 Polymorphism
Polymorphism means "many forms" — the same interface or method name behaves differently depending on the object that implements it. There are two types:
┌───────────────────────────────────────────────────────┐
│ POLYMORPHISM │
│ │
│ ┌─────────────────────┐ ┌───────────────────────┐ │
│ │ COMPILE-TIME │ │ RUNTIME │ │
│ │ (Static) │ │ (Dynamic) │ │
│ │ │ │ │ │
│ │ Method Overloading │ │ Method Overriding │ │
│ │ Same name, │ │ Same name & params, │ │
│ │ different params │ │ different class │ │
│ │ │ │ (via inheritance) │ │
│ │ Resolved at │ │ Resolved at │ │
│ │ compile time │ │ runtime │ │
│ └─────────────────────┘ └───────────────────────┘ │
└───────────────────────────────────────────────────────┘
Compile-Time Polymorphism (Method Overloading)
TypeScript supports overloading via overload signatures (JavaScript does not have native overloading):
class Calculator {
// Overload signatures
add(a: number, b: number): number;
add(a: string, b: string): string;
add(a: number, b: number, c: number): number;
// Implementation signature
add(a: number | string, b: number | string, c?: number): number | string {
if (typeof a === "string" && typeof b === "string") {
return a + b; // string concatenation
}
if (typeof a === "number" && typeof b === "number") {
return c ? a + b + c : a + b;
}
throw new Error("Invalid arguments");
}
}
const calc = new Calculator();
console.log(calc.add(1, 2)); // 3 (number + number)
console.log(calc.add("Hello ", "World")); // "Hello World" (string + string)
console.log(calc.add(1, 2, 3)); // 6 (three numbers)
Runtime Polymorphism (Method Overriding)
The same method name behaves differently depending on which object calls it. The decision is made at runtime.
class Shape {
area() {
throw new Error("area() must be implemented by subclass");
}
describe() {
console.log(`This shape has an area of ${this.area().toFixed(2)}`);
}
}
class Circle extends Shape {
constructor(radius) {
super();
this.radius = radius;
}
area() {
return Math.PI * this.radius * this.radius;
}
}
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
}
class Triangle extends Shape {
constructor(base, height) {
super();
this.base = base;
this.height = height;
}
area() {
return 0.5 * this.base * this.height;
}
}
// Polymorphism in action: same interface, different behavior
const shapes = [
new Circle(5),
new Rectangle(4, 6),
new Triangle(3, 8),
];
shapes.forEach(shape => shape.describe());
// "This shape has an area of 78.54"
// "This shape has an area of 24.00"
// "This shape has an area of 12.00"
The power: The forEach loop does not know or care which specific shape it is dealing with. It calls describe() and the correct area() runs automatically. This is runtime polymorphism.
TypeScript Interface Polymorphism
interface Notifier {
send(to: string, message: string): Promise<boolean>;
}
class EmailNotifier implements Notifier {
async send(to: string, message: string): Promise<boolean> {
console.log(`Sending email to ${to}: ${message}`);
return true;
}
}
class SMSNotifier implements Notifier {
async send(to: string, message: string): Promise<boolean> {
console.log(`Sending SMS to ${to}: ${message}`);
return true;
}
}
class PushNotifier implements Notifier {
async send(to: string, message: string): Promise<boolean> {
console.log(`Sending push notification to ${to}: ${message}`);
return true;
}
}
// The calling code does not know which notifier it uses
async function notifyUser(notifier: Notifier, user: string, msg: string) {
await notifier.send(user, msg);
}
// At runtime, any Notifier implementation works
await notifyUser(new EmailNotifier(), "alice@example.com", "Hello!");
await notifyUser(new SMSNotifier(), "+1234567890", "Hello!");
await notifyUser(new PushNotifier(), "device_token_abc", "Hello!");
3.4 Abstraction
Abstraction means exposing only the essential details and hiding the complexity. Users of a class should know what it does, not how it does it.
Real-world analogy: When you drive a car, you use the steering wheel, pedals, and gear shift. You do not need to understand how the internal combustion engine works. The car abstracts its internals behind a simple interface.
┌─────────────────────────────────────────────────────────┐
│ ABSTRACTION │
│ │
│ What the user sees: What is hidden: │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ database.save() │ │ Connection pool │ │
│ │ database.find() │ │ Query building │ │
│ │ database.delete() │ Transaction mgmt │ │
│ └─────────────────┘ │ Error retry │ │
│ │ Caching layer │ │
│ Simple interface │ Logging │ │
│ (abstraction layer) └─────────────────┘ │
│ Complex implementation │
└─────────────────────────────────────────────────────────┘
TypeScript Abstract Class Example
// Abstract class — cannot be instantiated directly
abstract class Database {
// Abstract methods — subclasses MUST implement these
abstract connect(): Promise<void>;
abstract disconnect(): Promise<void>;
abstract query(sql: string, params?: unknown[]): Promise<unknown[]>;
// Concrete method — shared behavior, available to all subclasses
async findById(table: string, id: string): Promise<unknown | null> {
const results = await this.query(
`SELECT * FROM ${table} WHERE id = ?`,
[id]
);
return results[0] ?? null;
}
async save(table: string, data: Record<string, unknown>): Promise<void> {
const keys = Object.keys(data).join(", ");
const placeholders = Object.keys(data).map(() => "?").join(", ");
await this.query(
`INSERT INTO ${table} (${keys}) VALUES (${placeholders})`,
Object.values(data)
);
}
}
// Concrete implementation for PostgreSQL
class PostgresDatabase extends Database {
private connectionString: string;
constructor(connectionString: string) {
super();
this.connectionString = connectionString;
}
async connect(): Promise<void> {
console.log(`Connecting to PostgreSQL at ${this.connectionString}`);
// ... actual pg connection logic
}
async disconnect(): Promise<void> {
console.log("Disconnecting from PostgreSQL");
// ... actual pg disconnect logic
}
async query(sql: string, params?: unknown[]): Promise<unknown[]> {
console.log(`[PG] Executing: ${sql}`);
// ... actual pg query logic
return [];
}
}
// Concrete implementation for MongoDB
class MongoDatabase extends Database {
private uri: string;
constructor(uri: string) {
super();
this.uri = uri;
}
async connect(): Promise<void> {
console.log(`Connecting to MongoDB at ${this.uri}`);
}
async disconnect(): Promise<void> {
console.log("Disconnecting from MongoDB");
}
async query(sql: string, params?: unknown[]): Promise<unknown[]> {
console.log(`[Mongo] Executing: ${sql}`);
return [];
}
}
// Application code uses the abstract type — does not care which DB
class UserService {
private db: Database;
constructor(db: Database) {
this.db = db;
}
async getUser(id: string) {
return this.db.findById("users", id);
}
}
// Swap databases without changing UserService
const userServicePG = new UserService(new PostgresDatabase("pg://localhost"));
const userServiceMongo = new UserService(new MongoDatabase("mongodb://localhost"));
Abstract Class vs Interface
| Feature | Abstract Class | Interface |
|---|---|---|
| Can have implementation | Yes — concrete methods with logic | No — only method signatures (in pure form) |
| Can have state | Yes — properties with values | No (in TypeScript interfaces, no runtime state) |
| Inheritance | Single parent class only | A class can implement multiple interfaces |
| Constructor | Yes | No |
| Use when | Shared behavior + forced contract | Pure contract, multiple implementations expected |
// Interface: pure contract
interface Serializable {
serialize(): string;
deserialize(data: string): void;
}
// Abstract class: shared behavior + contract
abstract class BaseEntity {
id: string;
createdAt: Date;
constructor() {
this.id = crypto.randomUUID();
this.createdAt = new Date();
}
abstract validate(): boolean; // subclasses must implement
getAge(): number { // shared implementation
return Date.now() - this.createdAt.getTime();
}
}
// A class can extend ONE abstract class and implement MANY interfaces
class User extends BaseEntity implements Serializable {
name: string;
email: string;
constructor(name: string, email: string) {
super();
this.name = name;
this.email = email;
}
validate(): boolean {
return this.name.length > 0 && this.email.includes("@");
}
serialize(): string {
return JSON.stringify({ id: this.id, name: this.name, email: this.email });
}
deserialize(data: string): void {
const parsed = JSON.parse(data);
this.name = parsed.name;
this.email = parsed.email;
}
}
4. OOP vs Procedural Programming
| Aspect | Procedural | Object-Oriented |
|---|---|---|
| Organization | Functions + global/local data | Classes + encapsulated data |
| Data exposure | Data is often globally accessible | Data is private, accessed via methods |
| Code reuse | Copy-paste or function libraries | Inheritance, composition, interfaces |
| Scalability | Hard to manage beyond ~1000 lines | Scales well to large codebases |
| Testing | Harder — functions depend on shared state | Easier — objects are self-contained units |
| Modeling | Think in terms of "steps" | Think in terms of "entities and interactions" |
| Examples | C, early PHP, Bash scripts | TypeScript, Java, C#, Python (OOP features) |
Side-by-Side Code Comparison
// ========== PROCEDURAL APPROACH ==========
let users = [];
function createUser(name, email) {
const user = { name, email, balance: 0 };
users.push(user);
return user;
}
function deposit(user, amount) {
if (amount <= 0) return;
user.balance += amount;
}
function withdraw(user, amount) {
if (amount > user.balance) return;
user.balance -= amount;
}
function getBalance(user) {
return user.balance;
}
// Problem: anyone can do `user.balance = 999999` — no protection
const alice = createUser("Alice", "alice@mail.com");
alice.balance = 999999; // oops — no encapsulation
// ========== OOP APPROACH ==========
class User {
private name: string;
private email: string;
private balance: number;
constructor(name: string, email: string) {
this.name = name;
this.email = email;
this.balance = 0;
}
deposit(amount: number): void {
if (amount <= 0) throw new Error("Invalid amount");
this.balance += amount;
}
withdraw(amount: number): void {
if (amount <= 0) throw new Error("Invalid amount");
if (amount > this.balance) throw new Error("Insufficient funds");
this.balance -= amount;
}
getBalance(): number {
return this.balance;
}
}
const alice = new User("Alice", "alice@mail.com");
// alice.balance = 999999; // TypeScript error: 'balance' is private
alice.deposit(500); // Controlled access
5. OOP Principles Applied to Real Systems
5.1 E-Commerce Order System
// Encapsulation — Order hides pricing logic
class Order {
private items: OrderItem[] = [];
private discount: number = 0;
addItem(item: OrderItem): void {
this.items.push(item);
}
applyDiscount(percentage: number): void {
if (percentage < 0 || percentage > 100) {
throw new Error("Invalid discount");
}
this.discount = percentage;
}
getTotal(): number {
const subtotal = this.items.reduce(
(sum, item) => sum + item.getPrice() * item.getQuantity(), 0
);
return subtotal * (1 - this.discount / 100);
}
}
// Inheritance — different order types
class SubscriptionOrder extends Order {
private renewalDate: Date;
constructor(renewalDate: Date) {
super();
this.renewalDate = renewalDate;
}
isRenewalDue(): boolean {
return new Date() >= this.renewalDate;
}
}
// Polymorphism — different payment methods, same interface
interface PaymentMethod {
charge(amount: number): Promise<PaymentReceipt>;
getName(): string;
}
class CreditCardPayment implements PaymentMethod {
async charge(amount: number): Promise<PaymentReceipt> {
// Credit card processing logic
return { transactionId: "cc_123", amount, method: this.getName() };
}
getName(): string { return "Credit Card"; }
}
class WalletPayment implements PaymentMethod {
async charge(amount: number): Promise<PaymentReceipt> {
// Digital wallet processing logic
return { transactionId: "w_456", amount, method: this.getName() };
}
getName(): string { return "Digital Wallet"; }
}
// Abstraction — OrderProcessor hides orchestration complexity
class OrderProcessor {
async processOrder(order: Order, payment: PaymentMethod): Promise<void> {
const total = order.getTotal();
const receipt = await payment.charge(total);
console.log(`Order processed: ${receipt.transactionId} via ${receipt.method}`);
// ... inventory update, email notification, analytics ...
}
}
5.2 Logger System (All Four Pillars)
// ABSTRACTION: abstract base class defines the contract
abstract class Logger {
protected level: LogLevel;
constructor(level: LogLevel) {
this.level = level;
}
// Template method — shared algorithm, specific steps overridden
log(level: LogLevel, message: string): void {
if (level >= this.level) {
const formatted = this.format(level, message);
this.write(formatted);
}
}
// Concrete shared method
protected format(level: LogLevel, message: string): string {
const timestamp = new Date().toISOString();
return `[${timestamp}] [${LogLevel[level]}] ${message}`;
}
// Abstract — subclasses decide WHERE to write
protected abstract write(message: string): void;
}
// INHERITANCE: specialized loggers
class ConsoleLogger extends Logger {
// ENCAPSULATION: write method is protected
protected write(message: string): void {
console.log(message);
}
}
class FileLogger extends Logger {
private filePath: string;
constructor(level: LogLevel, filePath: string) {
super(level);
this.filePath = filePath;
}
protected write(message: string): void {
// fs.appendFileSync(this.filePath, message + "\n");
console.log(`[FILE: ${this.filePath}] ${message}`);
}
}
class HttpLogger extends Logger {
private endpoint: string;
constructor(level: LogLevel, endpoint: string) {
super(level);
this.endpoint = endpoint;
}
protected write(message: string): void {
// fetch(this.endpoint, { method: 'POST', body: message });
console.log(`[HTTP: ${this.endpoint}] ${message}`);
}
}
// POLYMORPHISM: same interface, different destinations
enum LogLevel { DEBUG, INFO, WARN, ERROR }
const loggers: Logger[] = [
new ConsoleLogger(LogLevel.DEBUG),
new FileLogger(LogLevel.WARN, "/var/log/app.log"),
new HttpLogger(LogLevel.ERROR, "https://logging.example.com/ingest"),
];
// All loggers respond to the same method — polymorphism
loggers.forEach(logger => {
logger.log(LogLevel.ERROR, "Database connection failed");
});
6. Key Takeaways
- Classes are blueprints, objects are instances. A class defines structure;
newcreates a living object from that blueprint. - Encapsulation protects data by making it private and providing controlled access through methods. This prevents invalid states.
- Inheritance enables code reuse through "is-a" relationships but should be used sparingly — prefer composition when the relationship is "has-a".
- Polymorphism lets different objects respond to the same method call in their own way. Runtime polymorphism (overriding) is the most powerful form for LLD.
- Abstraction hides complexity behind simple interfaces. Users of a class should know what it does, not how it does it internally.
- OOP is not always better than procedural — use OOP when modeling systems with multiple interacting entities; use procedural for simple scripts and utilities.
- TypeScript's type system makes OOP more explicit with access modifiers (
private,protected,public), interfaces, and abstract classes.
Explain-It Challenge
Can you explain the difference between encapsulation and abstraction to a junior developer? Encapsulation is about protecting data (private fields + public methods). Abstraction is about hiding complexity (exposing only what users need). Try explaining with the ATM analogy: abstraction = you interact with the screen and buttons (simple interface), encapsulation = the cash vault inside is locked and only the machine's internal logic can access it.
Previous → 9.1.a — What Is LLD Next → 9.1.c — Class Relationships