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

javascript ยท fragile-base.js
// 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 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
// 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 behavior

Real-World Node.js Example: Notification System

Inheritance Approach (Problematic)

javascript ยท inheritance-notification.js
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 manage

Composition-Based Design (Better)

javascript ยท composition-notification.js
// 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.

javascript ยท di-composition.js
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 independently

Composition in Express Applications

Middleware itself is a form of composition.

javascript ยท express-composition.js
// 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 routes

Composition in React

React strongly favors composition over inheritance.

jsx ยท react-composition.jsx
// 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
javascript ยท when-inheritance-works.js
// 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 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
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)

javascript ยท after-refactor.js
// 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 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