Diseño simple y robusto: acoplamiento, cohesión y principios aplicables

Capítulo 8

Tiempo estimado de lectura: 11 minutos

+ Ejercicio

Acoplamiento y cohesión: criterios operables (no es teoría)

Cuando un módulo crece, el problema rara vez es “falta de POO”, sino decisiones que vuelven el cambio caro. Dos métricas mentales ayudan a decidir rápido: acoplamiento (cuánto depende una pieza de otras) y cohesión (qué tan enfocada está una pieza en una sola razón de cambio).

Bajo acoplamiento: qué significa en el día a día

  • Dependes de menos cosas: menos imports/usings, menos parámetros “de todo”, menos acceso a objetos globales.
  • Dependes de cosas más estables: contratos (interfaces) y tipos simples, no detalles concretos.
  • Las dependencias apuntan “hacia adentro”: el dominio/reglas no conocen infraestructura (DB, HTTP, archivos).

Señales de acoplamiento alto (útiles en revisión de código):

  • Una clase necesita instanciar muchas dependencias con new (o crea clientes HTTP/DB dentro).
  • Un cambio en un detalle (p. ej., formato de fecha, proveedor de pago) obliga a tocar muchas clases.
  • Se pasan objetos “gigantes” solo para usar 1–2 campos (acoplamiento a estructura).
  • Uso frecuente de instanceof/is para decidir comportamiento (acoplamiento a tipos concretos).

Alta cohesión: cómo se ve en el código

Una clase cohesionada tiene un propósito claro y un conjunto pequeño de operaciones que trabajan sobre los mismos datos/invariantes. No es “clase pequeña” por sí misma; es “clase enfocada”.

Señales de cohesión baja:

  • Métodos que no comparten datos entre sí (cada método usa cosas distintas).
  • Nombres tipo Manager, Helper, Utils con responsabilidades mezcladas.
  • La clase cambia por motivos no relacionados (reglas de negocio + logging + persistencia + formato).

Dependencias dirigidas a abstracciones (sin repetir teoría)

Operacionalmente, “depender de abstracciones” significa: cuando una clase necesita colaborar con otra, su constructor recibe un contrato mínimo (interfaz) que expresa lo que necesita, no el “cómo” se implementa. Esto reduce el radio de cambio y permite sustituir implementaciones (tests, proveedores, versiones).

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

Regla práctica: interfaz mínima por consumidor

Si una clase usa solo send(), no debería depender de una interfaz que también expone connect(), reconnect(), getLastError(). Define un contrato pequeño y estable.

Principios aplicables con enfoque operacional (SRP, OCP, ISP, DIP)

SRP (Single Responsibility): una razón de cambio verificable

Pregunta útil: “¿Qué cambios futuros podrían romper esta clase?” Si la lista tiene temas distintos, probablemente mezcla responsabilidades.

Señales de violación:

  • La clase tiene métodos de reglas + IO (DB/HTTP/archivo) + formateo.
  • Los tests requieren mocks de demasiadas cosas para probar una regla simple.

Refactorizaciones típicas:

  • Extraer colaborador: mover IO a un puerto (interfaz) y dejar reglas en una clase pura.
  • Separar “orquestación” (flujo) de “cálculo” (reglas).

OCP (Open/Closed): extender sin editar el núcleo

En la práctica: cuando agregas un caso nuevo (p. ej., nuevo tipo de descuento), no deberías editar un switch central cada vez. El núcleo define un punto de extensión (contrato) y el nuevo caso se agrega como nueva clase.

Señales de violación:

  • Un método crece con if/else o switch por “tipo”.
  • Cada feature nueva toca el mismo archivo “central”.

Refactorizaciones típicas:

  • Reemplazar condicional por estrategia (lista de reglas).
  • Registrar implementaciones (por configuración o composición en el “composition root”).

ISP (Interface Segregation): contratos pequeños, dependencias pequeñas

En la práctica: si para implementar una interfaz debes dejar métodos vacíos o lanzar excepciones “no soportado”, la interfaz está mal segmentada.

Señales de violación:

  • Implementaciones que no usan varios métodos del contrato.
  • Mocks enormes para tests porque la interfaz pide demasiado.

Refactorizaciones típicas:

  • Partir una interfaz grande en varias enfocadas por caso de uso.
  • Hacer que cada consumidor dependa solo de la interfaz que necesita.

DIP (Dependency Inversion): reglas arriba, detalles abajo

En la práctica: el código de reglas (dominio/casos de uso) no importa paquetes de infraestructura. La infraestructura implementa contratos definidos por el núcleo.

Señales de violación:

  • El caso de uso conoce SqlConnection, HttpClient, SDKs de terceros.
  • Para testear reglas necesitas levantar DB, red o filesystem.

Refactorizaciones típicas:

  • Introducir puertos: OrderRepository, PaymentGateway, EmailSender.
  • Inyectar dependencias por constructor y mover new al borde (arranque).

Reglas prácticas para mantener clases pequeñas (sin microclases inútiles)

  • Una clase, un “verbo” principal: si el nombre necesita “y” (p. ej., “calcula y guarda y notifica”), separa.
  • Máximo 3–5 dependencias directas (heurística): si necesita más, probablemente está orquestando demasiado o mezclando capas.
  • API pública mínima: expón lo necesario para el caso de uso; lo demás privado. Menos superficie = menos acoplamiento.
  • Parámetros con intención: si un método recibe 6–8 parámetros, agrupa en un objeto de parámetros (cohesivo) o divide responsabilidades.
  • Evita “clases dios”: si concentra reglas de muchos subdominios, extrae servicios/reglas por tema.

Ejemplo evolutivo: un módulo de checkout que crece sin reescrituras

Objetivo: mostrar cómo pasar de una implementación “rápida” a un diseño extensible aplicando SRP/OCP/ISP/DIP. El ejemplo usa Java y C# en paralelo.

Requisito inicial (R1): calcular total con impuesto fijo

Versión inicial (intencionalmente acoplada) para tener un punto de partida:

// Java (versión inicial, acoplada)
class CheckoutService {
  public double total(double subtotal) {
    double tax = subtotal * 0.21;
    return subtotal + tax;
  }
}

// C# (versión inicial, acoplada)
class CheckoutService {
  public decimal Total(decimal subtotal) {
    var tax = subtotal * 0.21m;
    return subtotal + tax;
  }
}

Esto funciona para R1. El problema aparece cuando llegan cambios.

R2: impuesto depende del país

Señal típica de OCP en riesgo: aparece un switch por país dentro del mismo método. En vez de crecer ahí, extrae una política de impuestos.

// Java
interface TaxPolicy { double taxFor(double subtotal); }

class SpainTaxPolicy implements TaxPolicy {
  public double taxFor(double subtotal) { return subtotal * 0.21; }
}

class CheckoutService {
  private final TaxPolicy taxPolicy;
  CheckoutService(TaxPolicy taxPolicy) { this.taxPolicy = taxPolicy; }

  public double total(double subtotal) {
    return subtotal + taxPolicy.taxFor(subtotal);
  }
}

// C#
interface ITaxPolicy { decimal TaxFor(decimal subtotal); }

class SpainTaxPolicy : ITaxPolicy {
  public decimal TaxFor(decimal subtotal) => subtotal * 0.21m;
}

class CheckoutService {
  private readonly ITaxPolicy _taxPolicy;
  public CheckoutService(ITaxPolicy taxPolicy) { _taxPolicy = taxPolicy; }

  public decimal Total(decimal subtotal) => subtotal + _taxPolicy.TaxFor(subtotal);
}

Qué se ganó: el checkout ya no cambia cuando agregas un país; agregas otra implementación de TaxPolicy/ITaxPolicy.

R3: descuentos acumulables (cupón, fidelidad, campaña)

Señal de violación OCP: un método empieza a tener condicionales por tipo de descuento. Solución: modelar descuentos como reglas componibles (lista).

// Java
interface DiscountRule { double apply(double subtotal); }

class CouponDiscount implements DiscountRule {
  private final double amount;
  CouponDiscount(double amount) { this.amount = amount; }
  public double apply(double subtotal) { return Math.max(0, subtotal - amount); }
}

class CheckoutService {
  private final TaxPolicy taxPolicy;
  private final java.util.List<DiscountRule> discounts;

  CheckoutService(TaxPolicy taxPolicy, java.util.List<DiscountRule> discounts) {
    this.taxPolicy = taxPolicy;
    this.discounts = discounts;
  }

  public double total(double subtotal) {
    double discounted = subtotal;
    for (var d : discounts) discounted = d.apply(discounted);
    return discounted + taxPolicy.taxFor(discounted);
  }
}

// C#
interface IDiscountRule { decimal Apply(decimal subtotal); }

class CouponDiscount : IDiscountRule {
  private readonly decimal _amount;
  public CouponDiscount(decimal amount) { _amount = amount; }
  public decimal Apply(decimal subtotal) => Math.Max(0, subtotal - _amount);
}

class CheckoutService {
  private readonly ITaxPolicy _taxPolicy;
  private readonly IEnumerable<IDiscountRule> _discounts;

  public CheckoutService(ITaxPolicy taxPolicy, IEnumerable<IDiscountRule> discounts) {
    _taxPolicy = taxPolicy;
    _discounts = discounts;
  }

  public decimal Total(decimal subtotal) {
    var discounted = subtotal;
    foreach (var d in _discounts) discounted = d.Apply(discounted);
    return discounted + _taxPolicy.TaxFor(discounted);
  }
}

Notas operativas:

  • La lista de reglas es un punto de extensión OCP: agregas una clase nueva, no editas el núcleo.
  • La cohesión mejora: cada descuento tiene una única razón de cambio.

R4: persistir la orden y enviar confirmación (DB + email)

Aquí suele romperse DIP: se mete SQL/SMTP dentro del caso de uso. En vez de eso, define puertos mínimos (ISP) y deja que infraestructura los implemente.

// Java
interface OrderRepository { void save(Order order); }
interface EmailSender { void send(String to, String subject, String body); }

class CheckoutUseCase {
  private final CheckoutService pricing;
  private final OrderRepository orders;
  private final EmailSender email;

  CheckoutUseCase(CheckoutService pricing, OrderRepository orders, EmailSender email) {
    this.pricing = pricing;
    this.orders = orders;
    this.email = email;
  }

  public void checkout(String customerEmail, double subtotal) {
    double total = pricing.total(subtotal);
    Order order = new Order(total);
    orders.save(order);
    email.send(customerEmail, "Order confirmed", "Total: " + total);
  }
}

// C#
interface IOrderRepository { void Save(Order order); }
interface IEmailSender { void Send(string to, string subject, string body); }

class CheckoutUseCase {
  private readonly CheckoutService _pricing;
  private readonly IOrderRepository _orders;
  private readonly IEmailSender _email;

  public CheckoutUseCase(CheckoutService pricing, IOrderRepository orders, IEmailSender email) {
    _pricing = pricing;
    _orders = orders;
    _email = email;
  }

  public void Checkout(string customerEmail, decimal subtotal) {
    var total = _pricing.Total(subtotal);
    var order = new Order(total);
    _orders.Save(order);
    _email.Send(customerEmail, "Order confirmed", $"Total: {total}");
  }
}

Qué cambió: se separó “cálculo” (pricing) de “orquestación” (use case) y de “detalles” (repositorio/email). Esto reduce acoplamiento y hace tests rápidos (puedes usar dobles de OrderRepository/EmailSender).

R5: nuevo canal de notificación (SMS) sin tocar el caso de uso

Señal: si para agregar SMS editas CheckoutUseCase, estás perdiendo OCP. Refactor: introducir un puerto de notificación y permitir múltiples implementaciones.

// Java
interface Notifier { void notify(String to, String message); }

class EmailNotifier implements Notifier {
  private final EmailSender email;
  EmailNotifier(EmailSender email) { this.email = email; }
  public void notify(String to, String message) {
    email.send(to, "Order confirmed", message);
  }
}

class CheckoutUseCase {
  private final CheckoutService pricing;
  private final OrderRepository orders;
  private final java.util.List<Notifier> notifiers;

  CheckoutUseCase(CheckoutService pricing, OrderRepository orders, java.util.List<Notifier> notifiers) {
    this.pricing = pricing;
    this.orders = orders;
    this.notifiers = notifiers;
  }

  public void checkout(String customer, double subtotal) {
    double total = pricing.total(subtotal);
    Order order = new Order(total);
    orders.save(order);
    for (var n : notifiers) n.notify(customer, "Total: " + total);
  }
}

// C#
interface INotifier { void Notify(string to, string message); }

class EmailNotifier : INotifier {
  private readonly IEmailSender _email;
  public EmailNotifier(IEmailSender email) { _email = email; }
  public void Notify(string to, string message) => _email.Send(to, "Order confirmed", message);
}

class CheckoutUseCase {
  private readonly CheckoutService _pricing;
  private readonly IOrderRepository _orders;
  private readonly IEnumerable<INotifier> _notifiers;

  public CheckoutUseCase(CheckoutService pricing, IOrderRepository orders, IEnumerable<INotifier> notifiers) {
    _pricing = pricing;
    _orders = orders;
    _notifiers = notifiers;
  }

  public void Checkout(string customer, decimal subtotal) {
    var total = _pricing.Total(subtotal);
    var order = new Order(total);
    _orders.Save(order);
    foreach (var n in _notifiers) n.Notify(customer, $"Total: {total}");
  }
}

Resultado: agregar SMS es sumar SmsNotifier e inyectarlo, sin editar el caso de uso.

Guía práctica paso a paso para evolucionar un módulo sin reescrituras

Paso 1: identifica el “núcleo estable” y los “detalles variables”

  • Núcleo: reglas de cálculo (impuestos, descuentos), invariantes del dominio.
  • Detalles: persistencia, proveedores externos, formatos, canales de notificación.

Paso 2: corta por responsabilidades (SRP) antes de abstraer

  • Si una clase hace reglas + IO, primero separa en dos clases.
  • Deja el cálculo lo más puro posible (sin dependencias externas).

Paso 3: introduce contratos mínimos (ISP) donde haya variación

  • Si cambia el “cómo” (proveedor), crea una interfaz pequeña.
  • Si cambia el “qué” (regla), modela como estrategia/regla componible.

Paso 4: invierte dependencias (DIP) moviendo new al borde

  • El caso de uso recibe interfaces por constructor.
  • La composición de objetos ocurre en el arranque (composition root).

Paso 5: valida OCP con una prueba mental

“Si mañana agrego un nuevo impuesto/descuento/notificador, ¿tengo que editar el archivo del caso de uso?” Si la respuesta es sí, busca el condicional central y conviértelo en punto de extensión.

Checklist de revisión de código orientada a POO (para PRs)

ÁreaPreguntas concretasSeñales de alerta
API pública mínima¿La clase expone solo lo necesario? ¿Hay métodos públicos “por si acaso”?Muchos public sin uso claro; setters públicos que rompen invariantes.
Invariantes¿Las reglas se mantienen siempre? ¿Se valida en el constructor/métodos?Estados inválidos posibles; validación dispersa o ausente.
Cohesión¿Todos los métodos trabajan sobre el mismo concepto?Clase con temas mezclados; nombres genéricos (Manager, Utils).
Acoplamiento¿Cuántas dependencias directas tiene? ¿Depende de detalles?Importa SDKs/DB/HTTP en reglas; demasiados colaboradores.
Abstracciones¿Las interfaces son pequeñas y por consumidor?Interfaces “gordas”; implementaciones con métodos no usados.
Composición preferida¿Se puede resolver con composición antes que jerarquía?Jerarquías para reutilizar código; overrides frágiles.
Jerarquías justificadas¿La especialización es real y estable?Herencia para “variantes” que cambian seguido; necesidad de switch por tipo.
OCP en práctica¿Agregar un caso nuevo requiere editar el núcleo?Condicional central que crece; archivo “hotspot” en cada feature.
Testabilidad¿Se puede probar reglas sin IO?Tests lentos por DB/red; mocks excesivos por interfaces grandes.

Ahora responde el ejercicio sobre el contenido:

Quieres agregar un nuevo canal de notificación (por ejemplo, SMS) al proceso de checkout sin modificar el caso de uso principal. ¿Qué diseño cumple mejor con OCP y reduce el acoplamiento?

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

¡Tú error! Inténtalo de nuevo.

Usar un contrato de notificación e inyectar una colección permite extender con nuevas implementaciones (p. ej., SmsNotifier) sin editar el núcleo. Evita condicionales centrales y mantiene las dependencias en abstracciones.

Siguiente capítulo

Refactorización orientada a objetos: del código rígido al código extensible

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#)
80%

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.