DEV Community

Cover image for When to Choose Inheritance vs. Composition
Ryo Suwito
Ryo Suwito

Posted on

When to Choose Inheritance vs. Composition

Choosing between inheritance and composition isn’t just a matter of coding style—it’s a bit like deciding whether you want a Swiss Army knife or a box of Lego. Sure, both have their place, but if you go overboard with one extreme, you’re in for a world of hurt. Let’s face it, if your codebase starts resembling a never-ending novel with cliffhangers in every file or a Lego set with so many tiny pieces that stepping on one becomes a life-or-death situation, you’ve got a problem. So, buckle up as we explore why you need a middle ground between the two extremes.

Imagine this:
you’re building a payment verification system, and you decide to go full-on inheritance. That’s like hiding the secret sauce of your grandma’s famous recipe in a series of locked boxes scattered around the house. You have a base class that does the heavy lifting, and then a bunch of subclasses that extend it. It sounds neat—until you realize that tracking down a single function means jumping through more files than a detective in a noir film. Welcome to “Inheritance Hell,” where even the simplest function is a mystery wrapped in an enigma, and your debugging skills are put to the ultimate test.

Here’s what that looks like when done right—and when done too far:

The “Deep Chain of Despair” (a.k.a. Worst-Case Inheritance)

Picture a project directory where inheritance is taken to the extreme. You’ve got abstract classes stacked so high, they make the Tower of Babel look like a simple stack of pancakes:

/project
  /src
    /core
      /validators
        BasePaymentVerifier.js
        AbstractCreditCardVerifier.js
        AbstractBankTransferVerifier.js
    /features
      /payments
        CreditCardPaymentVerifier.js
        BankTransferPaymentVerifier.js
Enter fullscreen mode Exit fullscreen mode

To figure out what a payment verifier does, you’re forced to embark on an Indiana Jones-style adventure through file after file. It’s a maze where every turn leads to yet another abstract class, and by the time you find the actual implementation, you’re so lost you might as well be trying to find Atlantis.

The “Lego Block Lunacy” (a.k.a. Worst-Case Composition)

Now, flip the script. You decide to decompose everything into its tiniest, most generic parts. Every validation function gets its own file, and suddenly you’re in “Lego Block Hell.” Here’s how that might look:

/project
  /src
    /utils
      validateAmount.js
      validateCurrency.js
      validateCardNumber.js
      validateAccountNumber.js
    /features
      /payments
        CreditCardVerifierComposite.js
        BankTransferVerifierComposite.js
Enter fullscreen mode Exit fullscreen mode

Every little piece is so isolated that making a tiny change feels like defusing a bomb—one wrong move, and boom, your credit card validation inadvertently starts checking bank account numbers. It’s modular, sure, but also a maintenance nightmare that will have you questioning your life choices and wondering why you didn’t just stick to a monolithic mess in the first place.

Finding the Goldilocks Zone

What you really need is the Goldilocks zone: not too deep, not too fragmented—just right. When choosing between inheritance and composition, the key is to evaluate your functions. Ask yourself: Is this function straightforward enough to be reused without leading to a scavenger hunt across dozens of files? And is it self-contained enough that splitting it further won’t turn it into a Lego brick that fits nowhere?

A Balanced Inheritance Approach

When more than half of your functionality is shared, inheritance can be your best friend—as long as you don’t build the Great Wall of Inheritance that spans your entire project. Instead, centralize common logic in a base class, but keep the hierarchy shallow enough that a developer isn’t forced into an epic quest just to understand what’s happening.

Directory structure:

/project
  /src
    /validators
      PaymentVerifier.js
    /features
      /payments
        CreditCardVerifier.js
        BankTransferVerifier.js
Enter fullscreen mode Exit fullscreen mode

Code example:

// PaymentVerifier.js: The base class that does the heavy lifting.
class PaymentVerifier {
  constructor(payment) {
    this.payment = payment;
  }

  validateAmount() {
    // Because free money doesn’t exist.
    return this.payment.amount > 0;
  }

  validateCurrency() {
    // Acceptable currencies—no Monopoly money allowed.
    const allowedCurrencies = ['USD', 'EUR', 'GBP'];
    return allowedCurrencies.includes(this.payment.currency);
  }

  verify() {
    // Combining validations like a cocktail shaker.
    return this.validateAmount() && this.validateCurrency();
  }
}

export default PaymentVerifier;
Enter fullscreen mode Exit fullscreen mode
// CreditCardVerifier.js: Extends the base class, adding its own flavor.
import PaymentVerifier from '../../validators/PaymentVerifier';

class CreditCardVerifier extends PaymentVerifier {
  verify() {
    const baseValid = super.verify();
    // Because a credit card without a 16-digit number is like a burger without a patty.
    const hasValidCardNumber = this.payment.cardNumber && this.payment.cardNumber.length === 16;
    return baseValid && hasValidCardNumber;
  }
}

export default CreditCardVerifier;
Enter fullscreen mode Exit fullscreen mode

A Balanced Composition Approach

If the overlap between components is minimal, composition might be the way to go—but don’t scatter your utility functions into a thousand separate files. Instead, group related functions together so you can still see the big picture without following breadcrumbs across the entire codebase.

Directory structure:

/project
  /src
    /utils
      PaymentUtils.js
    /features
      /payments
        CreditCardVerifierComposite.js
        BankTransferVerifierComposite.js
Enter fullscreen mode Exit fullscreen mode

Code example:

// PaymentUtils.js: The Swiss Army knife for payment validations.
class PaymentUtils {
  static validateAmount(payment) {
    return payment.amount > 0;
  }

  static validateCurrency(payment) {
    const allowedCurrencies = ['USD', 'EUR', 'GBP'];
    return allowedCurrencies.includes(payment.currency);
  }
}

export default PaymentUtils;
Enter fullscreen mode Exit fullscreen mode
// CreditCardVerifierComposite.js: Uses utilities without overcomplicating things.
import PaymentUtils from '../../utils/PaymentUtils';

class CreditCardVerifierComposite {
  constructor(payment) {
    this.payment = payment;
  }

  verify() {
    const baseValid = PaymentUtils.validateAmount(this.payment) &&
                      PaymentUtils.validateCurrency(this.payment);
    // Checking card number—because size does matter.
    const hasValidCardNumber = this.payment.cardNumber && this.payment.cardNumber.length === 16;
    return baseValid && hasValidCardNumber;
  }
}

export default CreditCardVerifierComposite;
Enter fullscreen mode Exit fullscreen mode

Conclusion: Choose Wisely, Code Wisely

At the end of the day, choosing between inheritance and composition is a bit like picking your favorite pizza topping. If you load your pizza with too many toppings (or layers of abstraction), you’re in for a soggy, confusing mess. On the other hand, if you spread out your toppings too thinly (or split your functions into microscopic pieces), you might lose the flavor entirely—and risk stepping on a Lego piece in the process.

Strive for the middle ground: evaluate whether a function is straightforward enough to be reused without sending your colleagues on a scavenger hunt through your file system. Keep your inheritance chains shallow and your utility functions grouped sensibly. That way, you’ll avoid the pitfalls of both “Deep Chain of Despair” and “Lego Block Lunacy,” creating a codebase that’s as robust as it is readable—and leaving you plenty of time to roast your past coding mistakes over a cup of well-earned coffee.

Top comments (0)