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:
| Pillar | Description | Key Benefit |
|---|---|---|
| Encapsulation | Bundle data and methods together, hide internal state | Maintainability & Security |
| Inheritance | Create class hierarchies, reuse code through "is-a" relationships | Code Reuse |
| Polymorphism | One interface, multiple implementations | Flexibility |
| Abstraction | Hide complexity, expose only essential features | Simplicity |
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
- Data Hiding: Keep internal state private
- Controlled Access: Expose only what's necessary through methods
- Validation: Ensure data integrity through controlled mutations
- 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 protectionPython:
# ❌ 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 privatePython:
# ✅ 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 attributeJava:
// ✅ 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 accessBenefits of Encapsulation
- Data Integrity: Validation prevents invalid states
- Maintainability: Change internal implementation without breaking external code
- Security: Hide sensitive data and operations
- Testability: Clear boundaries make testing easier
- Reduced Coupling: External code depends on interface, not implementation
Access Modifiers Comparison
| Modifier | TypeScript | Python | Java |
|---|---|---|---|
| Public | public (default) | No prefix (default) | public |
| Protected | protected | _prefix (convention) | protected |
| Private | private | __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
- Parent/Superclass: The base class being inherited from
- Child/Subclass: The class that inherits
- Method Overriding: Child replaces parent's method implementation
superKeyword: Access parent class methods and constructor- 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 furniturePython:
# 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 furnitureJava:
// 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 furnitureReal-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 developersInheritance 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
- Compile-time (Static) Polymorphism: Method overloading - same method name, different parameters
- 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
- Flexibility: Add new implementations without changing existing code
- Extensibility: Easy to extend the system (Open/Closed Principle)
- Loose Coupling: Client code depends on abstractions, not concretions
- Testability: Easy to mock implementations for testing
- 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
- Hide Complexity: Internal workings are hidden
- Show Essential Features: Only expose what users need
- Abstract Classes: Partial implementation (some methods implemented)
- Interfaces: Pure contracts (no implementation)
- Separation of Concerns: What vs How
Abstract Classes vs Interfaces
| Aspect | Abstract Class | Interface |
|---|---|---|
| Implementation | Can have some method implementations | No implementation (traditionally) |
| State | Can have instance variables | No state (constants only) |
| Inheritance | Single inheritance | Multiple implementation |
| Constructor | Can have constructor | No constructor |
| Use When | Sharing code among related classes | Defining 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
- Simplicity: Hide complex details from users
- Flexibility: Change implementation without affecting clients
- Maintainability: Isolated changes don't cascade
- Testability: Easy to mock abstract interfaces
- 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
| Pillar | How It's Used |
|---|---|
| Abstraction | INotification interface defines what notifications do, not how |
| Encapsulation | BaseNotification protects id, status, sentAt with private fields |
| Inheritance | EmailNotification, SMSNotification, PushNotification extend BaseNotification |
| Polymorphism | NotificationService.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
| Pillar | Definition | Remember |
|---|---|---|
| Encapsulation | Bundle data + methods, hide internal state | "Protect your data" |
| Inheritance | Create class hierarchies for "is-a" relationships | "Extend, don't modify" |
| Polymorphism | One interface, many implementations | "Program to interfaces" |
| Abstraction | Hide complexity, show essential features | "Simplify the complex" |
Best Practices
- Start with abstraction: Define interfaces before implementations
- Encapsulate by default: Make everything private unless needed
- Favor composition over inheritance: Use inheritance only for true "is-a"
- Leverage polymorphism: Eliminate conditional type checking
- Keep hierarchies shallow: 2-3 levels maximum
- Follow SOLID principles: They reinforce the four pillars
What's Next?
Now that you understand the four pillars, you're ready to dive deeper:
- Classes, Objects, and Abstraction: Deep dive into abstraction
- Encapsulation and Information Hiding: Master data protection
- Inheritance and Composition: Choose the right approach
- Polymorphism and Interfaces: Flexible design
- SOLID Principles Explained: Clean OOP design
Practice Exercises
Exercise 1: Bank Account System
Create a banking system with:
- An abstract
Accountclass 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
IShapeinterface witharea()andperimeter()methods - Implements at least 4 shapes
- Has a
ShapeCalculatorclass that works with anyIShape
Exercise 3: Notification System
Extend the notification example to:
- Add a
SlackNotificationclass - Implement retry with exponential backoff
- Add a
MultiChannelNotificationthat sends to multiple channels
Resources
- Refactoring.Guru - OOP - Visual explanations
- Head First Design Patterns - Beginner-friendly
- Clean Code by Robert C. Martin - Best practices
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.