Por qué “tiene-un” suele ser más estable que “es-un”
Cuando modelas con herencia (“es-un”), el acoplamiento entre clases suele crecer: cambios en la clase base se propagan a toda la jerarquía, aparecen métodos heredados que no aplican a todos los hijos y se vuelve difícil combinar comportamientos sin crear más subclases. La composición (“tiene-un”) evita jerarquías frágiles: en lugar de heredar comportamiento, un objeto usa otros objetos para cumplir su responsabilidad.
Idea central: si una clase necesita una capacidad (enviar, calcular, persistir, validar), en muchos casos es mejor tener un colaborador que la provea, en vez de ser una variante dentro de una jerarquía.
Composición vs agregación: dependencia, ciclo de vida y ownership
Ambas relaciones son “tiene-un”, pero difieren en quién controla el ciclo de vida del objeto contenido y qué tan “propio” es.
| Relación | Qué significa | Ownership | Ciclo de vida | Señales en código |
|---|---|---|---|---|
| Composición | El objeto “parte” es esencial para el “todo”. | El “todo” es dueño del “parte”. | El “parte” se crea y destruye con el “todo”. | El “todo” instancia internamente o recibe pero asume control; no se comparte normalmente. |
| Agregación | El “todo” usa un objeto que puede existir por sí mismo. | El “todo” no es dueño; solo referencia. | El “parte” puede vivir antes/después y compartirse. | Se inyecta desde fuera; puede ser compartido por varios “todos”. |
Ejemplo rápido: composición
Un Vehiculo “tiene” un Motor que forma parte del vehículo. Si el vehículo se destruye, ese motor (en ese modelo) también.
Ejemplo rápido: agregación
Un Equipo “tiene” Jugadores, pero los jugadores existen independientemente (pueden cambiar de equipo). El equipo agrega referencias a jugadores; no “posee” su existencia.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
Cómo se expresa en código (Java y C#)
Composición: el “todo” controla el ciclo de vida
Java
class Motor { private final int cilindrada; Motor(int cilindrada) { this.cilindrada = cilindrada; } int getCilindrada() { return cilindrada; }}class Vehiculo { private final Motor motor; Vehiculo(int cilindrada) { // Composición: Vehiculo crea y posee el Motor this.motor = new Motor(cilindrada); } void arrancar() { System.out.println("Arrancando con motor " + motor.getCilindrada()); }}C#
public class Motor { public int Cilindrada { get; } public Motor(int cilindrada) => Cilindrada = cilindrada; }public class Vehiculo { private readonly Motor _motor; public Vehiculo(int cilindrada) { // Composición: Vehiculo crea y posee el Motor _motor = new Motor(cilindrada); } public void Arrancar() => Console.WriteLine($"Arrancando con motor {_motor.Cilindrada}"); }Agregación: el “todo” recibe una referencia externa
Java
class Equipo { private final List<Jugador> jugadores; Equipo(List<Jugador> jugadores) { // Agregación: Equipo usa jugadores que existen fuera this.jugadores = jugadores; }}C#
public class Equipo { private readonly IReadOnlyList<Jugador> _jugadores; public Equipo(IReadOnlyList<Jugador> jugadores) => _jugadores = jugadores; }Nota práctica: en sistemas reales, la diferencia suele reflejarse en quién crea el objeto, quién lo reemplaza y si se comparte entre varios consumidores.
Patrones comunes basados en composición
1) Delegación: “no lo hago yo, lo hace mi colaborador”
Delegar significa que tu clase expone una operación, pero internamente la realiza un objeto colaborador. Esto reduce tamaño de clases y permite cambiar la implementación del colaborador sin tocar al consumidor.
// Idea: Pedido delega el cálculo de precio a una estrategia o servicio de precios2) Objetos valor (Value Objects): datos con reglas, sin identidad
Un objeto valor encapsula un concepto del dominio (por ejemplo Dinero, Email, RangoFechas) y se compone dentro de entidades/servicios. Beneficio: reglas locales, menos primitivas sueltas y menos duplicación.
Java
record Dinero(String moneda, long centavos) { Dinero { if (centavos < 0) throw new IllegalArgumentException("No negativo"); if (moneda == null || moneda.isBlank()) throw new IllegalArgumentException("Moneda requerida"); } Dinero sumar(Dinero otro) { if (!moneda.equals(otro.moneda)) throw new IllegalArgumentException("Moneda distinta"); return new Dinero(moneda, centavos + otro.centavos); }}C#
public readonly record struct Dinero(string Moneda, long Centavos) { public Dinero { if (Centavos < 0) throw new ArgumentException("No negativo"); if (string.IsNullOrWhiteSpace(Moneda)) throw new ArgumentException("Moneda requerida"); } public Dinero Sumar(Dinero otro) { if (Moneda != otro.Moneda) throw new ArgumentException("Moneda distinta"); return new Dinero(Moneda, Centavos + otro.Centavos); } }3) Estrategias configurables: variar comportamiento sin heredar
En vez de crear subclases para cada variante, compones una interfaz (contrato) y pasas una implementación. Cambiar la estrategia no requiere tocar la clase principal.
Criterios para elegir “tiene-un” vs “es-un”
- Usa composición cuando quieras combinar capacidades, cambiar implementaciones, o evitar que una clase herede métodos/estado que no siempre aplican.
- Usa herencia solo cuando exista una relación “es-un” fuerte y estable, y cuando la clase base sea realmente una generalización segura para todas las derivadas.
- Si anticipas variaciones (nuevos canales, nuevas reglas, nuevos algoritmos), composición + estrategia suele escalar mejor que una jerarquía creciente.
- Si el ciclo de vida es compartido (un recurso usado por muchos), tiende a agregación (inyección). Si es parte inseparable del objeto, tiende a composición (creación interna o ownership claro).
- Si necesitas probar fácil, la agregación (inyectar dependencias) permite reemplazar colaboradores por dobles de prueba.
Caso práctico: Notificador que usa un CanalDeEnvio (composición para reducir impacto de cambios)
Objetivo: enviar notificaciones por distintos canales (Email, SMS, Push) sin crear una jerarquía rígida del tipo NotificadorEmail, NotificadorSms, etc. En su lugar, Notificador compone un CanalDeEnvio y delega el envío.
Paso 1: define el contrato del canal
Java
interface CanalDeEnvio { void enviar(String destino, String mensaje); }C#
public interface ICanalDeEnvio { void Enviar(string destino, string mensaje); }Paso 2: implementa canales concretos
Java
class CanalEmail implements CanalDeEnvio { @Override public void enviar(String destino, String mensaje) { System.out.println("EMAIL a " + destino + ": " + mensaje); } }class CanalSms implements CanalDeEnvio { @Override public void enviar(String destino, String mensaje) { System.out.println("SMS a " + destino + ": " + mensaje); } }C#
public class CanalEmail : ICanalDeEnvio { public void Enviar(string destino, string mensaje) => Console.WriteLine($"EMAIL a {destino}: {mensaje}"); }public class CanalSms : ICanalDeEnvio { public void Enviar(string destino, string mensaje) => Console.WriteLine($"SMS a {destino}: {mensaje}"); }Paso 3: compón el canal dentro de Notificador y delega
Aquí la relación suele ser agregación (el canal se inyecta y puede compartirse), aunque conceptualmente sigue siendo “tiene-un”.
Java
class Notificador { private final CanalDeEnvio canal; Notificador(CanalDeEnvio canal) { this.canal = canal; } void notificar(String destino, String mensaje) { canal.enviar(destino, mensaje); // delegación } }C#
public class Notificador { private readonly ICanalDeEnvio _canal; public Notificador(ICanalDeEnvio canal) => _canal = canal; public void Notificar(string destino, string mensaje) => _canal.Enviar(destino, mensaje); }Paso 4: usa el sistema y cambia comportamiento sin tocar Notificador
Java
Notificador n1 = new Notificador(new CanalEmail());n1.notificar("ana@correo.com", "Tu pedido fue enviado");Notificador n2 = new Notificador(new CanalSms());n2.notificar("+34123456789", "Código: 1234");C#
var n1 = new Notificador(new CanalEmail());n1.Notificar("ana@correo.com", "Tu pedido fue enviado");var n2 = new Notificador(new CanalSms());n2.Notificar("+34123456789", "Código: 1234");Paso 5: demuestra el impacto ante cambios (comparación práctica)
Escenario de cambio: ahora necesitas un canal Push y además quieres registrar métricas por cada envío.
- Con herencia (p. ej.,
NotificadorBase+ subclases por canal), sueles tocar la base o replicar lógica en cada subclase (métricas, reintentos, formato), aumentando duplicación y riesgo. - Con composición, agregas una nueva clase
CanalPushy, si quieres métricas, puedes envolver el canal con otro objeto que delega (decoración por composición) sin modificarNotificador.
Java: canal con métricas por composición (delegación)
class CanalConMetricas implements CanalDeEnvio { private final CanalDeEnvio inner; CanalConMetricas(CanalDeEnvio inner) { this.inner = inner; } @Override public void enviar(String destino, String mensaje) { long inicio = System.currentTimeMillis(); inner.enviar(destino, mensaje); long ms = System.currentTimeMillis() - inicio; System.out.println("Métrica: envío en " + ms + "ms"); } }Notificador n = new Notificador(new CanalConMetricas(new CanalEmail()));n.notificar("ana@correo.com", "Aviso importante");C#: canal con métricas por composición (delegación)
public class CanalConMetricas : ICanalDeEnvio { private readonly ICanalDeEnvio _inner; public CanalConMetricas(ICanalDeEnvio inner) => _inner = inner; public void Enviar(string destino, string mensaje) { var inicio = DateTime.UtcNow; _inner.Enviar(destino, mensaje); var ms = (DateTime.UtcNow - inicio).TotalMilliseconds; Console.WriteLine($"Métrica: envío en {ms}ms"); } }var n = new Notificador(new CanalConMetricas(new CanalEmail()));n.Notificar("ana@correo.com", "Aviso importante");Observa el efecto: el cambio (métricas) se implementa como un nuevo objeto componible. No hay que editar Notificador, ni duplicar lógica en múltiples subclases, ni reestructurar una jerarquía.
Guía práctica paso a paso para diseñar con composición
Paso A: identifica lo que varía
- ¿Qué parte del comportamiento cambia con frecuencia? (canal, algoritmo, proveedor externo, formato, política).
- Esas variaciones suelen convertirse en colaboradores componibles.
Paso B: extrae un contrato pequeño
- Define una interfaz con 1–3 métodos centrados en una sola responsabilidad (por ejemplo
enviar()). - Evita contratos “gigantes” que obliguen a implementaciones a soportar cosas que no usan.
Paso C: inyecta el colaborador (agregación) o créalo internamente (composición)
- Si quieres intercambiar implementaciones, probar fácil o compartir instancia: inyecta (agregación).
- Si es una parte inseparable y privada del objeto: crea dentro (composición).
Paso D: delega y mantén el “orquestador” delgado
- La clase principal coordina, valida flujo y delega el trabajo específico.
- Si empieza a crecer, suele indicar que necesitas más colaboradores (p. ej., formateador, plantilla, política de reintentos).
Paso E: compón comportamientos transversales con envoltorios
- Métricas, logging, reintentos, caché o validaciones pueden implementarse como objetos que envuelven a otros y delegan.
- Esto permite añadir o quitar capacidades sin modificar clases existentes.