Rule
Favor composition over inheritance
Deep inheritance hierarchies create tight coupling
and make code harder to understand and maintain.
Supported languages: 45+Introduction
Inheritance creates tight coupling between parent and child classes, making code fragile and hard to change. When a class inherits behavior, it becomes dependent on its parent's implementation details. Subclasses that override methods but still call super are particularly problematic, mixing their own logic with inherited behavior in ways that break when the parent changes. Composition solves this by letting objects delegate to other objects, creating loose coupling and clear separation of concerns.
Why it matters
Mixed concerns and tight coupling: Inheritance forces unrelated concerns into the same class hierarchy. A recurring payment class that inherits from a payment processor mixes scheduling logic with payment processing. When you need to call super.process() and then add your own behavior, you're tightly coupled to the parent's implementation. If the parent's process() method changes, the child class breaks in unexpected ways.
Inheriting unwanted behavior: Subclasses inherit everything from their parents, including methods they don't need or that need different implementations. A recurring payment inherits refund() logic designed for one-time payments, but subscription refunds work differently. You either override methods and create confusion, or live with inappropriate inherited behavior.
Fragile base class problem: Changes to parent classes ripple through all subclasses. Modifying how CreditCardPayment processes payments affects RecurringCreditCardPayment even though the change is irrelevant to scheduling. This makes refactoring dangerous because you can't predict which subclasses will break.
Testing complexity: Testing classes deep in an inheritance hierarchy requires understanding parent class behavior. To test recurring payment scheduling, you must also deal with credit card processing logic, Stripe API calls, and validation. Composition lets you test scheduling with a simple mock payment object.
Code examples
❌ Non-compliant:
class Payment {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
async process() {
throw new Error('Must implement in subclass');
}
async refund() {
throw new Error('Must implement in subclass');
}
async sendReceipt(email) {
// All paymet types need receipts
await emailService.send(email, this.buildReceipt());
}
}
class CreditCardPayment extends Payment {
constructor(amount, currency, cardToken, billingAddress) {
super(amount, currency);
this.cardToken = cardToken;
this.billingAddress = billingAddress;
}
async process() {
await this.validateCard();
return await stripe.charges.create({
amount: this.amount * 100,
source: this.cardToken,
currency: this.currency
});
}
async refund() {
await this.validateRefund();
return await stripe.refunds.create({ charge: this.chargeId });
}
async validateCard() {
// Card validation logic
}
}
// Problem: RecurringCreditCardPayment's main concern is dealing with scheduling
// and not the actual payment
class RecurringCreditCardPayment extends CreditCardPayment {
constructor(amount, currency, cardToken, billingAddress, schedule) {
super(amount, currency, cardToken, billingAddress);
this.schedule = schedule;
}
async process() {
// Problem: Need to override parent's process() but also use it
await super.process();
await this.scheduleNextPayment();
}
async scheduleNextPayment() {
// Subscription scheduling
}
// Problem: Inherits refund() from parent but refunding
// subscriptions needs different logic
}Why it's wrong: RecurringCreditCardPayment inherits payment processing logic but its real concern is scheduling, not payments. It must call super.process() and wrap it with scheduling behavior, creating tight coupling. The class inherits refund() from the parent but refunding subscriptions needs different logic than one-time payments. Changes to CreditCardPayment affect RecurringCreditCardPayment even when those changes are irrelevant to scheduling.
✅ Compliant:
class CreditCardPayment extends Payment {
constructor(amount, currency, cardToken, billingAddress) {
super(amount, currency);
this.cardToken = cardToken;
this.billingAddress = billingAddress;
}
async process() {
await this.validateCard();
return await stripe.charges.create({
amount: this.amount * 100,
source: this.cardToken,
currency: this.currency
});
}
async refund() {
await this.validateRefund();
return await stripe.refunds.create({ charge: this.chargeId });
}
async validateCard() {
// Card validation logic
}
}
class RecurringCreditCardPayment {
constructor(creditCardPayment, schedule) {
this.creditCardPayment = creditCardPayment;
this.schedule = schedule;
}
async scheduleNextPayment() {
this.schedule.onNextCyle(() => {
await this.creditCardPayment.process();
})
}
}
const recurringCreditCardPayment = new RecurringCreditCardPayment(
new CreditCardPayment(),
new Schedule(),
);Why this matters: RecurringCreditCardPayment focuses solely on scheduling and delegates payment processing to the composed CreditCardPayment instance. No inheritance means no tight coupling to parent class implementation. Changes to credit card processing don't affect scheduling logic. The payment instance can be replaced with any payment method without changing the scheduling code.
Conclusion
Use composition to separate concerns instead of mixing them through inheritance. When a class needs another class's functionality, accept it as a dependency and delegate to it rather than inheriting from it. This creates loose coupling, makes testing easier, and prevents changes in one class from breaking another.
.avif)
