Composición y agregación: construir objetos sin jerarquías frágiles

Capítulo 4

Tiempo estimado de lectura: 9 minutos

+ Ejercicio

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ónQué significaOwnershipCiclo de vidaSeñales en código
ComposiciónEl 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ónEl “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.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

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 precios

2) 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 CanalPush y, si quieres métricas, puedes envolver el canal con otro objeto que delega (decoración por composición) sin modificar Notificador.

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.

Ahora responde el ejercicio sobre el contenido:

En un diseño donde una clase necesita variar el modo de envío de notificaciones (Email, SMS, Push) y además agregar métricas sin modificar la clase principal, ¿qué enfoque se ajusta mejor a lo descrito?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

La composición permite variar el comportamiento inyectando un canal (agregación) y añadir capacidades transversales como métricas mediante un envoltorio que delega, evitando modificar la clase principal y evitando jerarquías frágiles.

Siguiente capítulo

Herencia con criterios: especialización real, reglas y límites

Arrow Right Icon
Portada de libro electrónico gratuitaPOO práctica y clara: clases, encapsulación, herencia y polimorfismo (con ejemplos en Java y C#)
40%

POO práctica y clara: clases, encapsulación, herencia y polimorfismo (con ejemplos en Java y C#)

Nuevo curso

10 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.