For decades, inheritance was considered a cornerstone of object-oriented programming. However, experience from large-scale software systems has revealed significant limitations. Modern software design has largely shifted toward composition.
Favor composition over inheritance allows developers to build systems from small, reusable components that can be combined in flexible ways without creating rigid relationships between classes.
Understanding Inheritance
Inheritance allows one class to acquire properties and behaviors from another.
class Animal {
eat() {
console.log("Eating");
}
}
class Dog extends Animal {
bark() {
console.log("Barking");
}
}
const dog = new Dog();
dog.eat(); // Inherited from Animal
dog.bark(); // Defined in Dogโ At first glance, this appears to promote code reuse. But as applications grow, problems emerge.
The Hidden Problems of Inheritance
Tight Coupling
Child classes are tightly coupled to parent classes. Changes to parent may unintentionally affect children.
Deep Hierarchies
Understanding behavior requires inspecting multiple classes. Debugging becomes slower and harder.
Fragile Base Class
Changes in parent classes can break subclasses unexpectedly, creating ripple effects.
Overgeneralization
Developers often predict future needs incorrectly, creating unnecessary complexity.
The Fragile Base Class Problem
// Parent class changes
class User {
login() {
validateCredentials();
// Months later, new requirement added
verifyTwoFactorAuth(); // New behavior
}
}
class AdminUser extends User {
login() {
super.login();
// Admin-specific logic
}
}
// Every subclass now inherits the new behavior
// Unexpected side effects can appear throughout the applicationWhat Is Composition?
Composition builds objects by combining smaller pieces of behavior. Instead of inheriting functionality, objects receive functionality through collaboration.
๐ก Think of composition as building with LEGO blocks rather than extending a family tree.
Composition Example
// Behavior modules (LEGO blocks)
const canFly = {
fly() {
console.log("Flying");
}
};
const canSwim = {
swim() {
console.log("Swimming");
}
};
const canWalk = {
walk() {
console.log("Walking");
}
};
// Compose objects with only the behavior they need
const eagle = {
...canFly,
...canWalk
};
const penguin = {
...canSwim,
...canWalk
};
const fish = {
...canSwim
};
eagle.fly(); // โ Has fly behavior
penguin.swim(); // โ Has swim behavior
// fish.fly() // โ Doesn't have fly behaviorReal-World Node.js Example: Notification System
Inheritance Approach (Problematic)
class Notification {
send() {}
}
class EmailNotification extends Notification {}
class SMSNotification extends Notification {}
class PushNotification extends Notification {}
// New requirements appear: logging, retries, scheduling, tracking
// Inheritance becomes difficult to manageComposition-Based Design (Better)
// Small, reusable components
const logger = {
log(message) {
console.log(`[LOG] ${message}`);
}
};
const retryable = {
async retry(fn, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
this.log(`Attempt ${i + 1} failed`);
}
}
}
};
const schedulable = {
schedule(fn, delay = 1000) {
return setTimeout(fn, delay);
}
};
// Compose notification service with required behaviors
const emailNotification = {
...logger,
...retryable,
async send(email, message) {
await this.retry(async () => {
this.log(`Sending email to ${email}`);
// Email sending logic
});
}
};
const pushNotification = {
...logger,
...schedulable,
send(userId, message) {
this.schedule(() => {
this.log(`Sending push to ${userId}`);
}, 500);
}
};
// No hierarchy required. Behavior is assembled as needed.Why Composition Scales Better
Greater Flexibility
Inheritance provides behavior from a single parent. Composition allows combining multiple behaviors.
Better Reusability
Reusable modules can be shared across unrelated objects without creating inheritance relationships.
Lower Coupling
Components depend on behavior rather than hierarchy. Dependencies can be replaced easily.
Easier Testing
Test individual behaviors independently. No need to construct entire object hierarchies.
SOLID Principles Support
- Single Responsibility: Each component performs one task
- Open/Closed: Behavior can be extended without modifying existing code
- Dependency Inversion: Objects depend on abstractions, not concrete implementations
Composition with Dependency Injection
Modern Node.js frameworks frequently use composition through dependency injection.
class UserService {
constructor(
userRepository,
emailService,
logger,
cacheService
) {
this.userRepository = userRepository;
this.emailService = emailService;
this.logger = logger;
this.cacheService = cacheService;
}
async createUser(userData) {
this.logger.log("Creating user");
const cached = await this.cacheService.get(userData.email);
if (cached) return cached;
const user = await this.userRepository.save(userData);
await this.emailService.sendWelcome(user.email);
await this.cacheService.set(user.email, user);
return user;
}
}
// The service is composed from smaller components
// Each dependency can be swapped independentlyComposition in Express Applications
Middleware itself is a form of composition.
// Composable middleware
const authenticate = (req, res, next) => { /* ... */ next(); };
const validate = (req, res, next) => { /* ... */ next(); };
const rateLimit = (req, res, next) => { /* ... */ next(); };
const logRequest = (req, res, next) => { /* ... */ next(); };
// Compose route from reusable functions
app.post(
"/users",
logRequest,
rateLimit,
authenticate,
validate,
createUserHandler
);
// Each middleware performs a specific responsibility
// Can be mixed, matched, and reused across routesComposition in React
React strongly favors composition over inheritance.
// Instead of inheritance:
// class SpecialButton extends Button {}
// React uses composition:
function Button({ children, onClick, variant }) {
return (
<button className={`btn btn-${variant}`} onClick={onClick}>
{children}
</button>
);
}
// Compose behavior with props and children
function SaveButton() {
return (
<Button variant="primary" onClick={handleSave}>
Save Changes
</Button>
);
}
function DeleteButton() {
return (
<Button variant="danger" onClick={handleDelete}>
Delete
</Button>
);
}When Inheritance Still Makes Sense
Inheritance works well when:
- There is a true "is-a" relationship
- The hierarchy is stable and unlikely to change
- Shared behavior is truly common across all subclasses
- The number of subclasses remains small
// Simple hierarchies can remain effective
class Shape {
area() { return 0; }
}
class Circle extends Shape {
constructor(radius) {
super();
this.radius = radius;
}
area() {
return Math.PI * this.radius ** 2;
}
}
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
}
// Small, stable hierarchy - inheritance works finePractical Guidelines
โ Prefer composition when:
- Reusing behavior across unrelated objects
- Sharing functionality between services
- Building modular architectures
- Designing flexible APIs
โ Consider inheritance when:
- Modeling clear domain hierarchies
- Implementing polymorphic structures
- The hierarchy is unlikely to evolve
๐ก A useful rule: Use inheritance for identity. Use composition for behavior.
Refactoring from Inheritance to Composition
Before (Inheritance)
class BaseService {
log(message) {
console.log(message);
}
cache(key, value) {
// caching logic
}
retry(fn) {
// retry logic
}
}
class PaymentService extends BaseService {
processPayment(amount) {
this.log("Processing payment");
// payment logic
}
}
class EmailService extends BaseService {
sendEmail(to, message) {
this.log("Sending email");
// email logic
}
}After (Composition)
// Small, focused modules
const logger = {
log(message) {
console.log(message);
}
};
const cacheable = {
async get(key) { /* ... */ },
async set(key, value) { /* ... */ }
};
const retryable = {
async retry(fn, attempts = 3) { /* ... */ }
};
// Compose services with only what they need
class PaymentService {
constructor(logger, cache, retry) {
this.logger = logger;
this.cache = cache;
this.retry = retry;
}
async processPayment(amount) {
this.logger.log("Processing payment");
return this.retry.retry(async () => {
// payment logic with retry
});
}
}
class EmailService {
constructor(logger) {
this.logger = logger;
}
sendEmail(to, message) {
this.logger.log(`Sending email to ${to}`);
// email logic (no retry needed)
}
}
// The service now depends on capabilities rather than ancestry
// This makes the system easier to modify and extendConclusion
Inheritance was once the default mechanism for code reuse, but large-scale software development has shown its limitations. Deep hierarchies, tight coupling, and fragile dependencies often make inheritance difficult to maintain as applications grow.
Composition offers a more flexible alternative. By assembling behavior from small, reusable components, developers can build systems that are easier to understand, test, extend, and maintain.
Modern Node.js frameworks, dependency injection systems, middleware architectures, and component-based libraries all embrace composition as a core design philosophy. As applications evolve, developers who favor composition over inheritance are better equipped to build software that remains manageable and resilient over time.
Node.js Design Patterns
Master composition over inheritance, SOLID principles, DRY, KISS, YAGNI, and essential design patterns for building scalable Node.js applications.
