After five years building production systems, I've watched inheritance hierarchies grow so deep that changing a single base class method breaks deployments across three different services. Composition over inheritance is the pattern that fixed that. Instead of building tall class trees, I now compose small, focused behaviors together—and it's made my codebases far more resilient to change.
Inheritance couples tightly — Change the base class and every child is affected
When I started building the CRM platform at my last role, we modeled notifications like most do:
abstract class Notifier {
protected retries: number = 3;
abstract send(msg: string): Promise<void>;
async sendWithRetry(msg: string): Promise<void> {
let lastError: Error | null = null;
for (let i = 0; i < this.retries; i++) {
try {
await this.send(msg);
return;
} catch (e) {
lastError = e as Error;
}
}
throw lastError;
}
}
class EmailNotifier extends Notifier {
async send(msg: string): Promise<void> {
// SMTP logic
}
}
class SMSNotifier extends Notifier {
async send(msg: string): Promise<void> {
// Twilio API
}
}This looks clean initially. But then a requirement landed: SMS should not retry (carrier charges), only email should. Now I'm either:
- Adding a flag to the base class:
shouldRetry: boolean - Overriding
sendWithRetryin SMSNotifier to skip retries - Creating a new intermediate class:
RetryableNotifier extends Notifier
Each approach spreads the concept across the hierarchy. Later, when we need exponential backoff for API rate limits, we add another layer. Six months in, the tree looks like a bowl of spaghetti, and touching the base class terrifies everyone in code review.
The core issue: inheritance establishes a rigid contract. Every child inherits all behavior and properties, whether it needs them or not. If the base class changes, all descendants are affected. This is especially painful in long-lived systems where you don't control all the children (think framework extensions or plugin systems).
Composition is flexible — Combine small behaviors at runtime
The alternative is simpler: instead of asking "what type of notifier is this?", ask "what capabilities does this notifier need?" This shifts us from is-a thinking to has-a thinking.
In the travel booking platform, I redesigned notifications around small, composable strategies:
interface Channel {
send(msg: Message): Promise<void>;
}
class EmailChannel implements Channel {
async send(msg: Message): Promise<void> {
// SMTP logic, no retry logic here
}
}
class SMSChannel implements Channel {
async send(msg: Message): Promise<void> {
// Twilio API, no retry logic here
}
}
class Notifier {
constructor(private channels: Channel[]) {}
async send(msg: Message): Promise<void> {
return Promise.all(
this.channels.map(c => c.send(msg))
);
}
}
// Retry logic as a separate, optional decorator
class RetryableChannel implements Channel {
constructor(
private channel: Channel,
private maxRetries: number = 3
) {}
async send(msg: Message): Promise<void> {
let lastError: Error | null = null;
for (let i = 0; i < this.maxRetries; i++) {
try {
await this.channel.send(msg);
return;
} catch (e) {
lastError = e as Error;
}
}
throw lastError;
}
}Now the usage becomes declarative:
// Email with retries
const emailWithRetry = new RetryableChannel(
new EmailChannel(),
3
);
// SMS without retries
const sms = new SMSChannel();
// Combine them in one notifier
const notifier = new Notifier([emailWithRetry, sms]);
await notifier.send({
text: 'Your booking is confirmed',
recipient: 'user@example.com'
});Notice what changed:
- No base class changed. Adding retry logic didn't touch the notification core.
- Each channel owns its behavior. EmailChannel doesn't know about SMS constraints; SMSChannel doesn't know about retry strategy.
- Compose at the call site. The client decides which capabilities it needs, at runtime.
- Easy to test. Mock a Channel, pass it in, verify the output. No deep hierarchy to stub.
This is composition: you're combining small, single-responsibility objects to achieve complex behavior. The Notifier doesn't care what's inside the channels array—it just calls send() on each one.
Example — A Notifier composed of channels instead of EmailNotifier extends Notifier extends Base
Let me walk through a more realistic scenario from the e-commerce platform. We needed to:
- Send notifications to email, SMS, and push
- Apply retry logic only to email and push (not SMS)
- Rate-limit SMS to avoid carrier charges
- Log all attempts for audit
- Fail gracefully if one channel breaks
With inheritance, this becomes a matrix of permutations:
// Inheritance approach (what NOT to do)
class Notifier { }
class RetryableNotifier extends Notifier { }
class RateLimitedNotifier extends Notifier { }
class LoggingNotifier extends Notifier { }
class EmailNotifier extends RetryableNotifier { }
class SMSNotifier extends RateLimitedNotifier { }
class PushNotifier extends RetryableNotifier { }
// Now what if you need SMS + logging? EmailNotifier + rate limiting?
// You're stuck.With composition, each concern is a separate wrapper:
interface Channel {
send(msg: Message): Promise<void>;
}
class EmailChannel implements Channel {
async send(msg: Message): Promise<void> {
await sendViaSmtp(msg);
}
}
class SMSChannel implements Channel {
async send(msg: Message): Promise<void> {
await sendViaTwilio(msg);
}
}
class PushChannel implements Channel {
async send(msg: Message): Promise<void> {
await sendViaPushService(msg);
}
}
// Decorators for cross-cutting concerns
class LoggingChannel implements Channel {
constructor(private inner: Channel, private logger: Logger) {}
async send(msg: Message): Promise<void> {
this.logger.info(`Sending via ${this.inner.constructor.name}`, msg);
try {
await this.inner.send(msg);
this.logger.info(`Success`);
} catch (e) {
this.logger.error(`Failed: ${e.message}`);
throw e;
}
}
}
class RetryChannel implements Channel {
constructor(
private inner: Channel,
private maxRetries: number = 3,
private backoffMs: number = 100
) {}
async send(msg: Message): Promise<void> {
let lastError: Error | null = null;
for (let i = 0; i < this.maxRetries; i++) {
try {
await this.inner.send(msg);
return;
} catch (e) {
lastError = e as Error;
if (i < this.maxRetries - 1) {
await new Promise(r => setTimeout(r, this.backoffMs * (i + 1)));
}
}
}
throw lastError;
}
}
class RateLimitChannel implements Channel {
private queue: Message[] = [];
private processing = false;
constructor(
private inner: Channel,
private delayMs: number = 1000
) {}
async send(msg: Message): Promise<void> {
this.queue.push(msg);
if (!this.processing) {
await this.processQueue();
}
}
private async processQueue(): Promise<void> {
this.processing = true;
while (this.queue.length > 0) {
const msg = this.queue.shift();
if (msg) {
await this.inner.send(msg);
await new Promise(r => setTimeout(r, this.delayMs));
}
}
this.processing = false;
}
}
class Notifier {
constructor(private channels: Channel[]) {}
async send(msg: Message): Promise<void> {
const results = await Promise.allSettled(
this.channels.map(c => c.send(msg))
);
const failed = results.filter(r => r.status === 'rejected');
if (failed.length === results.length) {
throw new Error('All channels failed');
}
}
}Usage is now composable:
const logger = new ConsoleLogger();
// Email: retry + logging
const email = new LoggingChannel(
new RetryChannel(new EmailChannel(), 3),
logger
);
// SMS: rate limited + logging (no retry)
const sms = new LoggingChannel(
new RateLimitChannel(new SMSChannel(), 500),
logger
);
// Push: retry + logging
const push = new LoggingChannel(
new RetryChannel(new PushChannel(), 3),
logger
);
const notifier = new Notifier([email, sms, push]);
await notifier.send({ text: 'Order shipped', recipient: 'user@example.com' });Each concern is independent. If I need to add exponential backoff, I create a new decorator. If SMS needs logging but not rate limiting later, I remove the wrapper. No base class changes, no cascade of side effects.
Use inheritance sparingly — True is-a relationships and framework extension points
Composition isn't an absolute rule. There are legitimate cases for inheritance:
- True is-a relationships: A Dog is-a Animal. Inheritance models this naturally. But ask yourself: does every change to Animal need to affect Dog? If the answer is uncertain, compose instead.
- Framework extension points: If you're extending a third-party framework (Angular components, Express middleware), inheritance might be unavoidable. But keep your inheritance chains shallow—one level deep if possible.
- Polymorphism within a single team's codebase: If you own the base class and all its children, and they truly share implementation (not just interface), inheritance can work. But even then, consider if the children are really variants of the same thing, or just implementations of a behavior.
In the NestJS services I've built, I inherit from the framework's BaseController or BaseService when the framework requires it, but I keep that inheritance hidden behind a composition layer. The domain logic itself stays flat:
// Framework requirement
export class OrderController extends Controller {
constructor(private service: OrderService) {}
@Post()
async create(@Body() dto: CreateOrderDto) {
return this.service.create(dto);
}
}
// Domain logic is composed, not inherited
class OrderService {
constructor(
private repository: OrderRepository,
private notifier: Notifier,
private validator: OrderValidator
) {}
async create(dto: CreateOrderDto): Promise<Order> {
const order = await this.validator.validate(dto);
const saved = await this.repository.save(order);
await this.notifier.send({ text: `Order ${saved.id} created` });
return saved;
}
}The controller inherits (because NestJS asks for it), but the service is pure composition. That separation matters.
Common mistakes
- Over-composing: Not every layer needs to be wrappable. If you have 12 decorators stacked, you've gone too far. Aim for 2-3 layers of composition; the rest can live in the core class.
- Composing before you need it: Don't add flexibility you won't use. Build a concrete implementation first, refactor to composition when the second or third variant appears.
- Forgetting the interface: Composition only works if all pieces implement a common contract. That interface doesn't have to be explicit (duck typing works in dynamically typed languages), but it should be clear and minimal.
- Mixing composition and inheritance: It works, but it's confusing. If you're using composition, keep inheritance out. If you're extending a framework class, keep composition in the business logic layer, not across the inheritance hierarchy.
- Making the Notifier too smart: In my original example, Notifier just iterates channels. It doesn't decide which to use, retry, or log. That decision-making lives in the decorators. Keep the core object simple.
Key takeaways
- Inheritance creates tight coupling; changes to a base class cascade through the entire tree. Composition lets each piece change independently.
- Model behavior as a composition of small, focused objects that implement a common interface, rather than as variants of a base class.
- Decorators and wrapper patterns are your friends. They let you add functionality (retry, logging, rate limiting) without modifying the original class.
- Use inheritance only for true is-a relationships, unavoidable framework extension points, or shallow hierarchies where all children share significant implementation.
- Compose at the call site. Let the client decide which capabilities it needs, at runtime, rather than baking them into the class hierarchy.






Comments
Loading comments…