Entendiendo composición vs. herencia en Angular

⚡ ¿Usas Angular moderno (v17+)? Salta directamente a Solución 1: Funciones componibles con inject() para ver cómo la composición moderna elimina el código repetitivo del constructor con super().
Al construir aplicaciones empresariales en Angular, es muy común encontrar lógica duplicada en varios componentes: manejo del envío de formularios, visualización de errores, eventos de analítica o apertura de modales.
Como desarrolladores, nuestro primer instinto suele ser recurrir a la herencia de clases (extends BaseComponent).
Al principio, la herencia parece una solución rápida. Sin embargo, a medida que el proyecto crece, terminas cayendo en el "Constructor Hell": cada componente hijo se ve obligado a inyectar y pasar cinco o seis servicios solo para satisfacer la llamada a super().
En esta guía, te mostraré por qué la herencia falla en Angular y cómo Angular moderno nos ofrece dos alternativas superiores:
- Utilidades componibles con
inject()y Signals - La Directive Composition API (
hostDirectives)
Empecemos analizando la clásica trampa de la herencia.
La trampa de la herencia: duplicación de lógica en formularios 🛑
Imagina que estamos construyendo dos formularios distintos: un componente para la suscripción al newsletter y otro para una lista de espera beta.
Ambos componentes comparten el mismo comportamiento:
- Un único campo de correo electrónico.
- Validación de campo requerido y formato de correo.
- Extracción de estados de error.
- Un método para guardar o enviar los datos.
El enfoque ingenuo con herencia (extends BaseFormComponent)
Para no repetir la validación ni el manejo de errores, solemos crear una clase base:
// ⚠️ base-form.component.ts
import { Directive } from '@angular/core';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
@Directive()
export abstract class BaseFormComponent {
errors: string[] = [];
abstract form: FormGroup;
constructor(
protected fb: FormBuilder,
protected logger: LoggerService,
protected analytics: AnalyticsService
) {}
save() {
if (this.form.invalid) {
this.extractErrors();
} else {
this.errors = [];
this.submitData();
}
}
protected abstract submitData(): void;
private extractErrors() {
const emailErrors = this.form.get('email')?.errors;
this.errors = emailErrors ? Object.keys(emailErrors) : [];
}
}Ahora veamos qué pasa cuando el componente NewsletterComponent hereda de esta clase base:
// ⚠️ newsletter.component.ts
@Component({
selector: 'app-newsletter',
templateUrl: './newsletter.component.html'
})
export class NewsletterComponent extends BaseFormComponent {
form = this.fb.group({
email: ['', [Validators.required, Validators.email]]
});
constructor(
fb: FormBuilder,
logger: LoggerService,
analytics: AnalyticsService,
private newsletterService: NewsletterService // Dependencia propia del componente hijo
) {
super(fb, logger, analytics); // 😫 Código repetitivo en el constructor!
}
protected submitData() {
this.newsletterService.subscribe(this.form.value.email);
}
}Por qué esto se convierte en una pesadilla de mantenimiento:
- Constructor Hell: Si
BaseFormComponentnecesita un nuevo servicio (por ejemplo,NotificationService), debes actualizar absolutamente todos los componentes hijos de tu aplicación para pasar esa dependencia asuper(). - No hay herencia de plantillas ni estilos: Los componentes de Angular NO heredan las plantillas HTML ni los estilos CSS de las clases padre.
- Jerarquía rígida: En TypeScript no existe la herencia múltiple, por lo que no puedes extender más de una clase base.
Veamos ahora las soluciones modernas y desacopladas.
Solución 1: Funciones componibles con inject() 🧩
En Angular moderno, podemos usar la función inject() en inicializadores directos para extraer comportamientos reusables sin recurrir a la herencia de clases:
// use-email-form.ts
import { inject } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
export function createEmailForm(onSubmit: (email: string) => void) {
const fb = inject(FormBuilder);
const analytics = inject(AnalyticsService);
const form = fb.group({
email: ['', [Validators.required, Validators.email]]
});
const handleSubmit = () => {
if (form.valid) {
analytics.track('form_submitted');
onSubmit(form.value.email!);
}
};
return { form, handleSubmit };
}En el componente:
@Component({
selector: 'app-newsletter',
template: `
<form (ngSubmit)="emailForm.handleSubmit()">
<input type="email" [formControl]="emailForm.form.controls.email" />
<button type="submit">Subscribe</button>
</form>
`
})
export class NewsletterComponent {
private newsletterService = inject(NewsletterService);
emailForm = createEmailForm((email) => {
this.newsletterService.subscribe(email);
});
}Cero argumentos en el constructor, cero llamadas a super() y una lógica completamente componible en cualquier componente.
Ahora revisemos una herramienta clave para componer comportamientos en la interfaz de usuario: la Directive Composition API.
Solución 2: La Directive Composition API (hostDirectives) 🚀
¿Qué pasa cuando necesitas reutilizar comportamientos visuales o del DOM (como atajos de teclado, tooltips, estados de validación o efectos visuales) en varios componentes sin usar herencia?
Para estos casos, Angular ofrece hostDirectives:
// 1. Definimos una directiva standalone con el comportamiento
@Directive({
selector: '[appFormValidator]',
standalone: true
})
export class FormValidatorDirective {
// Encapsula estilos de validación y mensajes de error
}
// 2. La componemos directamente en el host del componente
@Component({
selector: 'app-waiting-list',
standalone: true,
templateUrl: './waiting-list.component.html',
hostDirectives: [
FormValidatorDirective,
{
directive: AnalyticsTrackingDirective,
inputs: ['eventName: trackingEvent'] // Exponemos los inputs de la directiva
}
]
})
export class WaitingListComponent {
// Hereda todo el comportamiento del DOM de FormValidatorDirective y AnalyticsTrackingDirective
}Ventajas clave de hostDirectives:
- Composición múltiple: Un componente puede componer cinco directivas diferentes en su elemento host.
- Contratos de API explícitos: Puedes renombrar o exponer propiedades específicas de
@Input()y@Output()de la directiva host. - Aisladas y con soporte para tree-shaking: No hay estado mutable compartido entre clases.
Comparación: herencia vs. composición 📊
| Criterio | Herencia de clases (extends) | Composición con directivas (hostDirectives e inject()) |
|---|---|---|
| Inyección de dependencias | Frágil paso de argumentos con super(a, b, c) en el constructor | Llamadas limpias y locales con inject() |
| Múltiples comportamientos | ❌ Solo herencia simple | ✅ Permite componer múltiples directivas y utilidades |
| Seguridad al refactorizar | Baja (cambiar la clase base rompe todos los hijos) | Alta (comportamientos aislados y modulares) |
| Comportamiento en plantilla / DOM | ❌ No se heredan plantillas ni elementos host | ✅ Vinculación directa al elemento host del DOM |
Resumen 🛠️
En el desarrollo moderno con Angular:
- Prioriza la composición sobre la herencia. Considera la herencia solo como último recurso.
- Usa funciones auxiliares componibles con
inject()para compartir lógica de negocio, configuración de formularios y flujos de estado. - Aprovecha la Directive Composition API (
hostDirectives) para asociar comportamientos interactivos directamente a tus componentes.
Si quieres conocer más patrones de arquitectura moderna en Angular, revisa mis artículos sobre preguntas esenciales de Angular para entrevistas y cómo combinar async pipes en Angular.
These are my experiences learning and facing my daily challenges working with Angular.
Preguntas Frecuentes
¿Por qué la herencia genera problemas en los componentes de Angular?
La herencia crea un acoplamiento fuerte y 'constructor hell' (pasar múltiples dependencias inyectadas a super()), dificulta el refactorizado y no hereda el marcado de las plantillas ni los estilos CSS.
¿Qué es la Directive Composition API (hostDirectives) en Angular?
La Directive Composition API permite que componentes y directivas apliquen otras directivas a su elemento host de forma programática mediante la propiedad hostDirectives en el decorador @Component o @Directive, sin herencia.
¿Cómo mejora la función inject() la composición frente a la herencia?
La función inject() permite crear funciones auxiliares reusables y componibles que obtienen sus propias dependencias del contexto de inyección activo, sin obligar a las clases hijas a pasar parámetros mediante el constructor.
¿Es posible componer estado usando Signals en lugar de clases base en Angular?
Sí, las funciones componibles y los servicios basados en Signals proporcionan un estado reactivo modular (signals, computeds y effects) que cualquier componente Standalone puede consumir mediante inject() sin jerarquías de clases.
Artículos Relacionados
Preguntas esenciales de entrevista en Angular para desarrolladores Junior y Mid-Level
Una guía práctica de preguntas de entrevista sobre Angular moderno para roles junior y mid-level, cubriendo Signals, Standalone APIs, Control Flow, Vitest y Zoneless Angular.
Cómo combinar Async Pipes en Angular: De combineLatest a Signals y @let
Aprende cómo evitar múltiples async pipes y suscripciones duplicadas en plantillas de Angular usando combineLatest, Signals modernas (toSignal) y la sintaxis @let.
Cómo compartir datos entre componentes en Angular (Signals y servicios)
Aprende los mejores patrones para compartir datos entre componentes en Angular. Compara los decoradores clásicos Input/Output y BehaviorSubjects con las Signals modernas: input(), output(), model() y servicios basados en Signals.
Cómo empezar con Standalone Components en Angular (Guía moderna completa)
Aprende a crear aplicaciones en Angular sin módulos usando Standalone Components. Revisa cómo usar bootstrapApplication, app.config.ts, lazy loading con loadComponent y pruebas unitarias con Vitest.
Compartir este artículo
Si esta guía te resultó útil, compártela con tu equipo o con otros desarrolladores.
Software real. Lecciones reales.
Comparto las lecciones que aprendí por las malas, para que puedas evitarlas o estar preparado cuando ocurran.
Únete a 13,800+ desarrolladores y lectores.
Cero spam. Cancela tu suscripción en cualquier momento.