Hernando Abella
Chapter 4CompositionInheritanceDesign Patterns

Why Composition Beats Inheritance in Large Applications

Discover why modern software design favors composition over inheritance, and learn how to build more flexible, maintainable, and scalable Node.js applications.

20 min read Hernando Abella๐Ÿ“˜ Node.js Design Patterns
StackNode.jsJavaScriptTypeScriptReactExpressNestJS

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.

javascript ยท inheritance.js
1class Animal {
2  eat() {
3    console.log("Eating");
4  }
5}
6
7class Dog extends Animal {
8  bark() {
9    console.log("Barking");
10  }
11}
12
13const dog = new Dog();
14dog.eat();   // Inherited from Animal
15dog.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

javascript ยท fragile-base.js
1// Parent class changes
2class User {
3  login() {
4    validateCredentials();
5    // Months later, new requirement added
6    verifyTwoFactorAuth();  // New behavior
7  }
8}
9
10class AdminUser extends User {
11  login() {
12    super.login();
13    // Admin-specific logic
14  }
15}
16
17// Every subclass now inherits the new behavior
18// Unexpected side effects can appear throughout the application

What 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

javascript ยท composition.js
1// Behavior modules (LEGO blocks)
2const canFly = {
3  fly() {
4    console.log("Flying");
5  }
6};
7
8const canSwim = {
9  swim() {
10    console.log("Swimming");
11  }
12};
13
14const canWalk = {
15  walk() {
16    console.log("Walking");
17  }
18};
19
20// Compose objects with only the behavior they need
21const eagle = {
22  ...canFly,
23  ...canWalk
24};
25
26const penguin = {
27  ...canSwim,
28  ...canWalk
29};
30
31const fish = {
32  ...canSwim
33};
34
35eagle.fly();   // โœ“ Has fly behavior
36penguin.swim(); // โœ“ Has swim behavior
37// fish.fly()  // โœ— Doesn't have fly behavior

Real-World Node.js Example: Notification System

Inheritance Approach (Problematic)

javascript ยท inheritance-notification.js
1class Notification {
2  send() {}
3}
4
5class EmailNotification extends Notification {}
6class SMSNotification extends Notification {}
7class PushNotification extends Notification {}
8
9// New requirements appear: logging, retries, scheduling, tracking
10// Inheritance becomes difficult to manage

Composition-Based Design (Better)

javascript ยท composition-notification.js
1// Small, reusable components
2const logger = {
3  log(message) {
4    console.log(`[LOG] ${message}`);
5  }
6};
7
8const retryable = {
9  async retry(fn, attempts = 3) {
10    for (let i = 0; i < attempts; i++) {
11      try {
12        return await fn();
13      } catch (err) {
14        this.log(`Attempt ${i + 1} failed`);
15      }
16    }
17  }
18};
19
20const schedulable = {
21  schedule(fn, delay = 1000) {
22    return setTimeout(fn, delay);
23  }
24};
25
26// Compose notification service with required behaviors
27const emailNotification = {
28  ...logger,
29  ...retryable,
30  async send(email, message) {
31    await this.retry(async () => {
32      this.log(`Sending email to ${email}`);
33      // Email sending logic
34    });
35  }
36};
37
38const pushNotification = {
39  ...logger,
40  ...schedulable,
41  send(userId, message) {
42    this.schedule(() => {
43      this.log(`Sending push to ${userId}`);
44    }, 500);
45  }
46};
47
48// 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.

javascript ยท di-composition.js
1class UserService {
2  constructor(
3    userRepository,
4    emailService,
5    logger,
6    cacheService
7  ) {
8    this.userRepository = userRepository;
9    this.emailService = emailService;
10    this.logger = logger;
11    this.cacheService = cacheService;
12  }
13
14  async createUser(userData) {
15    this.logger.log("Creating user");
16    
17    const cached = await this.cacheService.get(userData.email);
18    if (cached) return cached;
19    
20    const user = await this.userRepository.save(userData);
21    await this.emailService.sendWelcome(user.email);
22    
23    await this.cacheService.set(user.email, user);
24    
25    return user;
26  }
27}
28
29// The service is composed from smaller components
30// Each dependency can be swapped independently

Composition in Express Applications

Middleware itself is a form of composition.

javascript ยท express-composition.js
1// Composable middleware
2const authenticate = (req, res, next) => { /* ... */ next(); };
3const validate = (req, res, next) => { /* ... */ next(); };
4const rateLimit = (req, res, next) => { /* ... */ next(); };
5const logRequest = (req, res, next) => { /* ... */ next(); };
6
7// Compose route from reusable functions
8app.post(
9  "/users",
10  logRequest,
11  rateLimit,
12  authenticate,
13  validate,
14  createUserHandler
15);
16
17// Each middleware performs a specific responsibility
18// Can be mixed, matched, and reused across routes

Composition in React

React strongly favors composition over inheritance.

jsx ยท react-composition.jsx
1// Instead of inheritance:
2// class SpecialButton extends Button {}
3
4// React uses composition:
5function Button({ children, onClick, variant }) {
6  return (
7    <button className={`btn btn-${variant}`} onClick={onClick}>
8      {children}
9    </button>
10  );
11}
12
13// Compose behavior with props and children
14function SaveButton() {
15  return (
16    <Button variant="primary" onClick={handleSave}>
17      Save Changes
18    </Button>
19  );
20}
21
22function DeleteButton() {
23  return (
24    <Button variant="danger" onClick={handleDelete}>
25      Delete
26    </Button>
27  );
28}

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
javascript ยท when-inheritance-works.js
1// Simple hierarchies can remain effective
2class Shape {
3  area() { return 0; }
4}
5
6class Circle extends Shape {
7  constructor(radius) {
8    super();
9    this.radius = radius;
10  }
11  
12  area() {
13    return Math.PI * this.radius ** 2;
14  }
15}
16
17class Rectangle extends Shape {
18  constructor(width, height) {
19    super();
20    this.width = width;
21    this.height = height;
22  }
23  
24  area() {
25    return this.width * this.height;
26  }
27}
28
29// Small, stable hierarchy - inheritance works fine

Practical 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)

javascript ยท before-refactor.js
1class BaseService {
2  log(message) {
3    console.log(message);
4  }
5  
6  cache(key, value) {
7    // caching logic
8  }
9  
10  retry(fn) {
11    // retry logic
12  }
13}
14
15class PaymentService extends BaseService {
16  processPayment(amount) {
17    this.log("Processing payment");
18    // payment logic
19  }
20}
21
22class EmailService extends BaseService {
23  sendEmail(to, message) {
24    this.log("Sending email");
25    // email logic
26  }
27}

After (Composition)

javascript ยท after-refactor.js
1// Small, focused modules
2const logger = {
3  log(message) {
4    console.log(message);
5  }
6};
7
8const cacheable = {
9  async get(key) { /* ... */ },
10  async set(key, value) { /* ... */ }
11};
12
13const retryable = {
14  async retry(fn, attempts = 3) { /* ... */ }
15};
16
17// Compose services with only what they need
18class PaymentService {
19  constructor(logger, cache, retry) {
20    this.logger = logger;
21    this.cache = cache;
22    this.retry = retry;
23  }
24  
25  async processPayment(amount) {
26    this.logger.log("Processing payment");
27    return this.retry.retry(async () => {
28      // payment logic with retry
29    });
30  }
31}
32
33class EmailService {
34  constructor(logger) {
35    this.logger = logger;
36  }
37  
38  sendEmail(to, message) {
39    this.logger.log(`Sending email to ${to}`);
40    // email logic (no retry needed)
41  }
42}
43
44// The service now depends on capabilities rather than ancestry
45// This makes the system easier to modify and extend

Conclusion

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.


๐Ÿ“˜ From the Book

Node.js Design Patterns

Master composition over inheritance, SOLID principles, DRY, KISS, YAGNI, and essential design patterns for building scalable Node.js applications.

๐ŸŽฏ Composition๐Ÿ—๏ธ Design Patternsโšก Best Practices๐Ÿ”ง Clean Architecture
Get it on Amazon โ†’
Node.js Design Patterns book cover
Share X LinkedIn
Hernando Abella

Hernando Abella

Software engineer and author. I write about Python, AI, and software architecture. Author of 55+ programming books and creator of interactive coding challenges.