Back to blog

The Four Pillars of OOP: Foundation of Object-Oriented Design

oopencapsulationinheritancepolymorphismabstractiontypescriptpythonjava
The Four Pillars of OOP: Foundation of Object-Oriented Design

Introduction

The four pillars of Object-Oriented Programming (OOP) are the foundational concepts that every developer must understand to write clean, maintainable, and scalable code. These principles—Encapsulation, Inheritance, Polymorphism, and Abstraction—work together to help you model real-world problems effectively and build robust software systems.

In this comprehensive guide, we'll explore each pillar in depth with practical examples in TypeScript, Python, and Java. By the end, you'll understand not just what these principles are, but why they matter and how to apply them in your daily coding.

What You'll Learn

✅ Understand each of the four OOP pillars
✅ See how the pillars work together
✅ Apply principles with real-world examples
✅ Recognize common violations and anti-patterns
✅ Write cleaner, more maintainable code

Prerequisites

  • Basic understanding of classes and objects
  • Familiarity with at least one programming language (TypeScript, Python, or Java)
  • Completed OOP & Design Patterns Roadmap

Overview: The Four Pillars

Before diving deep into each pillar, let's understand what they are and how they relate to each other:

PillarDescriptionKey Benefit
EncapsulationBundle data and methods together, hide internal stateMaintainability & Security
InheritanceCreate class hierarchies, reuse code through "is-a" relationshipsCode Reuse
PolymorphismOne interface, multiple implementationsFlexibility
AbstractionHide complexity, expose only essential featuresSimplicity

How They Work Together

┌─────────────────────────────────────────────────────────────┐
│                       ABSTRACTION                           │
│   (What the outside world sees - simplified interface)      │
├─────────────────────────────────────────────────────────────┤
│                      ENCAPSULATION                          │
│   (How we protect and organize internal state)              │
├─────────────────────────────────────────────────────────────┤
│  INHERITANCE          │           POLYMORPHISM              │
│  (Code structure      │           (Behavior flexibility     │
│   and reuse)          │            at runtime)              │
└─────────────────────────────────────────────────────────────┘
  • Abstraction defines what an object does (the interface)
  • Encapsulation protects how it does it (implementation details)
  • Inheritance enables code reuse through class hierarchies
  • Polymorphism allows different implementations of the same interface

Pillar 1: Encapsulation

Encapsulation is the practice of bundling data (attributes) and the methods that operate on that data within a single unit (class), while restricting direct access to some of the object's components.

The Core Idea

Think of encapsulation like a car's dashboard. You don't need to know how the engine works internally—you just use the steering wheel, pedals, and controls. The complexity is encapsulated behind a simple interface.

Key Concepts

  1. Data Hiding: Keep internal state private
  2. Controlled Access: Expose only what's necessary through methods
  3. Validation: Ensure data integrity through controlled mutations
  4. Flexibility: Change internal implementation without affecting external code

Example: Without Encapsulation (Bad)

TypeScript:

// ❌ BAD: No encapsulation - anyone can modify balance directly
class BankAccount {
  balance: number = 0;
  accountNumber: string;
 
  constructor(accountNumber: string) {
    this.accountNumber = accountNumber;
  }
}
 
const account = new BankAccount("123456");
account.balance = -1000; // Invalid state! No validation
account.balance = 999999999; // Fraud! No protection

Python:

# ❌ BAD: No encapsulation
class BankAccount:
    def __init__(self, account_number: str):
        self.balance = 0  # Public attribute
        self.account_number = account_number
 
account = BankAccount("123456")
account.balance = -1000  # Invalid state!

Java:

// ❌ BAD: No encapsulation
public class BankAccount {
    public double balance = 0; // Public field
    public String accountNumber;
 
    public BankAccount(String accountNumber) {
        this.accountNumber = accountNumber;
    }
}
 
BankAccount account = new BankAccount("123456");
account.balance = -1000; // Invalid state!

Example: With Encapsulation (Good)

TypeScript:

// ✅ GOOD: Proper encapsulation
class BankAccount {
  private balance: number = 0;
  private readonly accountNumber: string;
 
  constructor(accountNumber: string, initialBalance: number = 0) {
    this.accountNumber = accountNumber;
    if (initialBalance > 0) {
      this.balance = initialBalance;
    }
  }
 
  // Controlled access through methods
  public deposit(amount: number): void {
    if (amount <= 0) {
      throw new Error("Deposit amount must be positive");
    }
    this.balance += amount;
  }
 
  public withdraw(amount: number): void {
    if (amount <= 0) {
      throw new Error("Withdrawal amount must be positive");
    }
    if (amount > this.balance) {
      throw new Error("Insufficient funds");
    }
    this.balance -= amount;
  }
 
  // Read-only access to balance
  public getBalance(): number {
    return this.balance;
  }
 
  public getAccountNumber(): string {
    // Return masked account number for security
    return "****" + this.accountNumber.slice(-4);
  }
}
 
// Usage
const account = new BankAccount("123456789", 1000);
account.deposit(500);     // ✅ Controlled mutation
account.withdraw(200);    // ✅ With validation
console.log(account.getBalance());        // 1300
console.log(account.getAccountNumber());  // ****6789
// account.balance = -1000;  // ❌ Error: Property 'balance' is private

Python:

# ✅ GOOD: Proper encapsulation with property decorators
class BankAccount:
    def __init__(self, account_number: str, initial_balance: float = 0):
        self._balance: float = 0  # Protected by convention
        self._account_number: str = account_number
        if initial_balance > 0:
            self._balance = initial_balance
 
    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        self._balance += amount
 
    def withdraw(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive")
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount
 
    @property
    def balance(self) -> float:
        """Read-only access to balance"""
        return self._balance
 
    @property
    def account_number(self) -> str:
        """Return masked account number"""
        return "****" + self._account_number[-4:]
 
 
# Usage
account = BankAccount("123456789", 1000)
account.deposit(500)
account.withdraw(200)
print(account.balance)         # 1300
print(account.account_number)  # ****6789
# account.balance = -1000  # AttributeError: can't set attribute

Java:

// ✅ GOOD: Proper encapsulation
public class BankAccount {
    private double balance = 0;
    private final String accountNumber;
 
    public BankAccount(String accountNumber, double initialBalance) {
        this.accountNumber = accountNumber;
        if (initialBalance > 0) {
            this.balance = initialBalance;
        }
    }
 
    public void deposit(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Deposit amount must be positive");
        }
        this.balance += amount;
    }
 
    public void withdraw(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Withdrawal amount must be positive");
        }
        if (amount > this.balance) {
            throw new IllegalArgumentException("Insufficient funds");
        }
        this.balance -= amount;
    }
 
    public double getBalance() {
        return this.balance;
    }
 
    public String getAccountNumber() {
        // Return masked account number
        return "****" + this.accountNumber.substring(this.accountNumber.length() - 4);
    }
}
 
// Usage
BankAccount account = new BankAccount("123456789", 1000);
account.deposit(500);
account.withdraw(200);
System.out.println(account.getBalance());        // 1300
System.out.println(account.getAccountNumber());  // ****6789
// account.balance = -1000;  // Error: balance has private access

Benefits of Encapsulation

  1. Data Integrity: Validation prevents invalid states
  2. Maintainability: Change internal implementation without breaking external code
  3. Security: Hide sensitive data and operations
  4. Testability: Clear boundaries make testing easier
  5. Reduced Coupling: External code depends on interface, not implementation

Access Modifiers Comparison

ModifierTypeScriptPythonJava
Publicpublic (default)No prefix (default)public
Protectedprotected_prefix (convention)protected
Privateprivate__prefix (name mangling)private

Pillar 2: Inheritance

Inheritance is a mechanism that allows a class (child/subclass) to inherit properties and methods from another class (parent/superclass). It represents an "is-a" relationship.

The Core Idea

Inheritance models real-world hierarchies. A Dog is an Animal. A Manager is an Employee. A SportsCar is a Car. The child class inherits all characteristics of the parent and can add or modify behavior.

Key Concepts

  1. Parent/Superclass: The base class being inherited from
  2. Child/Subclass: The class that inherits
  3. Method Overriding: Child replaces parent's method implementation
  4. super Keyword: Access parent class methods and constructor
  5. Code Reuse: Share common functionality across related classes

Example: Basic Inheritance

TypeScript:

// Parent class
class Animal {
  protected name: string;
 
  constructor(name: string) {
    this.name = name;
  }
 
  speak(): void {
    console.log(`${this.name} makes a sound`);
  }
 
  move(): void {
    console.log(`${this.name} moves`);
  }
}
 
// Child class - inherits from Animal
class Dog extends Animal {
  private breed: string;
 
  constructor(name: string, breed: string) {
    super(name); // Call parent constructor
    this.breed = breed;
  }
 
  // Override parent method
  speak(): void {
    console.log(`${this.name} barks: Woof!`);
  }
 
  // New method specific to Dog
  fetch(): void {
    console.log(`${this.name} fetches the ball`);
  }
 
  getBreed(): string {
    return this.breed;
  }
}
 
class Cat extends Animal {
  constructor(name: string) {
    super(name);
  }
 
  // Override parent method
  speak(): void {
    console.log(`${this.name} meows: Meow!`);
  }
 
  // New method specific to Cat
  scratch(): void {
    console.log(`${this.name} scratches the furniture`);
  }
}
 
// Usage
const dog = new Dog("Buddy", "Golden Retriever");
dog.speak();   // Buddy barks: Woof!
dog.move();    // Buddy moves (inherited)
dog.fetch();   // Buddy fetches the ball
 
const cat = new Cat("Whiskers");
cat.speak();   // Whiskers meows: Meow!
cat.move();    // Whiskers moves (inherited)
cat.scratch(); // Whiskers scratches the furniture

Python:

# Parent class
class Animal:
    def __init__(self, name: str):
        self._name = name
 
    def speak(self) -> None:
        print(f"{self._name} makes a sound")
 
    def move(self) -> None:
        print(f"{self._name} moves")
 
 
# Child class - inherits from Animal
class Dog(Animal):
    def __init__(self, name: str, breed: str):
        super().__init__(name)  # Call parent constructor
        self._breed = breed
 
    # Override parent method
    def speak(self) -> None:
        print(f"{self._name} barks: Woof!")
 
    # New method specific to Dog
    def fetch(self) -> None:
        print(f"{self._name} fetches the ball")
 
    @property
    def breed(self) -> str:
        return self._breed
 
 
class Cat(Animal):
    def __init__(self, name: str):
        super().__init__(name)
 
    # Override parent method
    def speak(self) -> None:
        print(f"{self._name} meows: Meow!")
 
    # New method specific to Cat
    def scratch(self) -> None:
        print(f"{self._name} scratches the furniture")
 
 
# Usage
dog = Dog("Buddy", "Golden Retriever")
dog.speak()   # Buddy barks: Woof!
dog.move()    # Buddy moves (inherited)
dog.fetch()   # Buddy fetches the ball
 
cat = Cat("Whiskers")
cat.speak()   # Whiskers meows: Meow!
cat.move()    # Whiskers moves (inherited)
cat.scratch() # Whiskers scratches the furniture

Java:

// Parent class
public class Animal {
    protected String name;
 
    public Animal(String name) {
        this.name = name;
    }
 
    public void speak() {
        System.out.println(name + " makes a sound");
    }
 
    public void move() {
        System.out.println(name + " moves");
    }
}
 
// Child class - inherits from Animal
public class Dog extends Animal {
    private String breed;
 
    public Dog(String name, String breed) {
        super(name); // Call parent constructor
        this.breed = breed;
    }
 
    // Override parent method
    @Override
    public void speak() {
        System.out.println(name + " barks: Woof!");
    }
 
    // New method specific to Dog
    public void fetch() {
        System.out.println(name + " fetches the ball");
    }
 
    public String getBreed() {
        return breed;
    }
}
 
public class Cat extends Animal {
    public Cat(String name) {
        super(name);
    }
 
    // Override parent method
    @Override
    public void speak() {
        System.out.println(name + " meows: Meow!");
    }
 
    // New method specific to Cat
    public void scratch() {
        System.out.println(name + " scratches the furniture");
    }
}
 
// Usage
Dog dog = new Dog("Buddy", "Golden Retriever");
dog.speak();   // Buddy barks: Woof!
dog.move();    // Buddy moves (inherited)
dog.fetch();   // Buddy fetches the ball
 
Cat cat = new Cat("Whiskers");
cat.speak();   // Whiskers meows: Meow!
cat.move();    // Whiskers moves (inherited)
cat.scratch(); // Whiskers scratches the furniture

Real-World Example: Employee Hierarchy

TypeScript:

class Employee {
  constructor(
    protected name: string,
    protected email: string,
    protected baseSalary: number
  ) {}
 
  calculatePay(): number {
    return this.baseSalary;
  }
 
  getDetails(): string {
    return `${this.name} (${this.email})`;
  }
}
 
class Manager extends Employee {
  private teamSize: number;
  private bonus: number;
 
  constructor(
    name: string,
    email: string,
    baseSalary: number,
    teamSize: number,
    bonus: number
  ) {
    super(name, email, baseSalary);
    this.teamSize = teamSize;
    this.bonus = bonus;
  }
 
  // Override: Manager gets bonus
  calculatePay(): number {
    return this.baseSalary + this.bonus;
  }
 
  // New method
  getTeamSize(): number {
    return this.teamSize;
  }
}
 
class Developer extends Employee {
  private programmingLanguages: string[];
 
  constructor(
    name: string,
    email: string,
    baseSalary: number,
    languages: string[]
  ) {
    super(name, email, baseSalary);
    this.programmingLanguages = languages;
  }
 
  // New method
  getLanguages(): string[] {
    return [...this.programmingLanguages];
  }
}
 
class SeniorDeveloper extends Developer {
  private yearsOfExperience: number;
  private seniorBonus: number;
 
  constructor(
    name: string,
    email: string,
    baseSalary: number,
    languages: string[],
    yearsOfExperience: number,
    seniorBonus: number
  ) {
    super(name, email, baseSalary, languages);
    this.yearsOfExperience = yearsOfExperience;
    this.seniorBonus = seniorBonus;
  }
 
  // Override: Senior gets bonus
  calculatePay(): number {
    return this.baseSalary + this.seniorBonus;
  }
 
  mentorJuniors(): void {
    console.log(`${this.name} is mentoring junior developers`);
  }
}
 
// Usage
const manager = new Manager("Alice", "alice@company.com", 80000, 5, 15000);
console.log(manager.calculatePay()); // 95000
 
const dev = new Developer("Bob", "bob@company.com", 70000, ["TypeScript", "Python"]);
console.log(dev.calculatePay());     // 70000
console.log(dev.getLanguages());     // ["TypeScript", "Python"]
 
const senior = new SeniorDeveloper(
  "Charlie", "charlie@company.com", 90000,
  ["TypeScript", "Go", "Rust"], 8, 20000
);
console.log(senior.calculatePay()); // 110000
senior.mentorJuniors();             // Charlie is mentoring junior developers

Inheritance Pitfalls to Avoid

1. Deep Hierarchies:

// ❌ BAD: Too deep hierarchy
class LivingThing { }
class Animal extends LivingThing { }
class Mammal extends Animal { }
class Canine extends Mammal { }
class Dog extends Canine { }
class GoldenRetriever extends Dog { }
 
// ✅ BETTER: Flatter hierarchy + composition
class Dog {
  breed: Breed;
  characteristics: DogCharacteristics;
}

2. Inappropriate "is-a" Relationships:

// ❌ BAD: Square is not really a Rectangle (Liskov Substitution violation)
class Rectangle {
  constructor(public width: number, public height: number) {}
 
  setWidth(w: number): void { this.width = w; }
  setHeight(h: number): void { this.height = h; }
 
  getArea(): number { return this.width * this.height; }
}
 
class Square extends Rectangle {
  constructor(size: number) {
    super(size, size);
  }
 
  // Problem: This violates parent's contract
  setWidth(w: number): void {
    this.width = w;
    this.height = w; // Must keep square property
  }
}
 
// ✅ BETTER: Use composition or separate classes
interface Shape {
  getArea(): number;
}
 
class Rectangle implements Shape {
  constructor(public width: number, public height: number) {}
  getArea(): number { return this.width * this.height; }
}
 
class Square implements Shape {
  constructor(public size: number) {}
  getArea(): number { return this.size * this.size; }
}

When to Use Inheritance

Use inheritance when:

  • There's a clear "is-a" relationship
  • Subclasses genuinely share behavior and state
  • You want to leverage polymorphism
  • The Liskov Substitution Principle holds

Avoid inheritance when:

  • The relationship is really "has-a" (use composition)
  • You just want to reuse some code (consider composition or mixins)
  • The hierarchy becomes too deep (more than 2-3 levels)

Pillar 3: Polymorphism

Polymorphism (from Greek, meaning "many forms") allows objects of different classes to be treated as objects of a common base class or interface. The same method call can behave differently depending on the actual object type.

The Core Idea

Polymorphism allows you to write code that works with objects at an abstract level without knowing their concrete types. This makes code more flexible and extensible.

Types of Polymorphism

  1. Compile-time (Static) Polymorphism: Method overloading - same method name, different parameters
  2. Runtime (Dynamic) Polymorphism: Method overriding - same method signature, different implementation in subclass

Example: Runtime Polymorphism

TypeScript:

// Interface defines the contract
interface Shape {
  calculateArea(): number;
  draw(): void;
}
 
class Circle implements Shape {
  constructor(private radius: number) {}
 
  calculateArea(): number {
    return Math.PI * this.radius * this.radius;
  }
 
  draw(): void {
    console.log(`Drawing a circle with radius ${this.radius}`);
  }
}
 
class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}
 
  calculateArea(): number {
    return this.width * this.height;
  }
 
  draw(): void {
    console.log(`Drawing a rectangle ${this.width}x${this.height}`);
  }
}
 
class Triangle implements Shape {
  constructor(private base: number, private height: number) {}
 
  calculateArea(): number {
    return 0.5 * this.base * this.height;
  }
 
  draw(): void {
    console.log(`Drawing a triangle with base ${this.base} and height ${this.height}`);
  }
}
 
// Polymorphic function - works with ANY Shape
function printShapeInfo(shape: Shape): void {
  console.log(`Area: ${shape.calculateArea().toFixed(2)}`);
  shape.draw();
}
 
// Usage - different behaviors, same interface
const shapes: Shape[] = [
  new Circle(5),
  new Rectangle(4, 6),
  new Triangle(4, 3)
];
 
shapes.forEach(shape => {
  printShapeInfo(shape);
  console.log("---");
});
 
// Output:
// Area: 78.54
// Drawing a circle with radius 5
// ---
// Area: 24.00
// Drawing a rectangle 4x6
// ---
// Area: 6.00
// Drawing a triangle with base 4 and height 3
// ---

Python:

from abc import ABC, abstractmethod
from math import pi
 
 
# Abstract base class defines the contract
class Shape(ABC):
    @abstractmethod
    def calculate_area(self) -> float:
        pass
 
    @abstractmethod
    def draw(self) -> None:
        pass
 
 
class Circle(Shape):
    def __init__(self, radius: float):
        self._radius = radius
 
    def calculate_area(self) -> float:
        return pi * self._radius ** 2
 
    def draw(self) -> None:
        print(f"Drawing a circle with radius {self._radius}")
 
 
class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self._width = width
        self._height = height
 
    def calculate_area(self) -> float:
        return self._width * self._height
 
    def draw(self) -> None:
        print(f"Drawing a rectangle {self._width}x{self._height}")
 
 
class Triangle(Shape):
    def __init__(self, base: float, height: float):
        self._base = base
        self._height = height
 
    def calculate_area(self) -> float:
        return 0.5 * self._base * self._height
 
    def draw(self) -> None:
        print(f"Drawing a triangle with base {self._base} and height {self._height}")
 
 
# Polymorphic function - works with ANY Shape
def print_shape_info(shape: Shape) -> None:
    print(f"Area: {shape.calculate_area():.2f}")
    shape.draw()
 
 
# Usage - different behaviors, same interface
shapes: list[Shape] = [
    Circle(5),
    Rectangle(4, 6),
    Triangle(4, 3)
]
 
for shape in shapes:
    print_shape_info(shape)
    print("---")

Java:

// Interface defines the contract
public interface Shape {
    double calculateArea();
    void draw();
}
 
public class Circle implements Shape {
    private double radius;
 
    public Circle(double radius) {
        this.radius = radius;
    }
 
    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
 
    @Override
    public void draw() {
        System.out.println("Drawing a circle with radius " + radius);
    }
}
 
public class Rectangle implements Shape {
    private double width;
    private double height;
 
    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
 
    @Override
    public double calculateArea() {
        return width * height;
    }
 
    @Override
    public void draw() {
        System.out.println("Drawing a rectangle " + width + "x" + height);
    }
}
 
public class Triangle implements Shape {
    private double base;
    private double height;
 
    public Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }
 
    @Override
    public double calculateArea() {
        return 0.5 * base * height;
    }
 
    @Override
    public void draw() {
        System.out.println("Drawing a triangle with base " + base + " and height " + height);
    }
}
 
// Main class
public class Main {
    // Polymorphic method - works with ANY Shape
    public static void printShapeInfo(Shape shape) {
        System.out.printf("Area: %.2f%n", shape.calculateArea());
        shape.draw();
    }
 
    public static void main(String[] args) {
        Shape[] shapes = {
            new Circle(5),
            new Rectangle(4, 6),
            new Triangle(4, 3)
        };
 
        for (Shape shape : shapes) {
            printShapeInfo(shape);
            System.out.println("---");
        }
    }
}

Real-World Example: Payment Processing

TypeScript:

interface PaymentProcessor {
  processPayment(amount: number): boolean;
  refund(transactionId: string, amount: number): boolean;
  getTransactionHistory(): Transaction[];
}
 
interface Transaction {
  id: string;
  amount: number;
  timestamp: Date;
  status: "completed" | "refunded" | "failed";
}
 
class CreditCardProcessor implements PaymentProcessor {
  private transactions: Transaction[] = [];
 
  processPayment(amount: number): boolean {
    console.log(`Processing $${amount} via Credit Card...`);
    // Credit card specific logic: validate card, charge, etc.
    const transaction: Transaction = {
      id: `CC-${Date.now()}`,
      amount,
      timestamp: new Date(),
      status: "completed"
    };
    this.transactions.push(transaction);
    return true;
  }
 
  refund(transactionId: string, amount: number): boolean {
    console.log(`Refunding $${amount} to Credit Card...`);
    return true;
  }
 
  getTransactionHistory(): Transaction[] {
    return [...this.transactions];
  }
}
 
class PayPalProcessor implements PaymentProcessor {
  private transactions: Transaction[] = [];
 
  processPayment(amount: number): boolean {
    console.log(`Processing $${amount} via PayPal...`);
    // PayPal specific logic: redirect to PayPal, verify, etc.
    const transaction: Transaction = {
      id: `PP-${Date.now()}`,
      amount,
      timestamp: new Date(),
      status: "completed"
    };
    this.transactions.push(transaction);
    return true;
  }
 
  refund(transactionId: string, amount: number): boolean {
    console.log(`Refunding $${amount} to PayPal account...`);
    return true;
  }
 
  getTransactionHistory(): Transaction[] {
    return [...this.transactions];
  }
}
 
class CryptoProcessor implements PaymentProcessor {
  private transactions: Transaction[] = [];
  private walletAddress: string;
 
  constructor(walletAddress: string) {
    this.walletAddress = walletAddress;
  }
 
  processPayment(amount: number): boolean {
    console.log(`Processing $${amount} in crypto to ${this.walletAddress}...`);
    // Crypto specific logic: blockchain transaction, etc.
    const transaction: Transaction = {
      id: `CRYPTO-${Date.now()}`,
      amount,
      timestamp: new Date(),
      status: "completed"
    };
    this.transactions.push(transaction);
    return true;
  }
 
  refund(transactionId: string, amount: number): boolean {
    console.log(`Refunding $${amount} in crypto...`);
    return true;
  }
 
  getTransactionHistory(): Transaction[] {
    return [...this.transactions];
  }
}
 
// Checkout service - works with ANY PaymentProcessor
class CheckoutService {
  private paymentProcessor: PaymentProcessor;
 
  constructor(processor: PaymentProcessor) {
    this.paymentProcessor = processor;
  }
 
  // Can swap processors without changing this code!
  setPaymentProcessor(processor: PaymentProcessor): void {
    this.paymentProcessor = processor;
  }
 
  checkout(amount: number): boolean {
    console.log(`Starting checkout for $${amount}`);
    const success = this.paymentProcessor.processPayment(amount);
    if (success) {
      console.log("Checkout completed successfully!");
    }
    return success;
  }
}
 
// Usage - runtime flexibility
const checkout = new CheckoutService(new CreditCardProcessor());
checkout.checkout(100);
// Output:
// Starting checkout for $100
// Processing $100 via Credit Card...
// Checkout completed successfully!
 
// Switch to PayPal at runtime
checkout.setPaymentProcessor(new PayPalProcessor());
checkout.checkout(50);
// Output:
// Starting checkout for $50
// Processing $50 via PayPal...
// Checkout completed successfully!
 
// Switch to Crypto
checkout.setPaymentProcessor(new CryptoProcessor("0x1234..."));
checkout.checkout(200);
// Output:
// Starting checkout for $200
// Processing $200 in crypto to 0x1234......
// Checkout completed successfully!

Benefits of Polymorphism

  1. Flexibility: Add new implementations without changing existing code
  2. Extensibility: Easy to extend the system (Open/Closed Principle)
  3. Loose Coupling: Client code depends on abstractions, not concretions
  4. Testability: Easy to mock implementations for testing
  5. Clean Code: Eliminates complex conditional logic

Polymorphism vs Conditional Logic

// ❌ BAD: Without polymorphism - messy conditionals
function processPayment(type: string, amount: number): void {
  if (type === "credit_card") {
    console.log(`Processing $${amount} via Credit Card`);
  } else if (type === "paypal") {
    console.log(`Processing $${amount} via PayPal`);
  } else if (type === "crypto") {
    console.log(`Processing $${amount} via Crypto`);
  } else if (type === "bank_transfer") {
    console.log(`Processing $${amount} via Bank Transfer`);
  }
  // Adding new payment type = modifying this function
}
 
// ✅ GOOD: With polymorphism - clean and extensible
function processPaymentPolymorphic(processor: PaymentProcessor, amount: number): void {
  processor.processPayment(amount);
  // Adding new payment type = just implement interface, no changes here
}

Pillar 4: Abstraction

Abstraction is the process of hiding complex implementation details and exposing only the essential features of an object. It focuses on what an object does rather than how it does it.

The Core Idea

Think of a TV remote. You press "Volume Up" without knowing how the infrared signal works, how the TV receives it, or how the speaker amplifies sound. The complex details are abstracted away behind a simple interface.

Key Concepts

  1. Hide Complexity: Internal workings are hidden
  2. Show Essential Features: Only expose what users need
  3. Abstract Classes: Partial implementation (some methods implemented)
  4. Interfaces: Pure contracts (no implementation)
  5. Separation of Concerns: What vs How

Abstract Classes vs Interfaces

AspectAbstract ClassInterface
ImplementationCan have some method implementationsNo implementation (traditionally)
StateCan have instance variablesNo state (constants only)
InheritanceSingle inheritanceMultiple implementation
ConstructorCan have constructorNo constructor
Use WhenSharing code among related classesDefining a contract for unrelated classes

Example: Abstract Classes

TypeScript:

// Abstract class - partial implementation
abstract class DatabaseConnection {
  protected host: string;
  protected port: number;
  protected isConnected: boolean = false;
 
  constructor(host: string, port: number) {
    this.host = host;
    this.port = port;
  }
 
  // Concrete method - shared implementation
  protected log(message: string): void {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }
 
  // Abstract methods - must be implemented by subclasses
  abstract connect(): Promise<void>;
  abstract disconnect(): Promise<void>;
  abstract executeQuery(query: string): Promise<any>;
 
  // Template method pattern - defines algorithm structure
  async runQuery(query: string): Promise<any> {
    if (!this.isConnected) {
      await this.connect();
    }
    this.log(`Executing: ${query}`);
    const result = await this.executeQuery(query);
    this.log(`Query completed`);
    return result;
  }
}
 
// Concrete implementation for PostgreSQL
class PostgreSQLConnection extends DatabaseConnection {
  private database: string;
 
  constructor(host: string, port: number, database: string) {
    super(host, port);
    this.database = database;
  }
 
  async connect(): Promise<void> {
    this.log(`Connecting to PostgreSQL at ${this.host}:${this.port}/${this.database}`);
    // PostgreSQL-specific connection logic
    this.isConnected = true;
    this.log("Connected to PostgreSQL");
  }
 
  async disconnect(): Promise<void> {
    this.log("Disconnecting from PostgreSQL");
    // PostgreSQL-specific disconnect logic
    this.isConnected = false;
  }
 
  async executeQuery(query: string): Promise<any> {
    // PostgreSQL-specific query execution
    return { rows: [], rowCount: 0 };
  }
}
 
// Concrete implementation for MongoDB
class MongoDBConnection extends DatabaseConnection {
  private database: string;
 
  constructor(host: string, port: number, database: string) {
    super(host, port);
    this.database = database;
  }
 
  async connect(): Promise<void> {
    this.log(`Connecting to MongoDB at ${this.host}:${this.port}/${this.database}`);
    // MongoDB-specific connection logic
    this.isConnected = true;
    this.log("Connected to MongoDB");
  }
 
  async disconnect(): Promise<void> {
    this.log("Disconnecting from MongoDB");
    // MongoDB-specific disconnect logic
    this.isConnected = false;
  }
 
  async executeQuery(query: string): Promise<any> {
    // MongoDB-specific query execution (actually uses different syntax)
    return { documents: [] };
  }
}
 
// Usage - client code doesn't know implementation details
async function fetchData(db: DatabaseConnection): Promise<void> {
  const result = await db.runQuery("SELECT * FROM users");
  console.log("Result:", result);
  await db.disconnect();
}
 
// Works with any database connection
const postgres = new PostgreSQLConnection("localhost", 5432, "myapp");
const mongo = new MongoDBConnection("localhost", 27017, "myapp");
 
fetchData(postgres);
fetchData(mongo);

Python:

from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any
 
 
# Abstract class - partial implementation
class DatabaseConnection(ABC):
    def __init__(self, host: str, port: int):
        self._host = host
        self._port = port
        self._is_connected = False
 
    # Concrete method - shared implementation
    def _log(self, message: str) -> None:
        print(f"[{datetime.now().isoformat()}] {message}")
 
    # Abstract methods - must be implemented by subclasses
    @abstractmethod
    async def connect(self) -> None:
        pass
 
    @abstractmethod
    async def disconnect(self) -> None:
        pass
 
    @abstractmethod
    async def execute_query(self, query: str) -> Any:
        pass
 
    # Template method pattern
    async def run_query(self, query: str) -> Any:
        if not self._is_connected:
            await self.connect()
        self._log(f"Executing: {query}")
        result = await self.execute_query(query)
        self._log("Query completed")
        return result
 
 
# Concrete implementation for PostgreSQL
class PostgreSQLConnection(DatabaseConnection):
    def __init__(self, host: str, port: int, database: str):
        super().__init__(host, port)
        self._database = database
 
    async def connect(self) -> None:
        self._log(f"Connecting to PostgreSQL at {self._host}:{self._port}/{self._database}")
        # PostgreSQL-specific connection logic
        self._is_connected = True
        self._log("Connected to PostgreSQL")
 
    async def disconnect(self) -> None:
        self._log("Disconnecting from PostgreSQL")
        self._is_connected = False
 
    async def execute_query(self, query: str) -> dict:
        # PostgreSQL-specific query execution
        return {"rows": [], "row_count": 0}
 
 
# Concrete implementation for MongoDB
class MongoDBConnection(DatabaseConnection):
    def __init__(self, host: str, port: int, database: str):
        super().__init__(host, port)
        self._database = database
 
    async def connect(self) -> None:
        self._log(f"Connecting to MongoDB at {self._host}:{self._port}/{self._database}")
        self._is_connected = True
        self._log("Connected to MongoDB")
 
    async def disconnect(self) -> None:
        self._log("Disconnecting from MongoDB")
        self._is_connected = False
 
    async def execute_query(self, query: str) -> dict:
        return {"documents": []}
 
 
# Usage
async def fetch_data(db: DatabaseConnection) -> None:
    result = await db.run_query("SELECT * FROM users")
    print("Result:", result)
    await db.disconnect()

Java:

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
 
// Abstract class - partial implementation
public abstract class DatabaseConnection {
    protected String host;
    protected int port;
    protected boolean isConnected = false;
 
    public DatabaseConnection(String host, int port) {
        this.host = host;
        this.port = port;
    }
 
    // Concrete method - shared implementation
    protected void log(String message) {
        String timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
        System.out.println("[" + timestamp + "] " + message);
    }
 
    // Abstract methods - must be implemented by subclasses
    public abstract void connect() throws Exception;
    public abstract void disconnect() throws Exception;
    public abstract Object executeQuery(String query) throws Exception;
 
    // Template method pattern
    public Object runQuery(String query) throws Exception {
        if (!isConnected) {
            connect();
        }
        log("Executing: " + query);
        Object result = executeQuery(query);
        log("Query completed");
        return result;
    }
}
 
// Concrete implementation
public class PostgreSQLConnection extends DatabaseConnection {
    private String database;
 
    public PostgreSQLConnection(String host, int port, String database) {
        super(host, port);
        this.database = database;
    }
 
    @Override
    public void connect() throws Exception {
        log("Connecting to PostgreSQL at " + host + ":" + port + "/" + database);
        isConnected = true;
        log("Connected to PostgreSQL");
    }
 
    @Override
    public void disconnect() throws Exception {
        log("Disconnecting from PostgreSQL");
        isConnected = false;
    }
 
    @Override
    public Object executeQuery(String query) throws Exception {
        // PostgreSQL-specific query execution
        return new Object(); // Placeholder
    }
}

Example: Interfaces for Abstraction

TypeScript:

// Interface defines what a logger does, not how
interface ILogger {
  debug(message: string): void;
  info(message: string): void;
  warn(message: string): void;
  error(message: string, error?: Error): void;
}
 
// Console implementation
class ConsoleLogger implements ILogger {
  debug(message: string): void {
    console.debug(`[DEBUG] ${message}`);
  }
 
  info(message: string): void {
    console.info(`[INFO] ${message}`);
  }
 
  warn(message: string): void {
    console.warn(`[WARN] ${message}`);
  }
 
  error(message: string, error?: Error): void {
    console.error(`[ERROR] ${message}`, error);
  }
}
 
// File implementation
class FileLogger implements ILogger {
  private filePath: string;
 
  constructor(filePath: string) {
    this.filePath = filePath;
  }
 
  private writeToFile(level: string, message: string): void {
    const logEntry = `${new Date().toISOString()} [${level}] ${message}\n`;
    // In real implementation: write to file
    console.log(`Writing to ${this.filePath}: ${logEntry}`);
  }
 
  debug(message: string): void {
    this.writeToFile("DEBUG", message);
  }
 
  info(message: string): void {
    this.writeToFile("INFO", message);
  }
 
  warn(message: string): void {
    this.writeToFile("WARN", message);
  }
 
  error(message: string, error?: Error): void {
    const errorMessage = error ? `${message}: ${error.message}` : message;
    this.writeToFile("ERROR", errorMessage);
  }
}
 
// Remote logging implementation
class RemoteLogger implements ILogger {
  private endpoint: string;
 
  constructor(endpoint: string) {
    this.endpoint = endpoint;
  }
 
  private async sendToRemote(level: string, message: string): Promise<void> {
    // In real implementation: HTTP request to logging service
    console.log(`Sending to ${this.endpoint}: [${level}] ${message}`);
  }
 
  debug(message: string): void {
    this.sendToRemote("DEBUG", message);
  }
 
  info(message: string): void {
    this.sendToRemote("INFO", message);
  }
 
  warn(message: string): void {
    this.sendToRemote("WARN", message);
  }
 
  error(message: string, error?: Error): void {
    const errorMessage = error ? `${message}: ${error.message}` : message;
    this.sendToRemote("ERROR", errorMessage);
  }
}
 
// Application uses the abstraction, not concrete implementations
class UserService {
  private logger: ILogger;
 
  constructor(logger: ILogger) {
    this.logger = logger;
  }
 
  createUser(name: string, email: string): void {
    this.logger.info(`Creating user: ${name}`);
    try {
      // User creation logic
      this.logger.info(`User created successfully: ${email}`);
    } catch (error) {
      this.logger.error("Failed to create user", error as Error);
      throw error;
    }
  }
}
 
// Usage - easily swap implementations
const consoleLogger = new ConsoleLogger();
const fileLogger = new FileLogger("/var/log/app.log");
const remoteLogger = new RemoteLogger("https://logs.example.com/api");
 
// Development: use console
const devService = new UserService(consoleLogger);
devService.createUser("John", "john@example.com");
 
// Production: use remote logging
const prodService = new UserService(remoteLogger);
prodService.createUser("Jane", "jane@example.com");

Benefits of Abstraction

  1. Simplicity: Hide complex details from users
  2. Flexibility: Change implementation without affecting clients
  3. Maintainability: Isolated changes don't cascade
  4. Testability: Easy to mock abstract interfaces
  5. Security: Internal details are hidden

Putting It All Together: Real-World Example

Let's combine all four pillars in a comprehensive example: an e-commerce notification system.

TypeScript:

// ============================================
// ABSTRACTION: Define what notifications do
// ============================================
interface INotification {
  send(recipient: string, subject: string, body: string): Promise<boolean>;
  getDeliveryStatus(notificationId: string): Promise<string>;
}
 
// ============================================
// ENCAPSULATION: Protect internal state
// ============================================
abstract class BaseNotification implements INotification {
  private readonly id: string;
  private status: "pending" | "sent" | "failed" = "pending";
  private sentAt: Date | null = null;
  protected retryCount: number = 0;
  protected readonly maxRetries: number = 3;
 
  constructor() {
    this.id = this.generateId();
  }
 
  private generateId(): string {
    return `NOTIF-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }
 
  // Controlled access to status
  protected setStatus(status: "pending" | "sent" | "failed"): void {
    this.status = status;
    if (status === "sent") {
      this.sentAt = new Date();
    }
  }
 
  getId(): string {
    return this.id;
  }
 
  async getDeliveryStatus(notificationId: string): Promise<string> {
    return this.status;
  }
 
  // Template method using abstraction
  async send(recipient: string, subject: string, body: string): Promise<boolean> {
    try {
      console.log(`[${this.id}] Preparing to send...`);
      const success = await this.doSend(recipient, subject, body);
 
      if (success) {
        this.setStatus("sent");
        console.log(`[${this.id}] Sent successfully`);
      } else {
        this.setStatus("failed");
      }
 
      return success;
    } catch (error) {
      console.error(`[${this.id}] Send failed:`, error);
      this.setStatus("failed");
 
      // Retry logic
      if (this.retryCount < this.maxRetries) {
        this.retryCount++;
        console.log(`[${this.id}] Retrying (${this.retryCount}/${this.maxRetries})...`);
        return this.send(recipient, subject, body);
      }
 
      return false;
    }
  }
 
  // Abstract method - subclasses implement the actual sending
  protected abstract doSend(recipient: string, subject: string, body: string): Promise<boolean>;
}
 
// ============================================
// INHERITANCE: Reuse common notification logic
// ============================================
class EmailNotification extends BaseNotification {
  private smtpServer: string;
  private fromAddress: string;
 
  constructor(smtpServer: string, fromAddress: string) {
    super();
    this.smtpServer = smtpServer;
    this.fromAddress = fromAddress;
  }
 
  protected async doSend(recipient: string, subject: string, body: string): Promise<boolean> {
    console.log(`Sending email via ${this.smtpServer}`);
    console.log(`From: ${this.fromAddress}`);
    console.log(`To: ${recipient}`);
    console.log(`Subject: ${subject}`);
    console.log(`Body: ${body}`);
    // Actual SMTP logic would go here
    return true;
  }
}
 
class SMSNotification extends BaseNotification {
  private provider: string;
  private fromNumber: string;
 
  constructor(provider: string, fromNumber: string) {
    super();
    this.provider = provider;
    this.fromNumber = fromNumber;
  }
 
  protected async doSend(recipient: string, subject: string, body: string): Promise<boolean> {
    console.log(`Sending SMS via ${this.provider}`);
    console.log(`From: ${this.fromNumber}`);
    console.log(`To: ${recipient}`);
    // SMS doesn't use subject, just body
    console.log(`Message: ${body}`);
    // Actual SMS API logic would go here
    return true;
  }
}
 
class PushNotification extends BaseNotification {
  private appId: string;
 
  constructor(appId: string) {
    super();
    this.appId = appId;
  }
 
  protected async doSend(recipient: string, subject: string, body: string): Promise<boolean> {
    console.log(`Sending push notification via app ${this.appId}`);
    console.log(`Device Token: ${recipient}`);
    console.log(`Title: ${subject}`);
    console.log(`Body: ${body}`);
    // Actual push notification logic would go here
    return true;
  }
}
 
// ============================================
// POLYMORPHISM: Work with any notification type
// ============================================
class NotificationService {
  private notifications: INotification[] = [];
 
  registerNotificationChannel(notification: INotification): void {
    this.notifications.push(notification);
  }
 
  // Polymorphic method - works with any INotification
  async sendToAll(recipient: string, subject: string, body: string): Promise<void> {
    console.log("=== Sending notifications to all channels ===\n");
 
    for (const notification of this.notifications) {
      await notification.send(recipient, subject, body);
      console.log("---");
    }
  }
}
 
// ============================================
// Usage
// ============================================
async function main(): Promise<void> {
  // Create notification channels
  const emailNotification = new EmailNotification("smtp.example.com", "noreply@example.com");
  const smsNotification = new SMSNotification("Twilio", "+1234567890");
  const pushNotification = new PushNotification("my-app-123");
 
  // Register channels with service
  const service = new NotificationService();
  service.registerNotificationChannel(emailNotification);
  service.registerNotificationChannel(smsNotification);
  service.registerNotificationChannel(pushNotification);
 
  // Send notification to all channels (polymorphism in action)
  await service.sendToAll(
    "user@example.com",
    "Order Confirmation",
    "Your order #12345 has been confirmed!"
  );
}
 
main();
 
// Output:
// === Sending notifications to all channels ===
//
// [NOTIF-xxx] Preparing to send...
// Sending email via smtp.example.com
// From: noreply@example.com
// To: user@example.com
// Subject: Order Confirmation
// Body: Your order #12345 has been confirmed!
// [NOTIF-xxx] Sent successfully
// ---
// [NOTIF-yyy] Preparing to send...
// Sending SMS via Twilio
// From: +1234567890
// To: user@example.com
// Message: Your order #12345 has been confirmed!
// [NOTIF-yyy] Sent successfully
// ---
// [NOTIF-zzz] Preparing to send...
// Sending push notification via app my-app-123
// Device Token: user@example.com
// Title: Order Confirmation
// Body: Your order #12345 has been confirmed!
// [NOTIF-zzz] Sent successfully
// ---

How Each Pillar Contributes

PillarHow It's Used
AbstractionINotification interface defines what notifications do, not how
EncapsulationBaseNotification protects id, status, sentAt with private fields
InheritanceEmailNotification, SMSNotification, PushNotification extend BaseNotification
PolymorphismNotificationService.sendToAll() works with any INotification implementation

Common Violations and Anti-Patterns

1. Exposing Internal State (Violates Encapsulation)

// ❌ BAD
class User {
  public passwords: string[] = []; // Never expose sensitive data!
  public balance: number = 0;      // Can be modified directly
}
 
// ✅ GOOD
class User {
  private passwordHash: string;
  private balance: number = 0;
 
  setPassword(password: string): void {
    this.passwordHash = this.hashPassword(password);
  }
 
  getBalance(): number {
    return this.balance;
  }
}

2. Inheritance for Code Reuse Only (Violates "is-a")

// ❌ BAD: Stack is NOT a vector, just uses similar operations
class Stack extends Vector {
  push(item: any): void { /* ... */ }
  pop(): any { /* ... */ }
}
 
// ✅ GOOD: Stack HAS a list (composition)
class Stack {
  private items: any[] = [];
 
  push(item: any): void { this.items.push(item); }
  pop(): any { return this.items.pop(); }
}

3. Type Checking Instead of Polymorphism

// ❌ BAD: Type checking anti-pattern
function processShape(shape: any): void {
  if (shape instanceof Circle) {
    // Circle logic
  } else if (shape instanceof Rectangle) {
    // Rectangle logic
  } else if (shape instanceof Triangle) {
    // Triangle logic
  }
}
 
// ✅ GOOD: Polymorphism
function processShape(shape: Shape): void {
  shape.draw(); // Each shape knows how to draw itself
}

4. Leaky Abstractions

// ❌ BAD: Abstraction leaks implementation details
interface IRepository {
  executeSqlQuery(sql: string): any;  // Exposes SQL!
  getMongoCollection(): any;          // Exposes MongoDB!
}
 
// ✅ GOOD: Proper abstraction
interface IRepository<T> {
  findById(id: string): T | null;
  findAll(): T[];
  save(entity: T): void;
  delete(id: string): void;
}

Summary and Key Takeaways

The Four Pillars Recap

PillarDefinitionRemember
EncapsulationBundle data + methods, hide internal state"Protect your data"
InheritanceCreate class hierarchies for "is-a" relationships"Extend, don't modify"
PolymorphismOne interface, many implementations"Program to interfaces"
AbstractionHide complexity, show essential features"Simplify the complex"

Best Practices

  1. Start with abstraction: Define interfaces before implementations
  2. Encapsulate by default: Make everything private unless needed
  3. Favor composition over inheritance: Use inheritance only for true "is-a"
  4. Leverage polymorphism: Eliminate conditional type checking
  5. Keep hierarchies shallow: 2-3 levels maximum
  6. Follow SOLID principles: They reinforce the four pillars

What's Next?

Now that you understand the four pillars, you're ready to dive deeper:

  1. Classes, Objects, and Abstraction: Deep dive into abstraction
  2. Encapsulation and Information Hiding: Master data protection
  3. Inheritance and Composition: Choose the right approach
  4. Polymorphism and Interfaces: Flexible design
  5. SOLID Principles Explained: Clean OOP design

Practice Exercises

Exercise 1: Bank Account System

Create a banking system with:

  • An abstract Account class with encapsulated balance
  • Concrete classes: SavingsAccount, CheckingAccount, InvestmentAccount
  • Polymorphic calculateInterest() method (different rates per account type)

Exercise 2: Shape Calculator

Build a shape calculator that:

  • Uses an IShape interface with area() and perimeter() methods
  • Implements at least 4 shapes
  • Has a ShapeCalculator class that works with any IShape

Exercise 3: Notification System

Extend the notification example to:

  • Add a SlackNotification class
  • Implement retry with exponential backoff
  • Add a MultiChannelNotification that sends to multiple channels

Resources


Questions or feedback? Feel free to reach out at contact@chanhle.dev or connect with me on X.

Happy coding! 🚀

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.