Open almost any Indian engineering college textbook on Object-Oriented Programming and you will find the exact same explanation: Dog inherits from Animal, and Car inherits from Vehicle. Then you enter the software industry, inherit a 5-year-old enterprise repository, and discover that deep inheritance hierarchies are one of the fastest ways to make a codebase completely unmaintainable.
Joe Armstrong, creator of Erlang, famously captured this trap: "You wanted a banana, but what you got was a gorilla holding the banana and the entire jungle."
OOP is not about building rigid taxonomic trees of classes. OOP is about managing state complexity, protecting business invariants, and decoupling interfaces from implementations.
The Death of Deep Inheritance: Why Composition Wins
Inheritance represents an IS-A relationship. Composition represents a HAS-A relationship.
In textbook exercises, inheritance looks clean. In production software, requirements change every week. Suppose you are building a billing system for an Indian logistics platform:
// Textbook approach: Deep Inheritance
class User { /* ... */ }
class CustomerUser extends User { /* ... */ }
class PremiumCustomerUser extends CustomerUser { /* ... */ }
class EnterpriseCustomerUser extends PremiumCustomerUser { /* ... */ }
What happens when business requirements demand that a contractor can act as both an Enterprise customer and a technical auditor? You cannot inherit from two parent classes without hitting the dreaded diamond problem, and single inheritance forces you to duplicate methods across branches.
The Composition Pattern
Instead of locking capabilities into an inheritance tree, compose classes by injecting small, interchangeable behaviors:
interface PaymentProcessor {
charge(amountInPaise: bigint): Promise<string>;
}
interface NotificationService {
sendAlert(destination: string, message: string): Promise<void>;
}
// Composition: User has a wallet processor and notification channels
class AccountService {
constructor(
private readonly payment: PaymentProcessor,
private readonly notifier: NotificationService
) {}
async processSubscriptionRenewal(userId: string, fee: bigint): Promise<void> {
const transactionId = await this.payment.charge(fee);
await this.notifier.sendAlert(userId, `Renewed transaction: ${transactionId}`);
}
}
Now, switching payment gateways from Razorpay to Stripe requires zero modifications to AccountService. You just supply a different implementation of PaymentProcessor. Testing becomes instant because you can pass a mock in-memory processor during unit tests.
True Encapsulation: Protecting Invariants, Not Just Adding Getters
Many junior developers believe encapsulation simply means making fields private and immediately generating public get and set methods for every property:
// Fake Encapsulation: Anemic Domain Model
class BankAccount {
private balance: number = 0;
public getBalance(): number {
return this.balance;
}
public setBalance(value: number): void {
this.balance = value; // Anyone can set balance to -10,000,000!
}
}
That is not encapsulation. That is a public variable disguised as extra typing. Real encapsulation guarantees that an object can never enter an illegal or corrupt state. The class boundaries enforce business invariants:
// True Encapsulation: Invariants Protected
class BankAccount {
private balanceInPaise: bigint = 0n;
private isFrozen: boolean = false;
public get balance(): bigint {
return this.balanceInPaise;
}
public deposit(amountInPaise: bigint): void {
if (this.isFrozen) {
throw new Error('Account is frozen: deposits rejected');
}
if (amountInPaise <= 0n) {
throw new Error('Deposit amount must be strictly positive');
}
this.balanceInPaise += amountInPaise;
}
public withdraw(amountInPaise: bigint): void {
if (this.isFrozen) {
throw new Error('Account is frozen: withdrawals rejected');
}
if (amountInPaise <= 0n) {
throw new Error('Withdrawal amount must be strictly positive');
}
if (amountInPaise > this.balanceInPaise) {
throw new Error('Insufficient funds: overdraft prohibited');
}
this.balanceInPaise -= amountInPaise;
}
}
No outside caller can force balanceInPaise into a negative state. The object protects its own integrity.
SOLID Principles Without the Academic Fluff
The SOLID acronym is often taught as abstract theory. Here is what each principle actually means on a daily engineering team:
| Principle | Practical Definition | Production Smell It Prevents |
|---|---|---|
| S: Single Responsibility | A class should have only one reason to change. | A 2,500-line "UserHelper" class that handles database queries, sends emails, and parses CSVs. |
| O: Open/Closed | Open for extension, closed for modification. | Adding a new payment provider by editing a massive switch-case statement in core checkout logic. |
| L: Liskov Substitution | Derived classes must be substitutable for their base interfaces. | A subclass overriding a parent method to throw throw new Error("NotSupportedException"). |
| I: Interface Segregation | Clients should not depend on methods they do not call. | An interface with 40 methods where every implementer leaves 30 methods empty. |
| D: Dependency Inversion | Depend upon abstractions, not concrete driver classes. | Hardcoding new MySQLClient() directly inside a domain billing model. |
Entities vs Value Objects: A Critical Distinction
When modeling business software, you must distinguish between things with an ongoing identity and things defined purely by their values.
1. Entities (Identified by Unique ID)
A User is an Entity. If two users both have the name "Amit Sharma" and live in the same city, they are still two distinct human beings because their database IDs (user_101 and user_102) are different. An Entity can change its properties over time while remaining the exact same entity.
2. Value Objects (Identified by Attributes)
A ₹500 currency note or an email address is a Value Object. If you have two instances of ₹500, you do not care which one is which: their values are interchangeable. Value objects should always be immutable:
// Value Object: Immutable and compared by value
class Money {
constructor(
public readonly amountInPaise: bigint,
public readonly currency: 'INR' | 'USD' = 'INR'
) {
if (amountInPaise < 0n) {
throw new Error('Monetary amounts cannot be negative');
}
Object.freeze(this);
}
public add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error(`Currency mismatch: cannot add ${this.currency} and ${other.currency}`);
}
return new Money(this.amountInPaise + other.amountInPaise, this.currency);
}
public equals(other: Money): boolean {
return this.amountInPaise === other.amountInPaise && this.currency === other.currency;
}
}
Using Value Objects prevents currency conversion bugs and prevents accidental mutations across threads and async loops.
Frequently Asked Questions
Is OOP obsolete compared to Functional Programming (FP)?
No. Modern production systems blend both paradigms. Functional programming shines for pure data transformations, pipeline processing, and math. Object-oriented principles shine for domain modeling, boundary isolation, and defining clear service contracts across teams.
Why is multiple inheritance banned in languages like Java and C#?
Multiple inheritance causes ambiguities known as the diamond problem, where a subclass inherits conflicting implementations of the same method from two different parent classes. Modern languages permit implementing multiple interfaces while restricting class inheritance to a single parent.
What is Dependency Injection in simple terms?
Dependency Injection means that instead of a class creating its own helper tools inside its constructor with new Service(), the caller passes those tools in as arguments. This allows swapping real external services with mocks during unit tests.
