8 min read
Preguntale a cualquier desarrollador qué significa SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion — Responsabilidad Única, Abierto/Cerrado, Sustitución de Liskov, Segregación de Interfaces e Inversión de Dependencias) y probablemente te recite las cinco definiciones sin problema. Ahora pedile que te muestre su último pull request… y ahí es donde la historia cambia.
Todos conocemos el acrónimo. Pocos lo aplicamos consistentemente. Y no es por mala fe — es porque las definiciones son abstractas y los ejemplos de los libros rara vez se parecen al código que escribimos un martes a las 4pm con un deadline encima.
En este post vamos a ver cada principio con código real: primero la versión que todos hemos escrito alguna vez (el “antes”), y luego el refactor que lo arregla (el “después”).
El diagrama

S — Responsabilidad Única
La regla: una clase debe tener una sola razón para cambiar.
El antes. Este es el clásico servicio que hace de todo: procesa la factura, la guarda, y encima manda el correo.
// ANTES: tres razones para cambiar en una sola clase
public class InvoiceService {
public void processInvoice(Invoice invoice) {
// 1. Lógica de negocio: calcular totales e impuestos
double subtotal = invoice.getItems().stream()
.mapToDouble(item -> item.getPrice() * item.getQuantity())
.sum();
invoice.setTotal(subtotal * 1.13); // IVA incluido
// 2. Persistencia: guardar en base de datos
String sql = "INSERT INTO invoices (id, total) VALUES (?, ?)";
// ... código JDBC aquí ...
// 3. Notificación: enviar correo al cliente
String body = "Su factura #" + invoice.getId() + " fue generada.";
// ... código SMTP aquí ...
}
}
Si cambia la regla de impuestos, tocás esta clase. Se migra de MySQL a PostgreSQL, tocás esta clase. Si cambia el proveedor de correos, tocás esta clase. Tres razones para cambiar = tres fuentes de bugs en el mismo lugar.
El después. Cada responsabilidad en su propia clase:
// DESPUÉS: cada clase tiene una única razón para cambiar
public class InvoiceCalculator {
private static final double TAX_RATE = 1.13;
public double calculateTotal(Invoice invoice) {
double subtotal = invoice.getItems().stream()
.mapToDouble(item -> item.getPrice() * item.getQuantity())
.sum();
return subtotal * TAX_RATE;
}
}
public class InvoiceRepository {
public void save(Invoice invoice) {
// Solo persistencia
}
}
public class InvoiceNotifier {
public void notifyCustomer(Invoice invoice) {
// Solo notificaciones
}
}
O — Abierto/Cerrado
La regla: abierto para extensión, cerrado para modificación.
Antes. El famoso switch que crece con cada nuevo requerimiento:
// ANTES: cada nuevo método de pago obliga a modificar esta clase
public class PaymentProcessor {
public void process(Payment payment) {
switch (payment.getType()) {
case "CREDIT_CARD":
// lógica de tarjeta
break;
case "PAYPAL":
// lógica de PayPal
break;
case "SINPE": // llegó un nuevo requerimiento...
// otra modificación más a esta clase
break;
}
}
}
Después. Una interfaz y una implementación por método de pago. Agregar SINPE Móvil ya no toca código existente:
// DESPUÉS: nuevos métodos de pago = nuevas clases, cero modificaciones
public interface PaymentMethod {
void process(Payment payment);
}
public class CreditCardPayment implements PaymentMethod {
@Override
public void process(Payment payment) { /* lógica de tarjeta */ }
}
public class SinpePayment implements PaymentMethod {
@Override
public void process(Payment payment) { /* lógica de SINPE */ }
}
public class PaymentProcessor {
public void process(Payment payment, PaymentMethod method) {
method.process(payment);
}
}
L — Sustitución de Liskov
La regla: cualquier subclase debe poder usarse donde se espera la clase padre, sin romper el comportamiento.
El antes. Una subclase que lanza excepciones donde el padre prometía funcionar:
// ANTES: ReadOnlyUserRepository rompe el contrato de su padre
public class UserRepository {
public void save(User user) { /* guarda el usuario */ }
public User findById(String id) { /* busca el usuario */ return null; }
}
public class ReadOnlyUserRepository extends UserRepository {
@Override
public void save(User user) {
// ¡Sorpresa! El código que confiaba en save() explota en producción
throw new UnsupportedOperationException("Este repositorio es de solo lectura");
}
}
El después. Separamos los contratos para que nadie prometa lo que no puede cumplir:
// DESPUÉS: cada interfaz promete solo lo que sus implementaciones cumplen
public interface UserReader {
User findById(String id);
}
public interface UserWriter {
void save(User user);
}
public class FullUserRepository implements UserReader, UserWriter {
@Override
public User findById(String id) { /* ... */ return null; }
@Override
public void save(User user) { /* ... */ }
}
public class ReadOnlyUserRepository implements UserReader {
@Override
public User findById(String id) { /* ... */ return null; }
// No implementa UserWriter: el compilador nos protege
}
I — Segregación de Interfaces
La regla: ninguna clase debería estar obligada a implementar métodos que no usa.
Este principio ya lo aplicamos en el ejemplo anterior sin darnos cuenta: en lugar de una interfaz gigante UserRepository con lectura y escritura, creamos UserReader y UserWriter. La versión de solo lectura implementa únicamente lo que necesita.
La señal de alerta más común de este principio violado: métodos implementados con throw new UnsupportedOperationException() o con cuerpos vacíos. Si tu clase implementa una interfaz y deja métodos “muertos”, esa interfaz es demasiado grande.
D — Inversión de Dependencias
La regla: los módulos de alto nivel no deben depender de los de bajo nivel. Ambos deben depender de abstracciones.
El antes. El servicio de órdenes acoplado directamente a una implementación concreta:
// ANTES: OrderService está casado con MySQL y con SendGrid
public class OrderService {
private final MySqlOrderRepository repository = new MySqlOrderRepository();
private final SendGridEmailClient emailClient = new SendGridEmailClient();
public void createOrder(Order order) {
repository.insert(order);
emailClient.send(order.getCustomerEmail(), "Orden creada");
}
}
¿Querés escribir un test unitario de OrderService? Necesitás una base de datos MySQL levantada y una cuenta de SendGrid. ¿Quieren migrar a otro proveedor de correos? Hay que modificar el servicio.
El después. El servicio depende de interfaces, y las implementaciones se inyectan:
import lombok.AllArgsConstructor;
// DESPUÉS: OrderService solo conoce abstracciones
public interface OrderRepository {
void insert(Order order);
}
public interface EmailClient {
void send(String to, String message);
}
@AllArgsConstructor
public class OrderService {
private final OrderRepository repository;
private final EmailClient emailClient;
public void createOrder(Order order) {
repository.insert(order);
emailClient.send(order.getCustomerEmail(), "Orden creada");
}
}
Ahora los tests usan mocks, cambiar de proveedor es crear una clase nueva, y OrderService nunca más se entera de qué hay detrás. Nótese el uso de @AllArgsConstructor de Lombok: genera el constructor con ambas dependencias sin escribirlo a mano.
Guía paso a paso para aplicar SOLID en tu código existente
1 — Buscá las clases “Dios”: las que superan las 300 líneas o mezclan lógica de negocio con infraestructura. Son las primeras candidatas para aplicar Responsabilidad Única.
2 — Cazá los switch y los if/else por tipo: cada vez que veas un switch(tipo) que crece con el tiempo, es una oportunidad para Abierto/Cerrado con polimorfismo.
3 — Revisá las excepciones UnsupportedOperationException: son la evidencia de violaciones de Liskov y de interfaces demasiado grandes.
4 — Buscá los new dentro de tus servicios: cada new MySqlAlgo() dentro de una clase de negocio es una dependencia concreta que deberías invertir.
5 — Refactorizá de forma incremental: no intentés arreglar todo en un sprint. Aplicá la regla del Boy Scout: dejá el código un poco mejor de como lo encontraste, cada vez que lo toqués.
Para tener en cuenta en producción
- SOLID no es dogma: aplicar los cinco principios a una clase de 20 líneas que nunca cambia es sobre-ingeniería. Los principios existen para manejar el cambio; si algo no cambia, no lo compliqués.
- La inyección de dependencias tiene frameworks: en proyectos reales con Spring, la Inversión de Dependencias se logra con
@Autowiredo inyección por constructor. El principio es el mismo; el framework solo automatiza el cableado. - Los tests son el mejor termómetro: si escribir un test unitario de una clase requiere levantar media infraestructura, esa clase está violando algún principio SOLID. Casi siempre es Inversión de Dependencias.
Conclusión
SOLID no se trata de memorizar definiciones, se trata de reconocer los síntomas en el código de todos los días: la clase que hace de todo, el switch que no para de crecer, la subclase que lanza excepciones inesperadas, la interfaz con métodos muertos, y el new que te ata a una implementación concreta.
Cada uno de estos síntomas tiene su refactor conocido. Y lo mejor: podés aplicarlos de a poco, sin necesidad de reescribir toda la aplicación.
En el próximo post vamos a comparar Event Sourcing vs CRUD: dos enfoques muy distintos para persistir datos, y cuándo conviene cada uno.
Recordá suscribirte aquí para recibir los próximos posts directamente en tu correo.
Referencias:
Clean Architecture — Robert C. Martin
Agile Software Development, Principles, Patterns, and Practices — Robert C. Martin