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:
AnimalDog 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.
Read Next
- For the bigger mental model, continue with the Object-Oriented Programming Guide.
- For subtype safety, read the LSP Guide.
- For dependency wiring, visit the Dependency Injection Guide.
Related Posts
Start Here
Continue with the core guides that pull steady search traffic.
- Middleware Troubleshooting Guide: Where to Start With Redis, RabbitMQ, or Kafka A practical middleware troubleshooting hub covering how to choose the right first branch when systems using Redis, RabbitMQ, and Kafka show cache drift, queue backlog, or consumer lag.
- Kubernetes CrashLoopBackOff: What to Check First A practical Kubernetes CrashLoopBackOff troubleshooting guide covering startup failures, probe issues, config mistakes, and what to inspect first.
- Technical Blog SEO Checklist for Astro: What to Fix Before You Wait for Traffic A practical Astro SEO checklist for technical blogs covering deployed-site checks, robots.txt, sitemap, canonical, hreflang, structured data, page-role metadata, noindex decisions, and verification commands.
- Canonical and hreflang Setup for Multilingual Blogs: What to Check and What Breaks A practical guide to canonical and hreflang setup for multilingual blogs, covering self-canonicals, reciprocal hreflang clusters, x-default, category pages, rendered HTML checks, and the mistakes that make one language version suppress another.
- OpenAI Codex CLI Setup Guide: Install, Auth, and Your First Task A practical OpenAI Codex CLI setup guide covering installation, sign-in, the first interactive run, Windows notes, and the safest workflow for your first real task.