Composition vs Inheritance: How to Choose Flexible Collaboration Over Fragile Hierarchies
Dev
Last updated on

Composition vs Inheritance: How to Choose Flexible Collaboration Over Fragile Hierarchies


When people first learn object-oriented programming, inheritance often feels like the most “official” tool. You define a base class, extend it, and reuse behavior. That sounds elegant at first, so many beginners end up reaching for inheritance before they understand the tradeoff.

In real projects, though, one sentence shows up again and again: prefer composition over inheritance. That advice is not saying inheritance is bad. It is saying inheritance creates a tighter relationship than many teams actually want once the code starts changing.

In this guide, we will cover:

  • what inheritance and composition actually mean
  • why inheritance can become fragile faster than it looks
  • how composition helps you adapt behavior without growing a class hierarchy
  • when inheritance is still a perfectly good choice

The short version is this: inheritance is strongest when the subtype really must behave like the parent, while composition is usually safer when you mainly want to mix behaviors and keep change local.

What is inheritance?

Inheritance means one type extends another type and reuses part of its structure or behavior.

For example:

  • Animal
  • Dog extends Animal

The child type gets part of the parent’s data or methods automatically. This can be useful when there is a real, stable “is-a” relationship and the subtype naturally fulfills the parent contract.

That last part matters. Inheritance is not just code reuse. It is a design promise:

  • the child should behave like the parent in the places where the parent is expected
  • changes in the parent can affect the child
  • the child is now tied to the parent shape

So inheritance can be clean, but it is never a neutral relationship.

What is composition?

Composition means an object gets its behavior by working with other objects instead of inheriting everything from one parent.

Instead of saying:

  • “this type is a kind of base notifier”

you often say:

  • “this object uses a transport”
  • “this object uses a retry policy”
  • “this object uses a logger”

That shifts the design question from “what parent should this class extend?” to “what collaborators does this object need?”

This usually leads to looser coupling because each collaborator can change independently.

Why composition is often safer in real projects

Inheritance often starts simple and feels convenient. The trouble appears when requirements become slightly more specific:

  • one subtype needs a different validation rule
  • another subtype needs extra logging
  • one channel needs retries but another does not
  • some shared behavior is not actually shared in the same way

At that point, the hierarchy starts carrying too many responsibilities at once. One parent class is trying to define:

  • shared data
  • shared workflow
  • extension points
  • subtype restrictions

That is a lot of pressure for a single abstraction. Composition usually handles that pressure better because you can swap or combine behaviors without inventing a new subclass every time the matrix of requirements changes.

Practical example: a notification workflow built with inheritance

Imagine a team starts with a base notifier:

abstract class Notifier {
  send(message: string): void {
    this.beforeSend(message);
    this.deliver(message);
    this.afterSend(message);
  }

  protected beforeSend(message: string): void {
    console.log('prepare', message);
  }

  protected afterSend(message: string): void {
    console.log('done');
  }

  protected abstract deliver(message: string): void;
}

class EmailNotifier extends Notifier {
  protected deliver(message: string): void {
    console.log('email', message);
  }
}

class SmsNotifier extends Notifier {
  protected deliver(message: string): void {
    console.log('sms', message);
  }
}

This looks fine at first. But then the requirements expand:

  • email needs retries
  • SMS needs rate limiting
  • some notifications need audit logging
  • some notifications should skip the “prepare” step entirely

Now the hierarchy starts to spread sideways:

class RetryingEmailNotifier extends EmailNotifier {
  protected override afterSend(message: string): void {
    console.log('retry on failure for', message);
  }
}

class AuditedRetryingEmailNotifier extends RetryingEmailNotifier {
  protected override beforeSend(message: string): void {
    console.log('audit start', message);
  }
}

This is where inheritance gets uncomfortable:

  • behavior combinations turn into subclass combinations
  • parent hooks start existing mainly to satisfy edge cases
  • small changes in the base flow can surprise multiple subclasses

The hierarchy is no longer expressing a clean “is-a” relationship. It is becoming a container for feature combinations.

Refactor: compose reusable behaviors instead

A more flexible design is to separate the roles:

interface MessageTransport {
  send(message: string): Promise<void>;
}

interface RetryPolicy {
  run(task: () => Promise<void>): Promise<void>;
}

interface Logger {
  info(message: string): void;
}

class FixedRetryPolicy implements RetryPolicy {
  constructor(private attempts: number) {}

  async run(task: () => Promise<void>): Promise<void> {
    for (let tryCount = 1; tryCount <= this.attempts; tryCount += 1) {
      try {
        await task();
        return;
      } catch (error) {
        if (tryCount === this.attempts) throw error;
      }
    }
  }
}

class EmailTransport implements MessageTransport {
  async send(message: string): Promise<void> {
    console.log('email', message);
  }
}

class NotificationSender {
  constructor(
    private transport: MessageTransport,
    private retryPolicy: RetryPolicy,
    private logger: Logger
  ) {}

  async send(message: string): Promise<void> {
    this.logger.info('sending notification');
    await this.retryPolicy.run(() => this.transport.send(message));
  }
}

Now the design is easier to evolve:

  • change transport without changing retry logic
  • change retry policy without changing sender logic
  • add a different logger without creating another subclass chain

If you want SMS instead of email, you can provide SmsTransport. If one workflow needs no retries, you can provide a NoRetryPolicy. The combinations happen through wiring, not through deeper inheritance trees.

That is the practical advantage of composition: you build behavior from focused pieces instead of forcing all variation into a parent-child hierarchy.

When inheritance fits well

Inheritance is still a good tool when the relationship is stable and natural.

Good signals:

  • the subtype truly is a specialized form of the parent
  • the parent contract is clear and unlikely to wobble
  • shared behavior is genuinely shared, not “mostly similar”
  • the hierarchy is shallow and easy to reason about

Framework code sometimes uses inheritance well for clear lifecycle extension points. Domain models can also use it when the abstraction is honest and stable.

The key question is not “can I reuse code this way?” The key question is “does this subtype genuinely belong under this parent contract?”

When composition is safer

Composition is usually safer when:

  • behavior needs to vary by configuration or environment
  • the same workflow needs different combinations of policies
  • you want to swap implementations during tests
  • reuse is about collaboration, not identity
  • the hierarchy is starting to grow because of feature combinations

That is why composition pairs naturally with patterns like:

  • strategy
  • dependency injection
  • repository boundaries
  • decorator-style behavior wrapping

These patterns all benefit from keeping roles separate and replaceable.

Common mistakes

1. Using inheritance as the default reuse tool

This is one of the most common beginner habits. Reuse alone is not a strong enough reason for inheritance. Reuse can also come from helper objects, injected strategies, or small collaborators.

2. Thinking composition means “more classes, therefore worse design”

Composition can introduce more small pieces, but those pieces are often easier to change than one parent class overloaded with extension hooks.

3. Avoiding inheritance so aggressively that obvious hierarchies become awkward

Inheritance is not forbidden. If the subtype relationship is truly stable, using composition only to avoid the word “extends” can become performative rather than helpful.

4. Hiding inheritance problems behind abstract base classes

If the base class needs many protected hooks, flags, or “override this in subclass” escape hatches, the abstraction may already be too weak.

Quick checklist before choosing

Before you choose inheritance, ask:

  • is this really an “is-a” relationship or just shared behavior?
  • would a parent change risk surprising several children?
  • do I expect behavior combinations to grow over time?
  • would separate collaborators make testing easier?

If those questions point toward change, variation, or combinations, composition is usually the safer default.

FAQ

Q. Does “prefer composition over inheritance” mean never use inheritance?

No. It means inheritance deserves stricter judgment because it creates tighter coupling and stronger behavioral promises.

Q. Why does composition usually help testing?

Because tests can provide small fake collaborators instead of needing to construct or override an entire hierarchy.

Q. Is composition always simpler?

Not always at first glance. But in code that changes often, it is frequently simpler over time because variation stays local.

Start Here

Continue with the core guides that pull steady search traffic.

Sponsored