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
@Transactionalpara 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