14. Casos prácticos en Java: aplicar SOLID paso a paso

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.

14.1 Escenario inicial

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.

14.2 Paso 1: SRP y separación de etapas

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);
    }
}

14.3 Paso 2: Implementaciones concretas

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);
    }
}

14.4 Paso 3: OCP e ISP para nuevas capacidades

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.

14.5 Paso 4: DIP e inyección de dependencias

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.

14.6 Paso 5: asegurar sustitución (LSP)

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.

14.7 Paso 6: monitorear métricas

  • Complejidad ciclomatica: desciende al dividir responsabilidades.
  • Cobertura de pruebas: aumenta gracias a los dobles y pruebas parametrizadas.
  • Tiempo de evolución: la incorporación de nuevas funcionalidades requiere solo nuevas clases.

14.8 Caso adicional: estrategia de descuentos

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;
        }
    }
}

14.9 Refactorización con estrategia (OCP + DIP)

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.

14.10 Conclusiones del caso práctico

  • Los principios se aplican incrementalmente: cada paso refuerza al siguiente.
  • La refactorización habilita pruebas: el código orientado a interfaces es más fácil de testear.
  • La arquitectura queda preparada: nuevos canales o reglas se agregan sin tocar código probado.

En el siguiente capítulo analizaremos los beneficios en pruebas unitarias y mantenimiento que surgen tras aplicar SOLID en este tipo de escenarios.