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/ispara 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,Utilscon 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).
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
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/elseoswitchpor “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
newal 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)
| Área | Preguntas concretas | Señ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. |