Duas das fontes mais comuns de bugs em aplicações Java são a ausência de valores
onde se espera um valor — o infame NullPointerException — e a
propagação silenciosa de erros até ao cliente de formas inconsistentes ou
informativamente perigosas. Este módulo trata os dois problemas: Optional
para tornar a ausência explícita e segura, e uma arquitectura de tratamento de erros
que garante respostas consistentes, sem vazar informação de sistema para o exterior.
Em C, um ponteiro pode ser NULL — e é tua responsabilidade verificar
antes de desreferenciar. Se não o fizeres, o programa termina com segmentation fault.
O compilador não te obriga a verificar; a disciplina é tua.
Em Java, qualquer referência a um objecto pode ser null. Chamar
um método numa referência nula lança uma NullPointerException
em runtime — sem aviso em compile-time. Tony Hoare, o inventor do conceito
de null, chamou-lhe o "billion-dollar mistake": décadas de bugs, crashes e
vulnerabilidades de segurança causados por uma única decisão de design.
// O problema clássico
public Document getDocument(Long id) {
Document doc = repository.findById(id); // pode retornar null
return doc.withAuditStamp(); // NullPointerException se doc == null
// o compilador não avisa. o erro só aparece em runtime, em produção.
}
// A "solução" ingénua — verificações null em cascata
public Document getDocument(Long id) {
Document doc = repository.findById(id);
if (doc == null) {
return null; // o chamador também tem de verificar null...
}
AuditStamp stamp = auditService.getStamp(id);
if (stamp == null) {
return doc;
}
return doc.withAuditStamp(stamp);
// código defensivo que obscurece a lógica real
}
Optional<T> é um contentor que representa um valor que
pode ou não estar presente. Em vez de retornar null
quando algo não existe, retornas um Optional.empty().
O tipo do retorno comunica ao compilador — e ao programador — que a ausência
é um caso legítimo que tem de ser tratado.
// Criação de Optional
Optional<Document> presente = Optional.of(document); // valor garantido não-null
Optional<Document> talvez = Optional.ofNullable(value); // pode ser null — usa sempre este com dados externos
Optional<Document> vazio = Optional.empty(); // ausência explícita
// Optional.of() lança NullPointerException se o valor for null.
// Optional.ofNullable() converte null em Optional.empty() sem excepção.
// Regra: quando o valor vem de uma fonte externa (BD, API, input), usar ofNullable.
// Consumir um Optional — as formas correctas
Optional<Document> result = repository.findByIdAndOwnerId(id, ownerId);
// 1. orElseThrow — o mais usado em APIs REST
// se presente: retorna o valor
// se vazio: lança a excepção fornecida
Document doc = result.orElseThrow(
() -> new ResourceNotFoundException("Document not found")
);
// 2. orElse — valor por omissão
Document doc = result.orElse(Document.empty());
// 3. orElseGet — valor por omissão computado lazily (preferível a orElse para objectos pesados)
Document doc = result.orElseGet(() -> documentFactory.createDefault());
// 4. ifPresent — executar uma acção apenas se o valor existir
result.ifPresent(doc -> auditService.log("accessed", doc.getId()));
// 5. ifPresentOrElse — branch explícito para ambos os casos (Java 9+)
result.ifPresentOrElse(
doc -> auditService.log("accessed", doc.getId()),
() -> auditService.log("not found", id)
);
// 6. map — transformar o valor se presente
Optional<String> title = result.map(Document::getTitle);
// 7. flatMap — transformar quando a função também retorna Optional
Optional<Owner> owner = result.flatMap(doc -> userRepository.findById(doc.getOwnerId()));
Optional foi desenhado para ser o tipo de retorno de métodos que
podem não ter resultado. Usá-lo fora desse contexto cria código mais complexo
sem benefício.
// ❌ Anti-padrão 1: optional.get() sem verificação prévia
// Equivalente a desreferenciar um ponteiro sem verificar null — derrota o propósito
Optional<Document> result = repository.findById(id);
Document doc = result.get(); // lança NoSuchElementException se vazio — pior que null
// ✅ Correcto: usar orElseThrow com excepção de domínio
Document doc = result.orElseThrow(() -> new ResourceNotFoundException("Document not found"));
// ❌ Anti-padrão 2: Optional como campo de classe
// Optional não é Serializable — quebra JPA, Jackson, e outros frameworks
public class Document {
private Optional<String> description; // não fazer isto
}
// ✅ Correcto: campo nullable, Optional apenas no retorno do getter
public class Document {
private String description; // pode ser null internamente
public Optional<String> getDescription() {
return Optional.ofNullable(description); // Optional apenas na interface pública
}
}
// ❌ Anti-padrão 3: Optional como parâmetro de método
// Força o chamador a embrulhar valores em Optional — verboso e inútil
public Document findDocument(Optional<Long> id) { ... }
// ✅ Correcto: parâmetro nullable com @Nullable, ou dois métodos distintos
public Optional<Document> findDocument(Long id) { ... }
public Optional<Document> findDocumentByTitle(String title) { ... }
// ❌ Anti-padrão 4: isPresent() + get() em vez de orElseThrow/map
if (result.isPresent()) {
return result.get(); // verboso — usa orElseThrow directamente
} else {
throw new ResourceNotFoundException("not found");
}
// ✅ Correcto: encadear directamente
return result.orElseThrow(() -> new ResourceNotFoundException("not found"));
Java distingue dois tipos de excepções. As checked são verificadas
pelo compilador — um método que as lança tem de as declarar na assinatura com
throws, e quem o chama tem de as tratar ou propagar. As
unchecked (subclasses de RuntimeException) não
são verificadas pelo compilador — podem ser lançadas e propagadas livremente.
| Tipo | Hierarquia | Verificação | Quando usar |
|---|---|---|---|
| Checked | Exception (não RuntimeException) |
Compilador obriga a tratar ou declarar | Erros recuperáveis esperados: ficheiro não encontrado, falha de rede. O chamador pode fazer algo útil. |
| Unchecked | RuntimeException |
Nenhuma — propagam livremente | Erros de programação ou condições de negócio: argumento inválido, recurso não encontrado, acesso não autorizado. |
// A hierarquia de excepções Java relevante para Spring Boot:
//
// Throwable
// ├── Error (erros da JVM — não apanhar: OutOfMemoryError, StackOverflowError)
// └── Exception
// ├── IOException (checked — ficheiros, rede)
// ├── SQLException (checked — JDBC directo)
// └── RuntimeException (unchecked — base das excepções de aplicação)
// ├── NullPointerException
// ├── IllegalArgumentException
// ├── IllegalStateException
// └── (as tuas excepções de domínio herdam daqui)
// Spring Boot traduz SQLException para DataAccessException (unchecked)
// via o mecanismo do @Repository — nunca apanhas SQLException directamente em Spring.
RuntimeException para excepções
de aplicação. A razão é pragmática: checked exceptions obrigam a declarar
throws em cada camada da pilha de chamadas, poluindo as assinaturas
dos métodos com detalhes de implementação. Em aplicações web, os erros são
tratados centralmente pelo @ControllerAdvice — não em cada método
individual.
Em vez de usar excepções genéricas como IllegalArgumentException
ou strings de erro hardcoded, cria excepções de domínio específicas para a
tua aplicação. Cada excepção representa uma condição de negócio clara
e mapeia directamente para um HTTP status code.
// Excepção base da aplicação — todas as excepções de negócio herdam daqui
public abstract class ApplicationException extends RuntimeException {
private final int statusCode;
protected ApplicationException(String message, int statusCode) {
super(message);
this.statusCode = statusCode;
}
public int getStatusCode() { return statusCode; }
}
// 404 — recurso não encontrado (também usado para IDOR — ver mais abaixo)
public class ResourceNotFoundException extends ApplicationException {
public ResourceNotFoundException(String message) {
super(message, 404);
}
}
// 401 — não autenticado (sem token ou token inválido)
public class UnauthorizedException extends ApplicationException {
public UnauthorizedException(String message) {
super(message, 401);
}
}
// 403 — autenticado mas sem permissão (role insuficiente)
public class ForbiddenException extends ApplicationException {
public ForbiddenException(String message) {
super(message, 403);
}
}
// 409 — conflito de negócio (email já registado, estado inválido)
public class ConflictException extends ApplicationException {
public ConflictException(String message) {
super(message, 409);
}
}
// 422 — entidade não processável (validação de negócio, não de formato)
public class UnprocessableEntityException extends ApplicationException {
public UnprocessableEntityException(String message) {
super(message, 422);
}
}
// Uso nos serviços:
public Account getAccount(Long accountId, Long authenticatedUserId) {
return repository.findByIdAndOwnerId(accountId, authenticatedUserId)
.orElseThrow(() -> new ResourceNotFoundException("Account not found"));
// nota: não dizemos "Account not found OR not yours" — ver secção de segurança
}
Sem tratamento centralizado, cada excepção não apanhada propaga até ao Spring Boot,
que retorna uma resposta de erro genérica com stack traces expostos ou HTML de erro
por omissão — inaceitável em produção. O @ControllerAdvice define
handlers globais que interceptam excepções em qualquer controller e produzem
respostas JSON consistentes.
// Modelo de resposta de erro — estrutura consistente em toda a API
public record ErrorResponse(
String timestamp, // quando ocorreu o erro
int status, // HTTP status code
String error, // descrição do status (ex: "Not Found")
String message, // mensagem para o cliente — sem detalhes internos
String path // endpoint que gerou o erro
) {
public static ErrorResponse of(int status, String error,
String message, String path) {
return new ErrorResponse(
Instant.now().toString(), status, error, message, path
);
}
}
// Handler global de excepções
@RestControllerAdvice // = @ControllerAdvice + @ResponseBody
public class GlobalExceptionHandler {
// Apanhar todas as excepções de domínio numa só regra
@ExceptionHandler(ApplicationException.class)
public ResponseEntity<ErrorResponse> handleApplicationException(
ApplicationException ex, HttpServletRequest request) {
HttpStatus status = HttpStatus.resolve(ex.getStatusCode());
ErrorResponse body = ErrorResponse.of(
ex.getStatusCode(),
status != null ? status.getReasonPhrase() : "Error",
ex.getMessage(),
request.getRequestURI()
);
return ResponseEntity.status(ex.getStatusCode()).body(body);
}
// Erros de validação (@Valid falhou)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
MethodArgumentNotValidException ex, HttpServletRequest request) {
// agregar todas as mensagens de validação numa só string
String message = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.joining(", "));
ErrorResponse body = ErrorResponse.of(400, "Bad Request", message, request.getRequestURI());
return ResponseEntity.badRequest().body(body);
}
// Fallback — qualquer excepção não prevista
// IMPORTANTE: nunca expor ex.getMessage() aqui — pode conter detalhes internos
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(
Exception ex, HttpServletRequest request) {
// log interno com detalhe — o cliente não vê isto
log.error("Unhandled exception on {}: {}", request.getRequestURI(), ex.getMessage(), ex);
ErrorResponse body = ErrorResponse.of(
500, "Internal Server Error",
"An unexpected error occurred", // mensagem genérica — sem stack trace
request.getRequestURI()
);
return ResponseEntity.internalServerError().body(body);
}
}
// Exemplo de resposta JSON para ResourceNotFoundException:
{
"timestamp": "2026-06-01T10:30:00Z",
"status": 404,
"error": "Not Found",
"message": "Account not found",
"path": "/api/accounts/42"
}
// Exemplo de resposta para erro de validação:
{
"timestamp": "2026-06-01T10:31:00Z",
"status": 400,
"error": "Bad Request",
"message": "email: must be a valid email address, password: size must be between 8 and 100",
"path": "/api/auth/register"
}
Bean Validation (JSR-380) permite declarar regras de validação directamente nos
DTOs com anotações. O Spring Boot integra-o automaticamente — basta anotar o
parâmetro do controller com @Valid e o Spring valida o objecto
antes de o entregar ao método.
// DTO de registo com validação declarativa
public record RegisterRequest(
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
String name,
@NotBlank(message = "Email is required")
@Email(message = "must be a valid email address")
String email,
@NotBlank(message = "Password is required")
@Size(min = 8, max = 100, message = "Password must be at least 8 characters")
String password
) {}
// Controller — @Valid activa a validação antes de o método ser executado
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/register")
public ResponseEntity<Void> register(@Valid @RequestBody RegisterRequest request) {
// se a validação falhar, MethodArgumentNotValidException é lançada
// o GlobalExceptionHandler apanha-a e retorna 400 com as mensagens
authService.register(request);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
// Anotações de validação mais comuns:
// @NotNull — não pode ser null (qualquer tipo)
// @NotBlank — não pode ser null, vazio, ou só espaços (String)
// @NotEmpty — não pode ser null ou vazio (String, Collection, Map, Array)
// @Size — tamanho entre min e max (String, Collection)
// @Email — formato de email válido
// @Min / @Max — valor numérico mínimo / máximo
// @Positive — número positivo (> 0)
// @PositiveOrZero — número >= 0
// @Pattern — validação por regex
// @Future — data no futuro
// @Past — data no passado
Dois status codes de segurança frequentemente confundidos têm semânticas distintas e consequências diferentes para o cliente.
| Status | Significado | Quando retornar |
|---|---|---|
401 Unauthorized |
Não autenticado | O pedido não tem credenciais válidas. Sem token, token expirado, token inválido. O cliente deve autenticar-se. |
403 Forbidden |
Autenticado mas sem permissão | O utilizador está autenticado mas o seu role não tem acesso ao recurso. Ex: utilizador normal a aceder a endpoint de admin. |
404 Not Found |
Recurso não existe (ou não é teu) | Recurso inexistente ou recurso que existe mas não pertence ao utilizador autenticado. Ver anti-IDOR abaixo. |
// Configurar respostas de segurança no Spring Security
// Em vez de redireccionamentos para páginas de login (comportamento MVC),
// uma API REST deve retornar JSON com os status codes correctos.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
// ...
.exceptionHandling(ex -> ex
// 401 — pedido sem autenticação válida
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("""
{"status":401,"error":"Unauthorized","message":"Authentication required"}
""");
})
// 403 — autenticado mas sem permissão de role
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("""
{"status":403,"error":"Forbidden","message":"Insufficient permissions"}
""");
})
)
.build();
}
As mensagens de erro são um vector de information disclosure — um atacante pode usar as diferenças entre respostas de erro para inferir o estado interno do sistema. A regra geral é: mensagens de erro para o cliente devem ser suficientemente informativas para o utilizador legítimo, mas nunca revelar detalhes de implementação, existência de recursos alheios, ou estrutura da base de dados.
// ❌ Mensagens que vazam informação
// Vaza que o email existe no sistema — permite enumeração de utilizadores
throw new UnauthorizedException("Password incorrect for user " + email);
// Vaza que o recurso existe mas pertence a outro utilizador
throw new ForbiddenException("Account 42 belongs to a different user");
// Vaza detalhes da query SQL
throw new RuntimeException("ERROR: column owner_id does not exist at character 45");
// Vaza stack trace para o cliente
response.getWriter().write(ex.getStackTrace().toString());
// ✅ Mensagens seguras
// Login: mesma mensagem para email errado e password errada
throw new UnauthorizedException("Invalid credentials");
// Recurso alheio: mesma mensagem que recurso inexistente
throw new ResourceNotFoundException("Account not found");
// Erros internos: mensagem genérica para o cliente, detalhe no log interno
log.error("Database error fetching account {}: {}", id, ex.getMessage(), ex);
throw new RuntimeException("An unexpected error occurred"); // apanhado pelo GlobalExceptionHandler
Este é um dos princípios de segurança mais importantes na arquitectura deste projecto e merece atenção explícita. Quando um utilizador tenta aceder a um recurso que existe na base de dados mas pertence a outro utilizador, há dois comportamentos possíveis:
// Comportamento vulnerável — confirma que o recurso existe
// GET /api/accounts/42 (o utilizador autenticado é o 99, não o dono da conta 42)
// Query vulnerável: apenas verifica existência
SELECT * FROM accounts WHERE id = 42;
// → encontra a conta → retorna 403 Forbidden
// O que o atacante aprende:
// "A conta 42 existe. Não é minha mas existe.
// Vou tentar explorar outro vector para a aceder."
// Ao iterar IDs (42, 43, 44...) consegue mapear todos os recursos do sistema.
// Comportamento seguro — não confirma que o recurso existe
// GET /api/accounts/42 (o utilizador autenticado é o 99, não o dono da conta 42)
// Query segura: verifica existência E propriedade em simultâneo
SELECT * FROM accounts WHERE id = 42 AND owner_id = 99;
// → 0 resultados (a conta existe mas owner_id é diferente)
// → Optional.empty() → ResourceNotFoundException → 404 Not Found
// O que o atacante aprende:
// "A conta 42 não existe (ou não é minha). Não sei distinguir os dois casos."
// Enumerar IDs torna-se inútil — a resposta é sempre a mesma.
// Implementação completa — da query ao HTTP status
// Repository — a query já incorpora o isolamento lógico
@Repository
public class AccountRepository {
public Optional<Account> findByIdAndOwnerId(Long id, Long ownerId) {
return jdbc.query(
"SELECT * FROM accounts WHERE id = ? AND owner_id = ?",
new AccountRowMapper(), id, ownerId
).stream().findFirst();
// se id existe mas owner_id não corresponde → Optional.empty()
// indistinguível de id que não existe
}
}
// Service — converte Optional.empty() em ResourceNotFoundException
@Service
public class AccountService {
public Account getAccount(Long accountId, Long authenticatedUserId) {
return repository.findByIdAndOwnerId(accountId, authenticatedUserId)
.orElseThrow(() -> new ResourceNotFoundException("Account not found"));
// mesma excepção, mesma mensagem — independentemente do motivo
}
}
// GlobalExceptionHandler — converte ResourceNotFoundException em 404
@ExceptionHandler(ApplicationException.class)
public ResponseEntity<ErrorResponse> handleApplicationException(
ApplicationException ex, HttpServletRequest request) {
// ResourceNotFoundException tem statusCode = 404
// a resposta é sempre 404 "Account not found"
// o atacante não consegue distinguir "não existe" de "não é teu"
}
403 Forbidden quando o recurso existe mas pertence a outro
utilizador confirma a existência do recurso. Um atacante que itera IDs consegue
mapear todos os recursos do sistema simplesmente pela diferença entre
404 (não existe) e 403 (existe mas não é teu).
A query WHERE id = ? AND owner_id = ? seguida de 404
é a defesa correcta — a informação de propriedade nunca chega ao cliente.
Optional<T> quando o resultado pode não existir — nunca null.orElseThrow, map, ifPresent — nunca com get() sem verificação.RuntimeException (unchecked) e mapeiam para HTTP status codes.@RestControllerAdvice centraliza o tratamento de excepções — sem try/catch nos controllers.Exception.class) nunca expõe mensagens internas — retorna mensagem genérica e faz log interno.@Valid, @NotBlank, @Email) — nunca validação manual com if/else.AND owner_id = ? seguida de 404 é a defesa correcta.