Aikido

Cómo favorecer la composición sobre la herencia para un código mantenible y flexible

Mantenibilidad

Regla
Priorizar la composición sobre la herencia
Las jerarquías de herencia profundas crean un acoplamiento fuerte
y dificultan la comprensión y el mantenimiento del código.

Lenguajes compatibles: 45+

Introducción

La herencia crea un acoplamiento fuerte entre las clases padre e hijo, lo que hace que el código sea frágil y difícil de modificar. Cuando una clase hereda comportamiento, se vuelve dependiente de los detalles de implementación de su clase padre. Las subclases que sobrescriben métodos pero aún llaman super son particularmente problemáticos, mezclando su propia lógica con comportamientos heredados de formas que se rompen cuando el padre cambia. La composición resuelve esto permitiendo que los objetos deleguen en otros objetos, creando un acoplamiento débil y una clara separación de responsabilidades.

Por qué es importante

Preocupaciones mixtas y acoplamiento fuerte: La herencia fuerza la inclusión de preocupaciones no relacionadas en la misma jerarquía de clases. Una clase de pago recurrente que hereda de un procesador de pagos mezcla la lógica de programación con el procesamiento de pagos. Cuando necesita llamar super.process() y luego añades tu propio comportamiento, estás fuertemente acoplado a la implementación del padre. Si el del padre process() el método cambia, la clase hija falla de formas inesperadas.

Heredar comportamientos no deseados: Las subclases heredan todo de sus clases padre, incluyendo métodos que no necesitan o que requieren implementaciones diferentes. Un pago recurrente hereda refund() lógica diseñada para pagos únicos, pero los reembolsos de suscripciones funcionan de manera diferente. O bien sobrescribe métodos y crea confusión, o convive con un comportamiento heredado inapropiado.

Problema de la clase base frágil: Los cambios en las clases padre se propagan a todas las subclases. Modificar cómo Pago con tarjeta de crédito procesa pagos afecta PagoRecurrenteConTarjetaDeCrédito aunque el cambio sea irrelevante para la planificación. Esto hace que la refactorización sea peligrosa porque no se puede predecir qué subclases se romperán.

Complejidad de las pruebas: Probar clases en lo profundo de una jerarquía de herencia requiere comprender el comportamiento de la clase padre. Para probar la programación de pagos recurrentes, también debe lidiar con la lógica de procesamiento de tarjetas de crédito, las llamadas a la API de Stripe y la validación. La composición le permite probar la programación con un objeto de pago simulado simple.

Ejemplos de código

❌ No conforme:

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
}

Por qué es incorrecto: PagoRecurrenteConTarjetaDeCrédito hereda la lógica de procesamiento de pagos, pero su verdadera preocupación es la programación, no los pagos. Debe llamar super.process() y envolverlo con un comportamiento de programación, creando un acoplamiento fuerte. La clase hereda refund() del principal, pero el reembolso de suscripciones requiere una lógica diferente a la de los pagos únicos. Cambios en Pago con tarjeta de crédito afectar PagoRecurrenteConTarjetaDeCrédito incluso cuando esos cambios son irrelevantes para la planificación.

✅ Conforme:

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(),
);

¿Por qué esto importa? PagoRecurrenteConTarjetaDeCrédito se centra únicamente en la programación y delega el procesamiento de pagos al compuesto Pago con tarjeta de crédito instancia. Sin herencia, no hay un acoplamiento estricto a la implementación de la clase padre. Los cambios en el procesamiento de tarjetas de crédito no afectan la lógica de planificación. La instancia de pago puede ser reemplazada por cualquier método de pago sin modificar el código de planificación.

Conclusión

Utilice la composición para separar responsabilidades en lugar de mezclarlas mediante herencia. Cuando una clase necesita la funcionalidad de otra clase, acéptela como una dependencia y delegue en ella en lugar de heredar. Esto crea un acoplamiento débil, facilita las pruebas y evita que los cambios en una clase rompan otra.

Preguntas frecuentes

¿Tiene preguntas?

¿Cuándo debo usar herencia vs. composición?

Utilice la herencia solo para relaciones 'es-un' verdaderas donde la subclase es genuinamente una versión especializada del padre. Un Cuadrado que extiende un Rectángulo tiene sentido si los cuadrados son rectángulos en su dominio. Utilice la composición para relaciones 'tiene-un' o 'usa-un'. Un pago recurrente utiliza un procesador de pagos, no es un tipo de procesador de pagos. En caso de duda, favorezca la composición.

¿Qué ocurre si necesito reutilizar código de múltiples fuentes?

La composición gestiona esto de forma natural a través de múltiples dependencias. Una clase puede componer un procesador de pagos, un planificador y un notificador sin enfrentarse a las restricciones de la herencia múltiple. La herencia te obliga a usar lenguajes de herencia única o jerarquías complejas de herencia múltiple. La composición es más clara: cada dependencia es explícita en el constructor.

¿Cómo refactorizo la herencia a composición?

Identifica lo que realmente hace la subclase frente a lo que hereda. En el ejemplo, RecurringCreditCardPayment programa pagos pero hereda la lógica de procesamiento. Extrae la funcionalidad del padre en una clase separada, luego pásala como una dependencia. Reemplaza extends Parent por un parámetro del constructor. Reemplaza las llamadas a super.method() por this.dependency.method(). Prueba cada paso.

¿La composición no genera más boilerplate?

La configuración inicial requiere dependencias explícitas, pero esta claridad es valiosa. Se ve exactamente lo que cada clase necesita sin tener que buscar en las jerarquías padre. Los frameworks modernos de inyección de dependencias reducen el código repetitivo. La explicitud previene errores derivados de comportamientos heredados implícitos. Unas pocas líneas adicionales de código de configuración valen la pena por la flexibilidad y mantenibilidad que ofrecen.

¿Qué pasa con las clases base abstractas y las interfaces?

Las interfaces son excelentes para definir contratos sin acoplar implementaciones. Utiliza interfaces para especificar qué comportamiento necesita una clase y luego inyecta implementaciones concretas. Las clases abstractas son solo herencia con algunos métodos no implementados, tienen los mismos problemas de acoplamiento. Prefiere interfaces con composición sobre clases abstractas con herencia.

¿Cómo gestiono los métodos de utilidad compartidos?

Extráelos en clases de utilidad o servicios separados. En lugar de heredar lógica de validación compartida, inyecta un servicio `Validator`. En el ejemplo, si tanto los pagos únicos como los recurrentes necesitan la misma validación, crea un `PaymentValidator` compartido que ambos puedan usar a través de la composición. Esto hace que la lógica compartida sea más descubrible y testeable que los métodos ocultos en clases padre.

Asegúrate ahora.

Proteja su código, la nube y el entorno de ejecución en un único sistema central.
Encuentre y corrija vulnerabilidades de forma rápida y automática.

No se requiere tarjeta de crédito | Resultados del escaneo en 32 segundos.