Object-Oriented Design Principles

Object-Oriented Design (OOD) is a paradigm that uses "objects" – instances of classes – to design applications and computer programs. This approach organizes software design around data, or objects, rather than functions and logic.

OOD principles help create software that is:

Core Concepts

Before diving into principles, understanding the fundamental concepts is crucial:

Key Design Principles (SOLID)

The SOLID principles are a set of five design principles intended to make software designs more understandable, flexible, and maintainable.

S: Single Responsibility Principle (SRP)

A class should have only one reason to change. This means that a class should have a single, well-defined job. If a class has multiple responsibilities, it becomes harder to maintain and test.

Example: Instead of a `Report` class that generates a report, formats it, and sends it via email, you would have separate classes like `ReportGenerator`, `ReportFormatter`, and `EmailSender`.

O: Open/Closed Principle (OCP)

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. This means you should be able to add new functionality without altering existing code.

Example: Using interfaces or abstract classes allows you to create new implementations that extend the behavior of existing code without changing the base classes.


// Instead of modifying this
class DiscountCalculator {
    calculate(price, type) {
        if (type === 'regular') {
            return price * 0.9;
        } else if (type === 'premium') {
            return price * 0.8;
        }
        return price;
    }
}

// Use this with extensions
interface DiscountStrategy {
    applyDiscount(price: number): number;
}

class RegularDiscount implements DiscountStrategy {
    applyDiscount(price: number): number {
        return price * 0.9;
    }
}

class PremiumDiscount implements DiscountStrategy {
    applyDiscount(price: number): number {
        return price * 0.8;
    }
}

class DiscountCalculator {
    private strategy: DiscountStrategy;

    constructor(strategy: DiscountStrategy) {
        this.strategy = strategy;
    }

    calculate(price: number): number {
        return this.strategy.applyDiscount(price);
    }
}
            

L: Liskov Substitution Principle (LSP)

Objects of a superclass should be replaceable with objects of its subclasses without altering the correctness of the program. Subtypes must be substitutable for their base types.

Example: If you have a `Bird` class with a `fly()` method, a `Penguin` class (which is a subtype of `Bird`) should not override `fly()` with an exception or no-op. Instead, `Penguin` might not inherit from `Bird` or might have a different interface.

I: Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they do not use. It is better to have many small, client-specific interfaces than one large, general-purpose interface.

Example: Instead of a single `Worker` interface with `work()` and `eat()` methods, you might have `Workable` and `Eatable` interfaces, allowing different types of workers to implement only what they need.

D: Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.

Example: A `ReportGenerator` (high-level) should not directly depend on a `DatabaseLogger` (low-level). Instead, both should depend on an `ILogger` interface.


// Low-level module
class DatabaseLogger {
    log(message: string): void {
        console.log(`[DB] ${message}`);
    }
}

// High-level module depending on low-level directly (BAD)
class OrderProcessorBad {
    private logger: DatabaseLogger;

    constructor() {
        this.logger = new DatabaseLogger(); // Tight coupling
    }

    processOrder(orderId: string) {
        this.logger.log(`Processing order: ${orderId}`);
        // ... processing logic
    }
}

// Abstraction
interface ILogger {
    log(message: string): void;
}

// High-level module depending on abstraction
class OrderProcessorGood {
    private logger: ILogger;

    constructor(logger: ILogger) {
        this.logger = logger; // Dependency Injection
    }

    processOrder(orderId: string) {
        this.logger.log(`Processing order: ${orderId}`);
        // ... processing logic
    }
}

// Usage
const dbLogger = new DatabaseLogger();
const orderProcessor = new OrderProcessorGood(dbLogger);
orderProcessor.processOrder("ORD123");
            

Best Practices

Next: Introduction to Design Patterns

Previous: Programming Paradigms Overview