TP10.1 - Gestão Centralizada de Erros
Objetivos do Exercício
Neste exercício, você irá:
- ✅ Criar uma hierarquia de exceções de negócio
- ✅ Implementar um GlobalExceptionHandler
- ✅ Padronizar as respostas de erro
- ✅ Tratar erros de validação
- ✅ Testar diferentes cenários
Duração estimada: 1h30
Contexto
Você está desenvolvendo uma API REST para uma livraria online. A API deve retornar mensagens de erro claras e estruturadas para facilitar a integração do frontend.
Parte 1: Estrutura do Projeto
src/main/java/com/bookstore/
├── exception/
│ ├── ApplicationException.java
│ ├── ResourceNotFoundException.java
│ ├── ValidationException.java
│ ├── ConflictException.java
│ └── BusinessRuleException.java
├── dto/
│ └── ErrorResponse.java
├── handler/
│ └── GlobalExceptionHandler.java
├── entity/
│ └── Book.java
├── repository/
│ └── BookRepository.java
├── service/
│ └── BookService.java
└── controller/
└── BookController.java
Parte 2: As Exceções
ApplicationException (Base)
package com.bookstore.exception;
import lombok.Getter;
@Getter
public abstract class ApplicationException extends RuntimeException {
private final String errorCode;
protected ApplicationException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
protected ApplicationException(String errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
}
ResourceNotFoundException
package com.bookstore.exception;
public class ResourceNotFoundException extends ApplicationException {
public ResourceNotFoundException(String resourceType, Object id) {
super("RESOURCE_NOT_FOUND",
String.format("%s with ID '%s' not found", resourceType, id));
}
}
ConflictException
// TODO: Implementar
// Code: "CONFLICT"
// Uso: throw new ConflictException("ISBN already exists: " + isbn);
ValidationException
// TODO: Implementar
// Code: "VALIDATION_FAILED"
// Deve armazenar uma lista de FieldError
BusinessRuleException
// TODO: Implementar
// Code: Parametrizável
// Uso: throw new BusinessRuleException("BOOK_NOT_AVAILABLE", "Book is out of stock");
Parte 3: ErrorResponse DTO
package com.bookstore.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse {
private String code;
private String message;
private LocalDateTime timestamp;
private String path;
private List<FieldError> errors;
@Data
@AllArgsConstructor
public static class FieldError {
private String field;
private String message;
private Object rejectedValue;
}
public static ErrorResponse of(String code, String message, String path) {
return ErrorResponse.builder()
.code(code)
.message(message)
.timestamp(LocalDateTime.now())
.path(path)
.build();
}
}
Parte 4: GlobalExceptionHandler
package com.bookstore.handler;
import com.bookstore.dto.ErrorResponse;
import com.bookstore.exception.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// ResourceNotFoundException → 404
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleResourceNotFound(ResourceNotFoundException ex, HttpServletRequest request) {
log.warn("Resource not found: {}", ex.getMessage());
return ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), request.getRequestURI());
}
// TODO: ConflictException → 409
// TODO: BusinessRuleException → 422
// TODO: MethodArgumentNotValidException → 400 (com lista de erros de validação)
// TODO: ConstraintViolationException → 400
// TODO: IllegalArgumentException → 400
// TODO: Exceção genérica → 500 (não expor a mensagem!)
}
Parte 5: A Entidade e o Serviço
Book Entity
package com.bookstore.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Entity
@Table(name = "books")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Title is required")
private String title;
@NotBlank(message = "Author is required")
private String author;
@Column(unique = true)
@Pattern(regexp = "\\d{13}", message = "ISBN must be 13 digits")
private String isbn;
@NotNull(message = "Price is required")
@Positive(message = "Price must be positive")
private BigDecimal price;
@Min(value = 0, message = "Stock cannot be negative")
private Integer stock = 0;
}
BookService
package com.bookstore.service;
import com.bookstore.entity.Book;
import com.bookstore.exception.*;
import com.bookstore.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Slf4j
public class BookService {
private final BookRepository bookRepository;
public List<Book> findAll() {
return bookRepository.findAll();
}
public Book findById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Book", id));
}
@Transactional
public Book create(Book book) {
// Verificar unicidade do ISBN
if (bookRepository.existsByIsbn(book.getIsbn())) {
throw new ConflictException("ISBN already exists: " + book.getIsbn());
}
log.info("Creating book: {}", book.getTitle());
return bookRepository.save(book);
}
@Transactional
public Book update(Long id, Book updated) {
Book existing = findById(id);
// Verificar o ISBN se alterado
if (!existing.getIsbn().equals(updated.getIsbn())
&& bookRepository.existsByIsbn(updated.getIsbn())) {
throw new ConflictException("ISBN already exists: " + updated.getIsbn());
}
existing.setTitle(updated.getTitle());
existing.setAuthor(updated.getAuthor());
existing.setIsbn(updated.getIsbn());
existing.setPrice(updated.getPrice());
existing.setStock(updated.getStock());
return bookRepository.save(existing);
}
@Transactional
public void delete(Long id) {
Book book = findById(id);
bookRepository.delete(book);
log.info("Deleted book: {}", id);
}
@Transactional
public void decreaseStock(Long id, int quantity) {
Book book = findById(id);
if (book.getStock() < quantity) {
throw new BusinessRuleException(
"INSUFFICIENT_STOCK",
String.format("Not enough stock. Available: %d, Requested: %d",
book.getStock(), quantity)
);
}
book.setStock(book.getStock() - quantity);
bookRepository.save(book);
}
}
BookController
package com.bookstore.controller;
import com.bookstore.entity.Book;
import com.bookstore.service.BookService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/books")
@RequiredArgsConstructor
public class BookController {
private final BookService bookService;
@GetMapping
public List<Book> getAll() {
return bookService.findAll();
}
@GetMapping("/{id}")
public Book getById(@PathVariable Long id) {
return bookService.findById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Book create(@Valid @RequestBody Book book) {
return bookService.create(book);
}
@PutMapping("/{id}")
public Book update(@PathVariable Long id, @Valid @RequestBody Book book) {
return bookService.update(id, book);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
bookService.delete(id);
}
@PostMapping("/{id}/decrease-stock")
public void decreaseStock(@PathVariable Long id, @RequestParam int quantity) {
bookService.decreaseStock(id, quantity);
}
}
Parte 6: Testes com cURL
Teste 1: Criar um livro válido
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{
"title": "Spring Boot in Action",
"author": "Craig Walls",
"isbn": "9781617292545",
"price": 45.99,
"stock": 10
}'
Resposta esperada: 201 Created
Teste 2: Livro não encontrado (404)
curl http://localhost:8080/api/books/999
Resposta esperada:
{
"code": "RESOURCE_NOT_FOUND",
"message": "Book with ID '999' not found",
"timestamp": "2024-01-15T10:30:00",
"path": "/api/books/999"
}
Teste 3: ISBN duplicado (409)
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{
"title": "Another Book",
"author": "Author",
"isbn": "9781617292545",
"price": 29.99,
"stock": 5
}'
Resposta esperada:
{
"code": "CONFLICT",
"message": "ISBN already exists: 9781617292545",
"timestamp": "...",
"path": "/api/books"
}
Teste 4: Validação falhou (400)
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{
"title": "",
"author": "",
"isbn": "123",
"price": -10,
"stock": -5
}'
Resposta esperada:
{
"code": "VALIDATION_FAILED",
"message": "Validation failed",
"timestamp": "...",
"path": "/api/books",
"errors": [
{"field": "title", "message": "Title is required", "rejectedValue": ""},
{"field": "isbn", "message": "ISBN must be 13 digits", "rejectedValue": "123"},
{"field": "price", "message": "Price must be positive", "rejectedValue": -10}
]
}
Teste 5: Estoque insuficiente (422)
curl -X POST "http://localhost:8080/api/books/1/decrease-stock?quantity=100"
Resposta esperada:
{
"code": "INSUFFICIENT_STOCK",
"message": "Not enough stock. Available: 10, Requested: 100",
"timestamp": "...",
"path": "/api/books/1/decrease-stock"
}
Parte 7: Gabarito
Clique para ver o GlobalExceptionHandler completo
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleResourceNotFound(ResourceNotFoundException ex, HttpServletRequest request) {
log.warn("Resource not found: {}", ex.getMessage());
return ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), request.getRequestURI());
}
@ExceptionHandler(ConflictException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ErrorResponse handleConflict(ConflictException ex, HttpServletRequest request) {
log.warn("Conflict: {}", ex.getMessage());
return ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), request.getRequestURI());
}
@ExceptionHandler(BusinessRuleException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public ErrorResponse handleBusinessRule(BusinessRuleException ex, HttpServletRequest request) {
log.warn("Business rule violation: {} - {}", ex.getErrorCode(), ex.getMessage());
return ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), request.getRequestURI());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
List<ErrorResponse.FieldError> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(e -> new ErrorResponse.FieldError(
e.getField(),
e.getDefaultMessage(),
e.getRejectedValue()
))
.collect(Collectors.toList());
ErrorResponse response = ErrorResponse.of("VALIDATION_FAILED", "Validation failed", request.getRequestURI());
response.setErrors(errors);
return response;
}
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleConstraintViolation(ConstraintViolationException ex, HttpServletRequest request) {
List<ErrorResponse.FieldError> errors = ex.getConstraintViolations()
.stream()
.map(v -> new ErrorResponse.FieldError(
v.getPropertyPath().toString(),
v.getMessage(),
v.getInvalidValue()
))
.collect(Collectors.toList());
ErrorResponse response = ErrorResponse.of("CONSTRAINT_VIOLATION", "Constraint violation", request.getRequestURI());
response.setErrors(errors);
return response;
}
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleIllegalArgument(IllegalArgumentException ex, HttpServletRequest request) {
log.warn("Bad request: {}", ex.getMessage());
return ErrorResponse.of("BAD_REQUEST", ex.getMessage(), request.getRequestURI());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGeneric(Exception ex, HttpServletRequest request) {
log.error("Unexpected error at {}", request.getRequestURI(), ex);
return ErrorResponse.of("INTERNAL_ERROR", "An unexpected error occurred", request.getRequestURI());
}
}
Checklist
- Todas as exceções personalizadas criadas
- GlobalExceptionHandler completo
- Respostas JSON estruturadas
- Códigos HTTP corretos (400, 404, 409, 422, 500)
- Logs apropriados (warn para erros de cliente, error para erros de servidor)
- Testes cURL bem-sucedidos