SOLID como una pregunta sobre el cambio: Refactorización de un servicio de Spring Boot
Aprende a aplicar cada principio SOLID en un backend de Spring Boot preguntándote qué es lo que probablemente cambiará y cómo evitar la sobreingeniería que SOLID a menudo provoca.
Fuentes: SOLID Principles for Java Developers: What 5 Years of Backend Development Taught Me
La mayoría de los desarrolladores pueden recitar qué significan las cinco letras de SOLID, pero muy pocos son capaces de explicar cómo esas letras deben influir en un servicio real de Spring Boot que incluya repositorios, proveedores de pago y canales de notificación. Las definiciones por sí solas no son útiles cuando un gerente de producto solicita notificaciones por WhatsApp un viernes por la tarde. Esta guía explica cada principio mediante código de backend típico, muestra el refactoring que cada uno sugiere y, lo que es igualmente importante, indica en qué casos aplicar mecánicamente el principio empeora la base de código.
El hilo conductor de los cinco principios no es “más interfaces” ni “clases pequeñas”. Se trata de mantener el código fácil de modificar, de modo que alterar un comportamiento no afecte a todo lo demás. Los ejemplos a continuación están en Java, pero el razonamiento se aplica directamente a los servicios de TypeScript en Node.js; si prefiere ese entorno, existe un artículo complementario en SOLID a través de ejemplos prácticos en JavaScript.
El problema que SOLID intenta evitar
Comencemos con un servicio de pedidos que realiza todo lo necesario para un proceso de pago en un único método: valida, guarda los datos, envía correos electrónicos, genera facturas, notifica y registra información analítica.
public class OrderService {
public void createOrder(Order order) {
validateOrder(order);
saveOrder(order);
sendEmail(order);
generateInvoice(order);
sendNotification(order);
updateAnalytics(order);
}
}
Nada aquí está roto. La lógica se sigue de arriba hacia abajo, pasaría una revisión de código rápidamente y podría ejecutarse en producción durante meses. Los problemas surgen con los requisitos siguientes: un stakeholder quiere que las notificaciones se envíen por WhatsApp en lugar de por correo electrónico. Otro desea que la generación de facturas se realice como tarea en segundo plano. El equipo de datos anuncia una migración de la base de datos. Luego, los análisis deben publicarse en Kafka.
Cada uno de esos pedidos termina en la misma clase. El código sigue funcionando; el problema es que cada pequeño cambio obliga a abrir, editar y volver a probar una unidad que está relacionada con cinco otros asuntos. Ese acoplamiento, en el que un cambio arrastra a muchos otros consigo, es exactamente lo que los principios SOLID están diseñados para controlar.
Responsabilidad única: cuente las razones para cambiar
El Principio de Responsabilidad Única establece que una clase debe tener una sola responsabilidad, lo que en la práctica significa un único motivo para modificarla. Con frecuencia se interpreta erróneamente como “un método por clase”. Esa interpretación es incorrecta: una clase con veinte métodos aún puede tener una sola responsabilidad, siempre y cuando todos ellos sirvan al mismo propósito.
La pregunta relevante no es cuán grande es la clase, sino qué podría obligarte a editarla. Si puedes enumerar varias respuestas no relacionadas, es probable que la clase esté asumiendo varias responsabilidades.
Detección de responsabilidades mixtas
A continuación se muestra un servicio de registro de usuarios en el estilo que encontrarás en muchos conjuntos de código.
public class UserService {
public void registerUser(User user) {
validate(user);
saveToDatabase(user);
sendEmail(user);
generateReport(user);
}
}
Realiza cuatro tareas distintas: validar al usuario, persistirlo, enviar correos electrónicos y generar un informe. Eso le da cuatro razones independientes para cambiar. Un nuevo proveedor de correo electrónico implica editar UserService. Un cambio a una base de datos diferente también implica editar UserService. Una modificación en el formato del informe requiere volver a editar UserService. Cuando equipos no relacionados siguen modificando el mismo archivo, se trata de un problema de diseño.
Delegación a colaboradores
El refactoring mantiene la orquestación en UserService y asigna el trabajo especializado a colaboradores dedicados que se inyectan mediante el constructor.
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
public void registerUser(User user) {
validate(user);
userRepository.save(user);
emailService.sendWelcomeEmail(user);
}
}
La estructura de dependencias resultante es fácil de imaginar:
UserService
|
+---- UserRepository
|
+---- EmailService
UserService ahora decide qué ocurre durante el registro, mientras que UserRepository se encarga de la persistencia y EmailService de la entrega. La validación permanece en el servicio porque forma parte de las reglas de registro mismas, y la generación de informes queda completamente fuera del flujo de registro.
La práctica recomendada es dejar de contar métodos y, en su lugar, preguntarse cuántas fuerzas diferentes podrían obligar a modificar una clase. Esa pregunta apunta al acoplamiento real en lugar del tamaño superficial.
Abierto/Cerrado: agregar comportamientos sin volver a abrir código estable
La formulación formal de que las entidades de software deben ser abiertas para la extensión pero cerradas para la modificación suena abstracta. En términos cotidianos: se debe poder introducir nuevos comportamientos sin editar repetidamente código que ya funciona y ha sido probado.
La condicional en crecimiento
El manejo de pagos es el ejemplo clásico. Una primera versión suele basarse en una cadena de texto.
public class PaymentService {
public void pay(String type) {
if (type.equals("CARD")) {
// card payment
}
if (type.equals("UPI")) {
// UPI payment
}
if (type.equals("NET_BANKING")) {
// net banking
}
}
}
Esto funciona bien para tres métodos. Luego, el negocio solicita billeteras digitales, PayPal, Apple Pay y transferencias bancarias. Cada adición implica volver a abrir PaymentService, extender la condicional y volver a probar cada rama que ya existía. La clase se convierte poco a poco en una larga cadena de if/else.
Sustituir ramas por polimorfismo
El enfoque Abierto/Cerrado introduce una abstracción que describe qué debe hacer cada método de pago.
public interface PaymentProcessor {
void process(Payment payment);
}
Cada método de pago se convierte entonces en su propia implementación. El procesador de tarjetas:
public class CardPaymentProcessor
implements PaymentProcessor {
@Override
public void process(Payment payment) {
// card payment
}
}
Y el procesador UPI:
public class UpiPaymentProcessor
implements PaymentProcessor {
@Override
public void process(Payment payment) {
// UPI payment
}
}
Agregar un proveedor nuevo ahora implica escribir una clase nueva en lugar de editar las existentes. Los procesadores que ya se encuentran en producción permanecen sin cambios, lo que reduce el alcance de cada modificación. En una aplicación Spring, normalmente se registra cada procesador como un bean y se selecciona el adecuado a través de un mapa indexado por tipo de pago, de modo que incluso la lógica de selección no necesita desarrollar una nueva rama.
Cuando una interfaz no te sirve de nada
Nada de esto implica que toda clase necesite una interfaz. Un patrón muy común en proyectos Spring es la asociación uno a uno entre una interfaz y su única implementación:
public interface UserService {
void createUser();
}
con una sola clase detrás de ella:
public class UserServiceImpl implements UserService {
}
Si no existe ninguna posibilidad realista de una segunda implementación y no se obtiene beneficio alguno al reemplazarla, esta interfaz es simplemente un archivo adicional del que hay que ocuparse. Las bibliotecas modernas de simulación pueden simular clases concretas, por lo que la solo factibilidad de pruebas rara vez constituye un argumento sólido. SOLID se refiere a diseñar para los cambios que se esperan, no a envolver cada clase en una abstracción de forma automática.
Sustitución de Liskov: respetar el contrato, no solo la firma
El principio de Sustitución de Liskov establece que se debe poder utilizar cualquier subtipo donde se espere su tipo padre sin que eso afecte la corrección del programa. De los cinco principios, este suele parecer el más académico, por lo que resulta útil volver a utilizar el ejemplo de pagos.
La abstracción es la misma que antes:
public interface PaymentProcessor {
void process(Payment payment);
}
Una implementación de tarjeta cumple con lo que promete la interfaz:
public class CardPaymentProcessor
implements PaymentProcessor {
public void process(Payment payment) {
// process payment
}
}
Hasta ahora, todo bien. Luego alguien agrega un procesador para pagos en efectivo que en realidad no puede procesar nada en línea:
public class CashPaymentProcessor
implements PaymentProcessor {
public void process(Payment payment) {
throw new UnsupportedOperationException();
}
}
El compilador está satisfecho, ya que la firma del método coincide. Sin embargo, el comportamiento rompe esa promesa. Cualquier llamante que posea un PaymentProcessor asume razonablemente que al llamar a process se procesará un pago. Con esta implementación, un bucle aparentemente inofensivo sobre todos los procesadores puede generar errores en tiempo de ejecución, y los llamantes comienzan a agregar verificaciones con instanceof o bloques try/catch para protegerse, lo cual es una señal clara de que la abstracción ha tenido fugas.
La pregunta que hay que plantearse al escribir una implementación es si realmente puede comportarse como promete la abstracción. Si la respuesta honesta es no, la solución suele ser cambiar la abstracción, por ejemplo modelando los pagos offline de forma separada o dividiendo la funcionalidad en una interfaz más específica. El principio de Liskov se refiere a la corrección del comportamiento, no a si la palabra clave implements se compila.
Segregación de interfaces: evitar que los consumidores dependan de lo que ignoran
La segregación de interfaces establece que ningún cliente debe verse obligado a depender de métodos que no utiliza. Las interfaces grandes y generales son donde suele manifestarse este principio.
Una interfaz que sirve a todos
Considere una interfaz que agrupa la gestión de empleados, nóminas, aprobación de vacaciones e informes:
public interface EmployeeOperations {
void createEmployee();
void updateEmployee();
void deleteEmployee();
void generateSalary();
void approveLeave();
void generateReport();
}
Un componente de generación de informes solo necesita una única operación:
generateReport();
Pero al depender de EmployeeOperations, queda vinculado a la lógica de creación, eliminación, salarios y permisos que nunca utilizará.
División según las necesidades de los consumidores
Una mejor estructura ofrece contratos separados y especializados. Uno para gestionar los registros de empleados:
public interface EmployeeManagement {
void createEmployee();
void updateEmployee();
void deleteEmployee();
}
Y otro para la generación de informes:
public interface EmployeeReporting {
void generateReport();
}
Cada consumidor ahora solo depende de la funcionalidad que utiliza. Una clase aún puede implementar ambas; lo importante es cómo los clientes ven la separación.
Por qué es relevante a medida que el sistema crece
En una aplicación pequeña, una interfaz de tamaño excesivo representa un molesto detalle menor. En una aplicación grande, se convierte en una red de acoplamientos accidentales donde un cambio en un consumidor afecta a muchos otros no relacionados. Las interfaces más pequeñas hacen que los límites de las dependencias sean explícitos y más fáciles de comprender.
La advertencia es la misma que con el principio de Abierto/Cerrado. No divida las interfaces en fragmentos solo para cumplir un requisito. Úselas como unidades separadas cuando diferentes clientes realmente necesiten subconjuntos distintos de funcionalidades.
Inversión de dependencias: permitir que la lógica empresarial dependa de abstracciones
La inversión de dependencias establece que tanto el código a nivel empresarial como el código de infraestructura deben basarse en abstracciones compartidas, en lugar de que la capa de políticas haga referencia a clases concretas de bajo nivel. En las aplicaciones Spring Boot, este principio es probablemente el más importante de los cinco.
Infraestructura predefinida
Aquí el servicio de pedidos crea su propio repositorio:
public class OrderService {
private final MySqlOrderRepository repository;
public OrderService() {
this.repository = new MySqlOrderRepository();
}
}
La lógica de negocio ahora sabe con precisión qué tecnología de base de datos almacena sus datos. Sustituir MySQL o usar un repositorio falso en una prueba unitaria requiere editar el propio OrderService. La política de alto nivel está vinculada a un detalle de bajo nivel.
Invertir la dependencia
Primero defina qué necesita realmente la lógica de negocio, independientemente de la tecnología de almacenamiento:
public interface OrderRepository {
void save(Order order);
}
Luego haga que la clase específica de MySQL sea una implementación de ese contrato:
@Repository
public class MySqlOrderRepository
implements OrderRepository {
@Override
public void save(Order order) {
// save order
}
}
Finalmente, haga que el servicio reciba la abstracción a través de su constructor:
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
OrderService ya no se preocupa por dónde terminan los pedidos. La implementación puede ser MySQL, PostgreSQL, MongoDB, una llamada a otro servicio o un mock en memoria utilizado en pruebas. La dirección del conocimiento ha cambiado: la infraestructura se ajusta a una interfaz propiedad de la capa de negocio, en lugar de que la capa de negocio se adapte a la infraestructura.
Cómo la inyección de dependencias de Spring respalda estos principios
Spring hace que la inversión de dependencias parezca algo natural porque su contenedor se encarga de conectarlas. Cuando un servicio declara sus colaboradores en el constructor, como a continuación, Spring encuentra un bean que coincida con el tipo OrderRepository y lo pasa como parámetro.
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
La clase nunca construye sus propias dependencias. Contrástese esto con la instantiación directa dentro del código de negocio:
OrderRepository repository =
new MySqlOrderRepository();
Esa única línea vincula al llamante a una clase concreta e impide la sustitución sin editarla. La inyección de constructores mantiene el acoplamiento en el nivel de la abstracción y hace que las dependencias sean visibles en la firma de la clase, razón por la cual generalmente se prefiere sobre la inyección de campos. Tenga en cuenta que la inyección de dependencias es un mecanismo y la inversión de dependencias es un principio de diseño: el contenedor le ayuda a conectar abstracciones, pero usted aún debe elegir qué abstracciones definir.
SOLID no significa más clases
Los equipos que acaban de descubrir SOLID a veces exageran. Una sola funcionalidad para el usuario se convierte en una docena de tipos:
UserValidator
UserCreator
UserUpdater
UserRepository
UserMapper
UserConverter
UserEmailService
UserNotificationService
UserFactory
UserBuilder
UserManager
UserProcessor
Si se aplica esto en todo un código, se termina con cuarenta clases cuando ocho habrían expresado el mismo diseño de manera más clara. Eso es sobreingeniería, no una buena arquitectura. Los principios existen para reducir la complejidad; si aplicar uno hace que el código sea más difícil de seguir, probablemente se esté aplicando de forma mecánica en lugar de como respuesta a una verdadera fuente de cambio.
Comience con lo que es probable que cambie
En lugar de preguntarse dónde se podría aplicar SOLID, los ingenieros backend experimentados tienden a empezar con otra pregunta: ¿qué en este componente es probable que cambie? La respuesta se relaciona directamente con los principios:
- La base de datos puede cambiar: introduzca una abstracción de repositorio.
- Los proveedores de pago pueden cambiar: coloque a los proveedores detrás de una abstracción de pagos.
Al plantearlo de esta forma, los principios se mantienen sólidos. Agrega abstracciones donde existe verdadera volatilidad y deja que el código estable permanezca simple.
Aplicando el mismo razonamiento a los límites de los servicios
Este razonamiento se extiende más allá de las clases individuales. Imagina un servicio desplegable responsable de todos estos dominios:
User
Payment
Notification
Reporting
Inventory
Shipping
Puede funcionar perfectamente bien, pero los usuarios, los pagos, las notificaciones, la generación de informes, el inventario y el envío cambian por diferentes razones y, a menudo, en diferentes plazos establecidos por equipos distintos. La lógica detrás del principio de Responsabilidad Única puede ayudarte a identificar dónde se encuentran los límites naturales.
Eso no significa que cada concepto de dominio merezca su propio microservicio. SOLID no es una exigencia para crear más servicios; es una guía para separar bien las responsabilidades y las dependencias, ya sea que esos límites terminen siendo módulos, paquetes o servicios.
Lista de verificación para los componentes backend
Antes de considerar que un componente backend importante está terminado, revisa una pregunta por cada principio:
- Responsabilidad Única: ¿tiene esta clase varias razones no relacionadas para cambiar?
Las respuestas honestas a estas preguntas son más valiosas que saber recitar las definiciones.
Puntos clave
- El objetivo detrás de los cinco principios es el mismo: hacer que los cambios futuros sean sencillos y controlados, de modo que cambiar la base de datos, añadir un proveedor de pagos o modificar el canal de notificaciones parezca algo rutinario y no arriesgado.
- Lea cada principio como una pregunta. Un servicio complejo plantea “¿qué está cambiando aquí?”, una interfaz con diez métodos pregunta “¿quién necesita todo esto?”, y un
new SomeImplementation()en la lógica de negocio plantea “¿esta clase necesita saberlo?”. - Introduzca abstracciones donde el cambio sea plausible, y resístase a ellas cuando no lo sea; una interfaz con una implementación permanente, o una docena de clases para una sola función, aumenta los costos sin ofrecer protección.
- Las violaciones de Liskov suelen indicar una abstracción incorrecta, así que corrija el modelo en lugar de parchear a quienes lo utilizan.
- Escriba código que funcione hoy, y estructúrelo de tal manera que las modificaciones de mañana no sean más difíciles que las de hoy.
Lecturas relacionadas
- Tokens de inyección en NestJS: Por qué useClass puede duplicar silenciosamente singulares — Entienda los tipos, tokens y proveedores en la inyección de dependencias de NestJS, por qué las interfaces no pueden ser inyectadas y cómo useExisting evita dos instancias del mismo servicio con estado.
- DI para frontend agnóstico al framework con InversifyJS y una raíz de composición — Cómo elegir un contenedor DI para TypeScript, empaquetar cada dominio como un ContainerModule, conectar todo en una sola raíz de composición y vincularlo a React, Vue y Angular.