Saltar a contenido

Semana 10 - Servicios Avanzados en Spring Boot

Resumen ejecutivo

En esta semana aprenderemos técnicas avanzadas para servicios en Spring Boot: manejo de excepciones personalizadas, integración completa con controladores REST, y patrones avanzados para aplicaciones robustas.


1. Manejo de excepciones personalizadas

1.1 Crear excepciones de negocio

// Excepción base
public class BusinessException extends RuntimeException {
    public BusinessException(String message) {
        super(message);
    }
}

// Excepciones específicas
public class ProductoNotFoundException extends BusinessException {
    public ProductoNotFoundException(String message) {
        super(message);
    }
}

public class StockInsuficienteException extends BusinessException {
    public StockInsuficienteException(String message) {
        super(message);
    }
}

1.2 Manejador global de excepciones

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ProductoNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleProductoNotFound(ProductoNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
            "PRODUCTO_NO_ENCONTRADO",
            ex.getMessage(),
            LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(StockInsuficienteException.class)
    public ResponseEntity<ErrorResponse> handleStockInsuficiente(StockInsuficienteException ex) {
        ErrorResponse error = new ErrorResponse(
            "STOCK_INSUFICIENTE",
            ex.getMessage(),
            LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException ex) {
        ErrorResponse error = new ErrorResponse(
            "DATOS_INVALIDOS",
            ex.getMessage(),
            LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }
}

@Data
@AllArgsConstructor
public class ErrorResponse {
    private String codigo;
    private String mensaje;
    private LocalDateTime timestamp;
}

2. Servicio avanzado con excepciones

@Service
@Transactional
public class ProductoServiceAvanzado {

    @Autowired
    private ProductoRepository productoRepository;

    // Actualizar producto con validaciones completas
    public ProductoDTO actualizarProducto(Long id, CrearProductoDTO dto) {
        // 1. Verificar que existe
        Producto producto = productoRepository.findById(id)
            .orElseThrow(() -> new ProductoNotFoundException(
                "Producto no encontrado con ID: " + id));

        // 2. Validaciones de negocio
        validarDatosProducto(dto);

        // 3. Actualizar campos
        producto.setNombre(dto.getNombre());
        producto.setDescripcion(dto.getDescripcion());
        producto.setPrecio(dto.getPrecio());
        producto.setStock(dto.getStock());

        // 4. Guardar y retornar
        Producto actualizado = productoRepository.save(producto);
        return convertirADTO(actualizado);
    }

    // Actualizar stock con validación
    public ProductoDTO actualizarStock(Long id, Integer cantidadVendida) {
        Producto producto = productoRepository.findById(id)
            .orElseThrow(() -> new ProductoNotFoundException(
                "Producto no encontrado con ID: " + id));

        // Validar stock suficiente
        if (producto.getStock() < cantidadVendida) {
            throw new StockInsuficienteException(
                String.format("Stock insuficiente. Disponible: %d, Solicitado: %d", 
                             producto.getStock(), cantidadVendida));
        }

        // Actualizar stock
        producto.setStock(producto.getStock() - cantidadVendida);

        // Si stock llega a 0, marcar como inactivo
        if (producto.getStock() == 0) {
            producto.setActivo(false);
        }

        Producto actualizado = productoRepository.save(producto);
        return convertirADTO(actualizado);
    }

    // Eliminar producto (eliminación lógica)
    public void eliminarProducto(Long id) {
        Producto producto = productoRepository.findById(id)
            .orElseThrow(() -> new ProductoNotFoundException(
                "Producto no encontrado con ID: " + id));

        producto.setActivo(false);
        productoRepository.save(producto);
    }

    // Validaciones privadas
    private void validarDatosProducto(CrearProductoDTO dto) {
        if (dto.getNombre() == null || dto.getNombre().trim().isEmpty()) {
            throw new IllegalArgumentException("El nombre es obligatorio");
        }

        if (dto.getPrecio() == null || dto.getPrecio().compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("El precio debe ser mayor a 0");
        }

        if (dto.getStock() == null || dto.getStock() < 0) {
            throw new IllegalArgumentException("El stock no puede ser negativo");
        }
    }

    private ProductoDTO convertirADTO(Producto producto) {
        ProductoDTO dto = new ProductoDTO();
        dto.setId(producto.getId());
        dto.setNombre(producto.getNombre());
        dto.setDescripcion(producto.getDescripcion());
        dto.setPrecio(producto.getPrecio());
        dto.setStock(producto.getStock());
        dto.setActivo(producto.getActivo());
        return dto;
    }
}

3. Controlador REST completo

@RestController
@RequestMapping("/api/productos")
@Validated
public class ProductoController {

    @Autowired
    private ProductoServiceAvanzado productoService;

    // Crear producto
    @PostMapping
    public ResponseEntity<ProductoDTO> crearProducto(
            @Valid @RequestBody CrearProductoDTO dto) {
        ProductoDTO producto = productoService.crearProducto(dto);
        return ResponseEntity.status(HttpStatus.CREATED).body(producto);
    }

    // Obtener por ID
    @GetMapping("/{id}")
    public ResponseEntity<ProductoDTO> obtenerProducto(@PathVariable Long id) {
        ProductoDTO producto = productoService.obtenerPorId(id);
        return ResponseEntity.ok(producto);
    }

    // Listar productos activos
    @GetMapping("/activos")
    public ResponseEntity<List<ProductoDTO>> obtenerActivos() {
        List<ProductoDTO> productos = productoService.obtenerActivos();
        return ResponseEntity.ok(productos);
    }

    // Buscar por nombre
    @GetMapping("/buscar")
    public ResponseEntity<List<ProductoDTO>> buscarPorNombre(
            @RequestParam String nombre) {
        List<ProductoDTO> productos = productoService.buscarPorNombre(nombre);
        return ResponseEntity.ok(productos);
    }

    // Actualizar producto
    @PutMapping("/{id}")
    public ResponseEntity<ProductoDTO> actualizarProducto(
            @PathVariable Long id,
            @Valid @RequestBody CrearProductoDTO dto) {
        ProductoDTO producto = productoService.actualizarProducto(id, dto);
        return ResponseEntity.ok(producto);
    }

    // Actualizar stock
    @PatchMapping("/{id}/stock")
    public ResponseEntity<ProductoDTO> actualizarStock(
            @PathVariable Long id,
            @RequestParam @Min(1) Integer cantidad) {
        ProductoDTO producto = productoService.actualizarStock(id, cantidad);
        return ResponseEntity.ok(producto);
    }

    // Eliminar producto
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> eliminarProducto(@PathVariable Long id) {
        productoService.eliminarProducto(id);
        return ResponseEntity.noContent().build();
    }
}

4. Patrones avanzados

4.1 Composición de servicios

@Service
@Transactional
public class VentaService {

    @Autowired
    private ProductoServiceAvanzado productoService;

    @Autowired
    private VentaRepository ventaRepository;

    // Procesar venta (usa múltiples servicios)
    public VentaDTO procesarVenta(CrearVentaDTO ventaDTO) {
        // 1. Validar productos y stock
        for (ItemVentaDTO item : ventaDTO.getItems()) {
            ProductoDTO producto = productoService.obtenerPorId(item.getProductoId());

            if (producto.getStock() < item.getCantidad()) {
                throw new StockInsuficienteException(
                    "Stock insuficiente para producto: " + producto.getNombre());
            }
        }

        // 2. Crear venta
        Venta venta = new Venta();
        venta.setFecha(LocalDateTime.now());
        venta.setTotal(calcularTotal(ventaDTO.getItems()));

        // 3. Actualizar stock de productos
        for (ItemVentaDTO item : ventaDTO.getItems()) {
            productoService.actualizarStock(item.getProductoId(), item.getCantidad());
        }

        // 4. Guardar venta
        Venta ventaGuardada = ventaRepository.save(venta);

        return convertirVentaADTO(ventaGuardada);
    }

    private BigDecimal calcularTotal(List<ItemVentaDTO> items) {
        return items.stream()
            .map(item -> {
                ProductoDTO producto = productoService.obtenerPorId(item.getProductoId());
                return producto.getPrecio().multiply(BigDecimal.valueOf(item.getCantidad()));
            })
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    private VentaDTO convertirVentaADTO(Venta venta) {
        // Conversión de entidad a DTO
        VentaDTO dto = new VentaDTO();
        dto.setId(venta.getId());
        dto.setFecha(venta.getFecha());
        dto.setTotal(venta.getTotal());
        return dto;
    }
}

4.2 Cache simple

@Service
@Transactional
public class ProductoServiceConCache {

    @Autowired
    private ProductoRepository productoRepository;

    // Cache simple en memoria
    private Map<Long, ProductoDTO> cache = new ConcurrentHashMap<>();

    @Transactional(readOnly = true)
    public ProductoDTO obtenerPorIdConCache(Long id) {
        // 1. Verificar cache
        if (cache.containsKey(id)) {
            return cache.get(id);
        }

        // 2. Buscar en base de datos
        Producto producto = productoRepository.findById(id)
            .orElseThrow(() -> new ProductoNotFoundException(
                "Producto no encontrado con ID: " + id));

        // 3. Convertir y guardar en cache
        ProductoDTO dto = convertirADTO(producto);
        cache.put(id, dto);

        return dto;
    }

    // Limpiar cache al actualizar
    public ProductoDTO actualizarProducto(Long id, CrearProductoDTO dto) {
        // Actualizar producto
        ProductoDTO actualizado = actualizarProductoEnBD(id, dto);

        // Limpiar cache
        cache.remove(id);

        return actualizado;
    }

    private ProductoDTO actualizarProductoEnBD(Long id, CrearProductoDTO dto) {
        // Lógica de actualización...
        return new ProductoDTO(); // Simplificado
    }

    private ProductoDTO convertirADTO(Producto producto) {
        // Conversión...
        return new ProductoDTO(); // Simplificado
    }
}

5. Testing de servicios

5.1 Test unitario

@ExtendWith(MockitoExtension.class)
class ProductoServiceTest {

    @Mock
    private ProductoRepository productoRepository;

    @InjectMocks
    private ProductoServiceAvanzado productoService;

    @Test
    void deberiaCrearProductoCorrectamente() {
        // Given
        CrearProductoDTO dto = new CrearProductoDTO();
        dto.setNombre("Producto Test");
        dto.setPrecio(BigDecimal.valueOf(100));
        dto.setStock(50);

        Producto producto = new Producto();
        producto.setId(1L);
        producto.setNombre("Producto Test");

        when(productoRepository.save(any(Producto.class))).thenReturn(producto);

        // When
        ProductoDTO resultado = productoService.crearProducto(dto);

        // Then
        assertThat(resultado.getNombre()).isEqualTo("Producto Test");
        verify(productoRepository).save(any(Producto.class));
    }

    @Test
    void deberiaLanzarExcepcionCuandoProductoNoExiste() {
        // Given
        Long id = 999L;
        when(productoRepository.findById(id)).thenReturn(Optional.empty());

        // When & Then
        assertThrows(ProductoNotFoundException.class, 
                    () -> productoService.obtenerPorId(id));
    }
}

6. Ejercicios prácticos

Ejercicio 1: Excepciones personalizadas

Crea excepciones para: - EmailDuplicadoException - UsuarioInactivoException - PermisoInsuficienteException

Ejercicio 2: Servicio de pedidos

Implementa un PedidoService que: - Valide stock antes de crear pedido - Calcule total con descuentos - Actualice inventario automáticamente

Ejercicio 3: Cache avanzado

Mejora el cache para: - Expirar entradas después de 5 minutos - Limitar tamaño máximo a 100 elementos - Limpiar cache automáticamente


7. Mejores prácticas

Consejos importantes

  • Excepciones específicas: Crea excepciones para cada caso de negocio
  • Validaciones tempranas: Valida datos al inicio del método
  • Transacciones: Usa @Transactional para operaciones que modifican datos
  • Testing: Siempre escribe tests para la lógica de negocio
  • Separación de responsabilidades: Un servicio, una responsabilidad
  • Logging: Registra operaciones importantes para debugging