En este capítulo nos centraremos en un caso práctico completo de e-commerce. Partiremos de un código acoplado y, mediante refactorizaciones sucesivas, aplicaremos cada principio SOLID. Cada paso incluye el fragmento original y la solución en Java.
La clase CheckoutService
maneja desde la validación hasta la notificación al cliente.
class CheckoutService {
void procesar(Pedido pedido) {
if (pedido.items().isEmpty()) {
throw new IllegalArgumentException("Pedido vacío");
}
BigDecimal total = pedido.items().stream()
.map(ItemPedido::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
try (Connection connection = DriverManager.getConnection("jdbc:h2:mem:test")) {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO pedidos (cliente, total) VALUES (?, ?)");
ps.setString(1, pedido.cliente());
ps.setBigDecimal(2, total);
ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
System.out.println("Enviando mail a " + pedido.cliente());
}
}
El código viola SRP, DIP, OCP e ISP, y es difícil de probar.
Extraemos colaboradores para validación, cálculo, persistencia y notificación.
interface ValidadorPedidos {
void validar(Pedido pedido);
}
interface CalculadoraTotales {
BigDecimal calcular(Pedido pedido);
}
interface RepositorioPedidos {
void guardar(Pedido pedido, BigDecimal total);
}
interface NotificadorPedidos {
void notificar(Pedido pedido, BigDecimal total);
}
class CheckoutService {
private final ValidadorPedidos validador;
private final CalculadoraTotales calculadora;
private final RepositorioPedidos repositorio;
private final NotificadorPedidos notificador;
CheckoutService(ValidadorPedidos validador,
CalculadoraTotales calculadora,
RepositorioPedidos repositorio,
NotificadorPedidos notificador) {
this.validador = validador;
this.calculadora = calculadora;
this.repositorio = repositorio;
this.notificador = notificador;
}
void procesar(Pedido pedido) {
validador.validar(pedido);
BigDecimal total = calculadora.calcular(pedido);
repositorio.guardar(pedido, total);
notificador.notificar(pedido, total);
}
}
class ValidadorPedidosBasico implements ValidadorPedidos {
public void validar(Pedido pedido) {
if (pedido.items().isEmpty()) {
throw new IllegalArgumentException("Pedido vacío");
}
}
}
class CalculadoraTotalesBasica implements CalculadoraTotales {
public BigDecimal calcular(Pedido pedido) {
return pedido.items().stream()
.map(ItemPedido::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
class RepositorioPedidosJdbc implements RepositorioPedidos {
private final DataSource dataSource;
RepositorioPedidosJdbc(DataSource dataSource) {
this.dataSource = dataSource;
}
public void guardar(Pedido pedido, BigDecimal total) {
try (Connection connection = dataSource.getConnection();
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO pedidos (cliente, total) VALUES (?, ?)")) {
ps.setString(1, pedido.cliente());
ps.setBigDecimal(2, total);
ps.executeUpdate();
} catch (SQLException e) {
throw new IllegalStateException("Error al guardar pedido", e);
}
}
}
class NotificadorPedidosMail implements NotificadorPedidos {
public void notificar(Pedido pedido, BigDecimal total) {
System.out.println("Mail a " + pedido.cliente() + " por " + total);
}
}
Añadimos un notificador por SMS sin tocar el servicio principal.
class NotificadorPedidosSms implements NotificadorPedidos {
public void notificar(Pedido pedido, BigDecimal total) {
System.out.println("SMS a " + pedido.cliente() + " por " + total);
}
}
Las interfaces se mantuvieron específicas, cumpliendo ISP.
El servicio recibe sus colaboraciones por constructor. En aplicaciones Spring, esto se configura con anotaciones:
@Service
class CheckoutService {
private final ValidadorPedidos validador;
private final CalculadoraTotales calculadora;
private final RepositorioPedidos repositorio;
private final NotificadorPedidos notificador;
CheckoutService(ValidadorPedidos validador,
CalculadoraTotales calculadora,
RepositorioPedidos repositorio,
@Qualifier("notificadorPedidosMail") NotificadorPedidos notificador) {
this.validador = validador;
this.calculadora = calculadora;
this.repositorio = repositorio;
this.notificador = notificador;
}
// método procesar idéntico al anterior
}
Aquí DIP se integra con el contenedor IoC para resolver implementaciones.
Creemos pruebas que comparen diferentes implementaciones.
@ParameterizedTest
@MethodSource("proveedoresNotificadores")
void testNotificadorRespetandoContrato(NotificadorPedidos notificador) {
Pedido pedido = PedidoFactory.crearPedidoBasico();
assertDoesNotThrow(() -> notificador.notificar(pedido, new BigDecimal("100")));
}
static Stream<NotificadorPedidos> proveedoresNotificadores() {
return Stream.of(new NotificadorPedidosMail(), new NotificadorPedidosSms());
}
La prueba polimórfica valida que todas las implementaciones cumplen el contrato esperado.
Otra parte del sistema necesita aplicar descuentos según campañas. Antes todo estaba en un switch
gigante.
class DescuentoService {
BigDecimal aplicar(Pedido pedido, String campaña) {
switch (campaña) {
case "NAVIDAD":
return pedido.total().multiply(new BigDecimal("0.1"));
case "ANIVERSARIO":
return pedido.total().multiply(new BigDecimal("0.15"));
default:
return BigDecimal.ZERO;
}
}
}
interface EstrategiaDescuento {
BigDecimal calcular(Pedido pedido);
}
class DescuentoService {
private final Map<String, EstrategiaDescuento> estrategias;
DescuentoService(Map<String, EstrategiaDescuento> estrategias) {
this.estrategias = estrategias;
}
BigDecimal aplicar(Pedido pedido, String campaña) {
return estrategias.getOrDefault(campaña, p -> BigDecimal.ZERO)
.calcular(pedido);
}
}
Agregar una campaña implica registrar una nueva implementación, manteniendo OCP y DIP.
En el siguiente capítulo analizaremos los beneficios en pruebas unitarias y mantenimiento que surgen tras aplicar SOLID en este tipo de escenarios.