Aikido

How to favor composition over inheritance for maintainable and flexible code

Maintainability

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.

FAQs

Got Questions?

When should I use inheritance vs. composition?

Use inheritance only for true is-a relationships where the subclass is genuinely a specialized version of the parent. Square extending Rectangle makes sense if squares are rectangles in your domain. Use composition for has-a or uses-a relationships. A recurring payment uses a payment processor, it's not a type of payment processor. When in doubt, favor composition.

What if I need to reuse code from multiple sources?

Composition handles this naturally through multiple dependencies. A class can compose a payment processor, a scheduler, and a notifier without fighting multiple inheritance restrictions. Inheritance forces you into single-inheritance languages or complex multiple-inheritance hierarchies. Composition is clearer: each dependency is explicit in the constructor.

How do I refactor inheritance to composition?

Identify what the subclass actually does versus what it inherits. In the example, RecurringCreditCardPayment schedules payments but inherits processing logic. Extract the parent functionality into a separate class, then pass it as a dependency. Replace extends Parent with a constructor parameter. Replace super.method() calls with this.dependency.method(). Test each step.

Doesn't composition create more boilerplate?

Initial setup requires explicit dependencies, but this clarity is valuable. You see exactly what each class needs without hunting through parent hierarchies. Modern dependency injection frameworks reduce boilerplate. The explicitness prevents bugs from implicit inherited behavior. A few extra lines of setup code is worth the flexibility and maintainability.

What about abstract base classes and interfaces?

Interfaces are excellent for defining contracts without coupling implementations. Use interfaces to specify what behavior a class needs, then inject concrete implementations. Abstract classes are just inheritance with some unimplemented methods, they have the same coupling problems. Prefer interfaces with composition over abstract classes with inheritance.

How do I handle shared utility methods?

Extract them into separate utility classes or services. Instead of inheriting shared validation logic, inject a Validator service. In the example, if both one-time and recurring payments need the same validation, create a shared PaymentValidator that both can use through composition. This makes the shared logic more discoverable and testable than methods hidden in parent classes.

Get secure now

Secure your code, cloud, and runtime in one central system.
Find and fix vulnerabilities fast automatically.

No credit card required | Scan results in 32secs.