Exception handling — @ExceptionHandler, @ControllerAdvice, Problem Details RFC 9457
Spring có 3 layer exception handling: @ExceptionHandler local, @ControllerAdvice global, ResponseStatusException. Bài này bóc 3 HandlerExceptionResolver default, ProblemDetail Boot 3 native (RFC 9457), pattern domain exception → HTTP status, security cảnh báo (đừng leak stack trace), và testing exception handler.
Bài 04 đã chỉ ra status code 4xx/5xx có ngữ nghĩa. Bài này bóc cách thực hiện trong code: làm sao biến exception trong service thành response 4xx/5xx có format chuẩn?
Đây là module quan trọng nhất Module 03 cho production — error response format ảnh hưởng trực tiếp đến UX của client (frontend, mobile, integration). Spring Boot 3 đưa Problem Details RFC 9457 thành native — bỏ luôn pattern Map<String, Object> ad-hoc cũ.
1. 3 layer exception handling
flowchart TB
Excp["Exception thrown"]
Local["Layer 1: @ExceptionHandler trong Controller<br/>(local — chi controller do)"]
Global["Layer 2: @ControllerAdvice<br/>(global — moi controller)"]
Default["Layer 3: HandlerExceptionResolver default<br/>(Spring built-in)"]
Response["HTTP Response 4xx/5xx"]
Excp --> Local
Local -->|"khong handle"| Global
Global -->|"khong handle"| Default
Default --> Response
Local -->|"handled"| Response
Global -->|"handled"| Response
style Global fill:#fef3c7| Layer | Scope | Use case |
|---|---|---|
@ExceptionHandler trong controller | Chỉ controller đó | Hiếm — exception đặc thù 1 endpoint |
@ControllerAdvice / @RestControllerAdvice | Toàn app | Standard 2026 — pattern chính |
HandlerExceptionResolver Spring built-in | Toàn Spring framework | Default fallback |
Spring iterate 3 layer theo thứ tự — local trước, advice global sau, default cuối.
2. Layer 3 — Default HandlerExceptionResolver
Module 03 bài 01 đã giới thiệu 3 resolver default. Detail:
2.1 ExceptionHandlerExceptionResolver
Handle method @ExceptionHandler trong @Controller/@RestControllerAdvice. Đây là layer 1 + 2 (custom của bạn).
2.2 ResponseStatusExceptionResolver
Handle exception annotated @ResponseStatus:
@ResponseStatus(HttpStatus.NOT_FOUND)
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long id) {
super("Order " + id + " not found");
}
}
@Service
public class OrderService {
public OrderDto findById(Long id) {
return repo.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
}
}
Service throw OrderNotFoundException → resolver thấy @ResponseStatus(NOT_FOUND) → return 404. Body default Spring template (HTML error page hoặc empty).
2.3 DefaultHandlerExceptionResolver
Handle exception built-in của Spring MVC:
| Exception | Status | Cause |
|---|---|---|
MethodArgumentNotValidException | 400 | Validation @Valid fail |
MethodArgumentTypeMismatchException | 400 | Path variable type wrong |
HttpRequestMethodNotSupportedException | 405 | Wrong HTTP method |
HttpMediaTypeNotSupportedException | 415 | Wrong Content-Type |
HttpMediaTypeNotAcceptableException | 406 | Wrong Accept |
MissingServletRequestParameterException | 400 | Missing required param |
HttpMessageNotReadableException | 400 | Cannot parse JSON body |
MaxUploadSizeExceededException | 413 | File too large |
NoHandlerFoundException | 404 | URL không match handler |
NoResourceFoundException | 404 | Static resource không có |
AsyncRequestTimeoutException | 503 | Async timeout |
Boot 3+ default body của các exception này là ProblemDetail JSON (RFC 9457) — section 4.
3. Layer 2 — @RestControllerAdvice + @ExceptionHandler
Pattern chính của 2026:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
pd.setTitle("Order not found");
pd.setDetail(ex.getMessage());
pd.setProperty("orderId", ex.getOrderId());
return pd;
}
@ExceptionHandler(DuplicateOrderException.class)
public ProblemDetail handleDuplicate(DuplicateOrderException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.CONFLICT);
pd.setTitle("Duplicate order");
pd.setDetail(ex.getMessage());
return pd;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Validation failed");
pd.setDetail("Request body has invalid fields");
List<Map<String, String>> violations = ex.getBindingResult().getAllErrors().stream()
.map(err -> Map.of(
"field", ((FieldError) err).getField(),
"message", err.getDefaultMessage(),
"rejectedValue", String.valueOf(((FieldError) err).getRejectedValue())
))
.toList();
pd.setProperty("violations", violations);
return pd;
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleAll(Exception ex) {
log.error("Unhandled exception", ex);
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
pd.setTitle("Internal server error");
pd.setDetail("An unexpected error occurred. Please try again later.");
// KHONG include stack trace — security
return pd;
}
}
Phân tích:
@RestControllerAdvice=@ControllerAdvice+@ResponseBody(parallel với@RestController).- Mỗi method
@ExceptionHandler(X.class)handle exception type X (và subclass). - Method return
ProblemDetail→ Spring serialize JSON vớiContent-Type: application/problem+json. - Nếu thiếu method → fall back layer 3 default.
3.1 Thứ tự match exception
Spring tìm method với type closest match:
@ExceptionHandler(RuntimeException.class) // catch-all RuntimeException
public ... handleRuntime(RuntimeException ex) { ... }
@ExceptionHandler(OrderNotFoundException.class) // specific
public ... handleNotFound(OrderNotFoundException ex) { ... }
Order throw OrderNotFoundException (subclass RuntimeException):
- Spring tìm specific match → handler thứ 2.
- Không match → handler thứ 1 (RuntimeException).
- Không match nữa → layer 3 default.
3.2 @RestControllerAdvice scope
Default: apply cho mọi controller. Scope hẹp hơn:
@RestControllerAdvice(basePackages = "com.olhub.api.v1") // chi v1
@RestControllerAdvice(annotations = ApiController.class) // controller có annotation
@RestControllerAdvice(assignableTypes = {OrderController.class}) // class cụ thể
Pattern: 1 advice global cho toàn app + advice riêng cho admin API (custom error format).
4. Problem Details — RFC 9457 chi tiết
RFC 9457 (kế thừa RFC 7807) định nghĩa format chuẩn cho HTTP API error response. Boot 3+ có ProblemDetail class native.
4.1 Format chuẩn
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "https://api.olhub.org/errors/order-not-found",
"title": "Order not found",
"status": 404,
"detail": "Order 42 not found",
"instance": "/api/v1/orders/42",
"orderId": 42,
"timestamp": "2026-04-15T10:00:00Z"
}
5 field standard:
| Field | Required | Description |
|---|---|---|
type | Optional | URI định nghĩa error type. Default about:blank. |
title | Optional | Tiêu đề ngắn (1 dòng), không phụ thuộc instance. |
status | Required | HTTP status code (duplicate header — convenience cho client). |
detail | Optional | Detail cụ thể cho instance này. |
instance | Optional | URI identify instance error (vd request URL). |
Custom field free — orderId, timestamp, violations, ... tuỳ ý.
4.2 Boot 3+ enable Problem Details
spring:
mvc:
problemdetails:
enabled: true
Property này:
- Bật default Problem Details cho exception built-in MVC (validation, type mismatch, ...). Không bật → response default HTML/text.
- Set Content-Type
application/problem+jsoncho response.
Default output cho MethodArgumentNotValidException:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request content.",
"instance": "/api/v1/orders"
}
Bạn override qua @RestControllerAdvice để add custom field (violations, requestId).
4.3 ProblemDetail API
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
// Hoac voi message
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "Order 42 not found");
// Custom field
pd.setType(URI.create("https://api.olhub.org/errors/order-not-found"));
pd.setTitle("Order not found");
pd.setInstance(URI.create("/api/v1/orders/42"));
pd.setProperty("orderId", 42L);
pd.setProperty("timestamp", Instant.now());
setProperty(key, value) add field custom. Spring serialize toàn bộ object → JSON.
4.4 type URI — link đến doc
type lý tưởng trỏ về documentation page giải thích error:
https://api.olhub.org/errors/order-not-found
Trang này document:
- Error nghĩa là gì.
- Cách fix.
- Status code expected.
Pattern enterprise — discoverable error. Public API như Stripe, GitHub đều có docs page này.
5. Domain exception → HTTP status mapping
Pattern hợp lý: domain exception không nhận biết HTTP. Mapping qua @RestControllerAdvice:
// Domain layer — POJO exception
public class OrderNotFoundException extends RuntimeException {
private final Long orderId;
public OrderNotFoundException(Long orderId) {
super("Order " + orderId + " not found");
this.orderId = orderId;
}
public Long getOrderId() { return orderId; }
}
public class InsufficientStockException extends RuntimeException {
private final String sku;
private final int requested;
private final int available;
// ...
}
public class PaymentDeclinedException extends RuntimeException { ... }
public class InvalidOrderStateException extends RuntimeException { ... }
// Web layer — mapping exception → HTTP status
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
return problem(HttpStatus.NOT_FOUND, "Order not found", ex.getMessage())
.property("orderId", ex.getOrderId());
}
@ExceptionHandler(InsufficientStockException.class)
public ProblemDetail handleInsufficientStock(InsufficientStockException ex) {
return problem(HttpStatus.CONFLICT, "Insufficient stock", ex.getMessage())
.property("sku", ex.getSku())
.property("requested", ex.getRequested())
.property("available", ex.getAvailable());
}
@ExceptionHandler(PaymentDeclinedException.class)
public ProblemDetail handlePaymentDeclined(PaymentDeclinedException ex) {
return problem(HttpStatus.PAYMENT_REQUIRED, "Payment declined", ex.getMessage());
}
@ExceptionHandler(InvalidOrderStateException.class)
public ProblemDetail handleInvalidState(InvalidOrderStateException ex) {
return problem(HttpStatus.UNPROCESSABLE_ENTITY, "Invalid order state", ex.getMessage());
}
}
Helper method:
private static ProblemDetailBuilder problem(HttpStatus status, String title, String detail) {
return new ProblemDetailBuilder(status, title, detail);
}
Lợi ích pattern:
- Domain pure: không import
org.springframework.http.*. - Service layer test ngoài Spring:
assertThrows(OrderNotFoundException.class, ...). - Web layer concentrated mapping: thay đổi HTTP status code không động đến service.
6. ResponseStatusException — shortcut
Thay throw exception custom + setup advice, throw ResponseStatusException trực tiếp:
@GetMapping("/orders/{id}")
public OrderDto get(@PathVariable Long id) {
return orderService.findOptional(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Order " + id + " not found"
));
}
ResponseStatusException được Spring built-in handle → return 4xx với body Problem Details.
Khi nào dùng:
- Quick prototype.
- Endpoint một-off — không reuse exception.
- App nhỏ không cần domain exception layer.
Khi nào không:
- App enterprise — domain exception clean hơn.
- Service layer test cần exception specific.
7. Validation error response — chi tiết
MethodArgumentNotValidException thrown khi @Valid @RequestBody fail. Default Boot 3 trả Problem Details basic:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request content.",
"instance": "/api/v1/orders"
}
Customize để include violations:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setType(URI.create("https://api.olhub.org/errors/validation"));
pd.setTitle("Validation failed");
pd.setDetail("Request body has " + ex.getBindingResult().getErrorCount() + " invalid fields");
pd.setInstance(URI.create(req.getRequestURI()));
List<Map<String, Object>> violations = ex.getBindingResult().getFieldErrors().stream()
.map(err -> {
Map<String, Object> v = new LinkedHashMap<>();
v.put("field", err.getField());
v.put("message", err.getDefaultMessage());
v.put("rejectedValue", err.getRejectedValue());
return v;
})
.toList();
pd.setProperty("violations", violations);
return pd;
}
Output:
{
"type": "https://api.olhub.org/errors/validation",
"title": "Validation failed",
"status": 400,
"detail": "Request body has 2 invalid fields",
"instance": "/api/v1/orders",
"violations": [
{"field": "customer", "message": "must not be blank", "rejectedValue": ""},
{"field": "total", "message": "must be positive", "rejectedValue": -10}
]
}
Client (frontend) parse violations → highlight field tương ứng. Pattern UX chuẩn.
8. Security — đừng leak stack trace
Cảnh báo nghiêm trọng: never include stack trace trong response body production.
// ANTI-PATTERN
@ExceptionHandler(Exception.class)
public ProblemDetail handleAll(Exception ex) {
ProblemDetail pd = ProblemDetail.forStatus(500);
pd.setDetail(ex.getMessage());
pd.setProperty("stackTrace", ExceptionUtils.getStackTrace(ex)); // KHONG BAO GIO
return pd;
}
Stack trace leak:
- Class structure internal (package, class name).
- Thư viện version (vulnerable version → exploit).
- DB schema (qua SQL exception trace).
Pattern an toàn:
@ExceptionHandler(Exception.class)
public ProblemDetail handleAll(Exception ex) {
String requestId = MDC.get("requestId");
log.error("Unhandled exception, requestId={}", requestId, ex); // log SERVER, không client
ProblemDetail pd = ProblemDetail.forStatus(500);
pd.setTitle("Internal server error");
pd.setDetail("An unexpected error occurred. Please contact support with request ID.");
pd.setProperty("requestId", requestId); // client xem requestId, lien he support
return pd;
}
Client nhận generic message + requestId. Server log full stack trace. Support team query log với requestId → debug.
Boot 3+ có property cho dev environment:
spring:
mvc:
problemdetails:
enabled: true
server:
error:
include-stacktrace: on_param # ?trace=true → include (chỉ dev)
include-binding-errors: on_param
include-message: always
Production: include-stacktrace: never. Dev: on_param để debug.
9. Testing exception handler
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean OrderService orderService;
@Test
void getNonExistent_returns404() throws Exception {
when(orderService.findById(99L))
.thenThrow(new OrderNotFoundException(99L));
mockMvc.perform(get("/api/v1/orders/99"))
.andExpect(status().isNotFound())
.andExpect(content().contentType("application/problem+json"))
.andExpect(jsonPath("$.title").value("Order not found"))
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.detail").value("Order 99 not found"))
.andExpect(jsonPath("$.orderId").value(99));
}
@Test
void invalidRequest_returns400WithViolations() throws Exception {
String invalidJson = """
{ "customer": "", "total": -10 }
""";
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.title").value("Validation failed"))
.andExpect(jsonPath("$.violations", hasSize(2)));
}
}
Module 06 (Testing) sẽ đào sâu MockMvc. Đây preview — quan trọng test exception path đầy đủ như happy path.
10. Vận hành production — error monitoring, security audit, 5xx runbook
Exception handler là layer trực tiếp đối mặt user khi có lỗi. Sai 1 chỗ → leak PII / stack trace → security incident. Hoặc 5xx spike không alert → user impact 30 phút trước khi on-call biết. Section này cover quy trình production cho error contract.
10.1 Error monitoring stack — 3 layer
Layer 1 — Structured log với requestId (đã setup từ Module 02 bài 06):
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
private final MeterRegistry meterRegistry;
@ExceptionHandler(Exception.class)
public ProblemDetail handleAll(Exception ex, HttpServletRequest req) {
String requestId = MDC.get("requestId");
log.error("Unhandled exception, path={}, requestId={}, exClass={}",
req.getRequestURI(), requestId, ex.getClass().getSimpleName(), ex);
// Increment metric voi label exception class
meterRegistry.counter("api.exception",
"exception", ex.getClass().getSimpleName(),
"endpoint", req.getRequestURI(),
"method", req.getMethod()).increment();
ProblemDetail pd = ProblemDetail.forStatus(500);
pd.setTitle("Internal server error");
pd.setDetail("An unexpected error occurred. Contact support with requestId.");
pd.setProperty("requestId", requestId);
return pd;
}
}
Layer 2 — Micrometer metrics với label phân loại:
| Metric | Label | Use case |
|---|---|---|
api.exception | exception, endpoint, method | Count exception per type/endpoint |
http.server.requests (auto) | status, uri | Phân biệt 4xx vs 5xx rate |
spring.security.authentications.failure | reason | Auth failure tracking |
Layer 3 — APM tracing (Micrometer Tracing → OTLP → Jaeger/Tempo):
Mỗi exception tạo span error tag. Trace timeline thấy đúng method nào throw — không phải đoán qua stack trace.
10.2 Alert pattern — phân biệt 4xx vs 5xx
| Status range | Owner | Alert? |
|---|---|---|
| 2xx | n/a | No |
| 3xx | n/a | No |
| 4xx (client error) | Client/UX team | Sustained spike → check API contract change |
| 5xx (server error) | Backend on-call | Always alert immediately |
Prometheus alert rules:
# 5xx spike — pages on-call
- alert: ServerErrorSpike
expr: |
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
/
sum(rate(http_server_requests_seconds_count[5m]))
> 0.01
for: 5m
labels:
severity: critical
annotations:
summary: "5xx error rate above 1% on {{ $labels.application }}"
# 4xx anomaly — slack channel only
- alert: ClientErrorAnomaly
expr: |
sum(rate(http_server_requests_seconds_count{status=~"4.."}[5m]))
> 2 * sum(rate(http_server_requests_seconds_count{status=~"4.."}[1h] offset 1d))
for: 10m
labels:
severity: warning
# Specific exception class spike — early warning
- alert: PaymentDeclinedSpike
expr: rate(api_exception_total{exception="PaymentDeclinedException"}[5m]) > 10
for: 5m
labels:
severity: warning
team: payments
10.3 Security audit — PII leak detection
Production checklist trước khi deploy global handler:
| Check | Pass criteria |
|---|---|
detail field generic? | Không chứa email, ID nội bộ, schema name |
| Stack trace exposed? | server.error.include-stacktrace: never ở prod |
| Exception message từ DB? | Wrap DataIntegrityViolationException → generic message |
| Tài liệu type URL? | Trỏ public docs page, không link internal wiki |
| requestId expose? | Có — để support lookup |
Auto-test PII leak với regex scan response:
@Test
void errorResponse_doesNotContainPii() throws Exception {
when(orderService.findById(any())).thenThrow(
new RuntimeException("DB error: user_email='[email protected]' duplicate"));
String response = mockMvc.perform(get("/api/orders/42"))
.andExpect(status().isInternalServerError())
.andReturn().getResponse().getContentAsString();
// Pattern PII — assert KHONG match trong response
assertThat(response).doesNotMatch("\\b\\w+@\\w+\\.\\w+\\b"); // email
assertThat(response).doesNotMatch("user_email|password|ssn"); // schema/keyword
}
Add test này vào CI cho mọi @ExceptionHandler catch-all. Bug PII leak rất khó phát hiện qua review thủ công.
10.4 Failure runbook — 5xx spike production
Mode 1 — 5xx spike từ 1 endpoint:
Triệu chứng: http_server_requests 5xx rate cụ thể endpoint vượt baseline.
Diagnose:
- Kibana/Loki query:
path:"/api/orders" AND log.level:ERROR→ gom log trong 5 phút spike. - Group by
exClassfield → top 3 exception type. - Pick
requestIdđại diện → trace OTLP/Jaeger → identify slow span hoặc downstream fail.
Remediate:
- Downstream fail (DB, cache, external API) → check connection pool / circuit breaker.
- Code bug release gần → rollback.
- Resource exhaustion → scale pod / increase pool.
Mode 2 — Global 5xx spike đột ngột:
Triệu chứng: tất cả endpoint 5xx — không tập trung 1 endpoint.
Diagnose: thường infrastructure fail.
- Check DB health (
/actuator/health). - Check Redis / cache (
HEALTH ping). - Check pod restart loop (
kubectl get pods --all-namespaces | grep CrashLoopBackOff).
Remediate: rollback nếu deploy gần. Escalate infra team nếu DB/cache fail.
Mode 3 — Validation 4xx spike (MethodArgumentNotValidException):
Triệu chứng: 4xx rate cao bất thường, validation failure spike.
Diagnose: API contract change client-side. Check log violations field — pattern field nào fail.
Remediate: communicate client team. Hoặc rollback API breaking change.
Mode 4 — Specific business exception spike (vd PaymentDeclinedException):
Triệu chứng: count exception class lên cao.
Diagnose: external service degrade (Stripe API timeout). Check downstream service health.
Remediate: enable circuit breaker fallback, surface user-friendly message qua advice.
Mode 5 — Silent error (4xx/5xx không spike nhưng user complaint):
Triệu chứng: feature broken nhưng error rate normal.
Diagnose: response trả 200 với body error (anti-pattern). Search status:200 AND message:"error" trong log.
Remediate: fix endpoint trả status code đúng. Thêm contract test catch 200-with-error pattern.
10.5 Error contract versioning
Public API: error response shape là part of contract. Đổi field name → break client.
3 quy tắc backward-compat:
- Add field OK — client ignore unknown.
- Remove field BREAKING — version bump.
- Change field type BREAKING — vd
orderIdtừ number sang string.
Strategy:
- API version trong path (
/api/v1,/api/v2) — error format có thể khác giữa version. - Deprecation header (
Sunset,Deprecation) — báo client migrate trước khi remove. - Contract test (Pact, Spring Cloud Contract) — verify error shape mỗi PR.
10.6 Disaster recovery — kill-switch error handler
Worst case: bug trong global handler → infinite loop, memory leak, hoặc throw chính nó.
Pattern bảo vệ — fallback handler thấp precedence nhất:
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE) // chay sau cung
public class FallbackHandler {
@ExceptionHandler(Throwable.class)
public ResponseEntity<String> ultimate(Throwable t) {
// Khong dung Spring serialization, khong call MDC, khong meter
// Defensive — chi tra response toi gian
return ResponseEntity.status(500)
.header("Content-Type", "application/problem+json")
.body("{\"status\":500,\"title\":\"Internal Server Error\"}");
}
}
Đây là layer cuối — handler chính bug → fallback đơn giản trả về client. Tránh handler chính throw → 500 default Spring với body unpredictable.
Test với chaos: inject RuntimeException trong handler khác → verify fallback active.
11. Pitfall tổng hợp
❌ Nhầm 1: Throw RuntimeException chung chung.
throw new RuntimeException("not found"); // fall back 500 default
✅ Throw exception specific (OrderNotFoundException) + map qua advice.
❌ Nhầm 2: Quên @RestControllerAdvice, dùng @ControllerAdvice cho REST API.
@ControllerAdvice
public class Handler {
@ExceptionHandler(...)
public ProblemDetail handle(...) { ... } // serialize sai
}
✅ @RestControllerAdvice = @ControllerAdvice + @ResponseBody. Method tự return body JSON.
❌ Nhầm 3: Catch exception trong controller method.
@GetMapping("/orders/{id}")
public ResponseEntity<?> get(@PathVariable Long id) {
try {
return ResponseEntity.ok(orderService.findById(id));
} catch (OrderNotFoundException ex) {
return ResponseEntity.notFound().build();
} catch (Exception ex) {
return ResponseEntity.status(500).body(...);
}
}
Verbose, lặp ở mọi method. Mất @RestControllerAdvice benefit.
✅ Throw exception, để advice handle.
❌ Nhầm 4: Stack trace trong response. ✅ Log server, response generic + requestId.
❌ Nhầm 5: Không handle exception generic catch-all.
@RestControllerAdvice
public class Handler {
@ExceptionHandler(BusinessException.class)
public ProblemDetail handle(BusinessException ex) { ... }
// KHONG catch Exception → unhandled → 500 default Spring
}
✅ Add @ExceptionHandler(Exception.class) cuối cùng — log + generic 500 response.
❌ Nhầm 6: Quên enable Problem Details Boot 3.
✅ spring.mvc.problemdetails.enabled=true. Boot 3.4+ default true cho mới project. Verify với /actuator/configprops.
❌ Nhầm 7: Trả status 200 với body error.
return ResponseEntity.ok(Map.of("error", "not found")); // status 200 voi error body
Client expect status 4xx → bug detect logic. Phá REST contract. ✅ HTTP status reflect error type. 200 chỉ cho success.
12. 📚 Deep Dive Spring Reference
RFC standards:
- RFC 9457 — Problem Details for HTTP APIs — chuẩn 2023, kế thừa RFC 7807.
- RFC 7807 — Problem Details (legacy) — vẫn referenced trong nhiều framework cũ.
Spring Framework Reference:
- Spring MVC — Exceptions
- Spring MVC — @ControllerAdvice
- Spring MVC — REST Exceptions (RFC 9457) — ProblemDetail support chính chủ.
- Spring Framework Reference — ProblemDetail — Javadoc API.
Spring Boot:
- Spring Boot Reference — Error Handling — autoconfig error pages.
- Spring Boot Reference — Custom Error Handling
Source:
ResponseEntityExceptionHandler— base class cung cấp default handler cho exception built-in. Extend nếu muốn override default.
Pattern reference:
- Stripe API Errors — example error response design tốt.
- GitHub API Errors — tương tự.
Tool:
- HTTPie / Bruno — test error response status code + format.
- Spring Boot Actuator
/actuator/configprops— verifyproblemdetails.enabled=true.
13. Tóm tắt
- 3 layer exception handling:
@ExceptionHandlerlocal controller →@RestControllerAdviceglobal →HandlerExceptionResolverSpring default. @RestControllerAdvice=@ControllerAdvice+@ResponseBody. Standard 2026 cho REST API.- 3 default resolver:
ExceptionHandlerExceptionResolver(custom),ResponseStatusExceptionResolver(@ResponseStatus),DefaultHandlerExceptionResolver(built-in MVC exception). - Problem Details RFC 9457: standard format error response. Boot 3+ có
ProblemDetailclass native. - Boot 3.4+ enable mặc định:
spring.mvc.problemdetails.enabled=true. Set Content-Typeapplication/problem+json. - 5 standard field:
type,title,status,detail,instance. Custom field quasetProperty(key, value). - Domain exception pure (không import HTTP) + mapping qua
@RestControllerAdvice— testable, clean architecture. ResponseStatusExceptionshortcut cho prototype hoặc one-off endpoint. Production prefer domain exception.- Security critical: never leak stack trace trong response. Log server-side, expose requestId cho client tham chiếu support.
- Validation error: customize
MethodArgumentNotValidExceptionhandler để includeviolationsarray — UX frontend friendly. - Test exception path với
MockMvc— verify status, content-type, body shape.
14. Tự kiểm tra
Q1Đoạn sau có vấn đề gì? Output cho client là gì?@Service
public class OrderService {
public OrderDto findById(Long id) {
return repo.findById(id)
.orElseThrow(() -> new RuntimeException("Order " + id + " not found"));
}
}
@RestController
public class OrderController {
@GetMapping("/api/orders/{id}")
public OrderDto get(@PathVariable Long id) {
return orderService.findById(id);
}
}
▸
@Service
public class OrderService {
public OrderDto findById(Long id) {
return repo.findById(id)
.orElseThrow(() -> new RuntimeException("Order " + id + " not found"));
}
}
@RestController
public class OrderController {
@GetMapping("/api/orders/{id}")
public OrderDto get(@PathVariable Long id) {
return orderService.findById(id);
}
}Vấn đề: throw RuntimeException chung chung — Spring không biết map status code nào. Fall back 500 Internal Server Error.
Output client:
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Order 42 not found",
"instance": "/api/orders/42"
}Client nhìn 500 → tưởng server bug. Thực tế là client error (resource không tồn tại) → phải 404.
Fix — domain exception specific:
// Domain layer
public class OrderNotFoundException extends RuntimeException {
private final Long orderId;
public OrderNotFoundException(Long orderId) {
super("Order " + orderId + " not found");
this.orderId = orderId;
}
public Long getOrderId() { return orderId; }
}
// Service throw specific
public OrderDto findById(Long id) {
return repo.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
// Web layer — mapping qua advice
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
pd.setTitle("Order not found");
pd.setDetail(ex.getMessage());
pd.setProperty("orderId", ex.getOrderId());
return pd;
}
}Output mới: 404 Not Found với detail rõ ràng + orderId custom field. Client biết đó là client error, không server bug.
Bài học: domain exception specific + mapping advice = clean architecture. Domain layer pure (không HTTP knowledge), web layer translate sang HTTP semantics.
Q2Đoạn handler sau có 2 vấn đề security nghiêm trọng. Liệt kê + fix.@RestControllerAdvice
public class GlobalHandler {
@ExceptionHandler(Exception.class)
public ProblemDetail handleAll(Exception ex) {
ProblemDetail pd = ProblemDetail.forStatus(500);
pd.setTitle("Server error");
pd.setDetail(ex.getMessage()); // (1)
pd.setProperty("stackTrace", ExceptionUtils.getStackTrace(ex)); // (2)
pd.setProperty("rootCause", ex.getCause() != null ? ex.getCause().getMessage() : null);
return pd;
}
}
▸
@RestControllerAdvice
public class GlobalHandler {
@ExceptionHandler(Exception.class)
public ProblemDetail handleAll(Exception ex) {
ProblemDetail pd = ProblemDetail.forStatus(500);
pd.setTitle("Server error");
pd.setDetail(ex.getMessage()); // (1)
pd.setProperty("stackTrace", ExceptionUtils.getStackTrace(ex)); // (2)
pd.setProperty("rootCause", ex.getCause() != null ? ex.getCause().getMessage() : null);
return pd;
}
}- Leak
ex.getMessage()trongdetail: exception message có thể chứa thông tin nhạy cảm:SQLException: "Duplicate entry '[email protected]' for key 'users.email'" — leak email schema.FileNotFoundException: "/etc/secret/api-key.txt not found" — leak file path.NullPointerException: "Cannot invoke method on null" — leak code structure.
- Leak stack trace: stack trace expose:
- Class name internal (com.olhub.OrderRepository, ...) — leak architecture.
- Library version (Hibernate 6.6.3, ...) — attacker tra CVE database tìm exploit.
- SQL queries (qua Hibernate stack trace) — leak DB schema.
- Config detail (DataSource URL, ...).
Fix:
@RestControllerAdvice
public class GlobalHandler {
@ExceptionHandler(Exception.class)
public ProblemDetail handleAll(Exception ex, HttpServletRequest req) {
String requestId = MDC.get("requestId");
// LOG day du SERVER — KHONG client
log.error("Unhandled exception, requestId={}, path={}",
requestId, req.getRequestURI(), ex);
// Response generic
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
pd.setTitle("Internal server error");
pd.setDetail("An unexpected error occurred. Please contact support with the request ID.");
pd.setInstance(URI.create(req.getRequestURI()));
pd.setProperty("requestId", requestId); // SUPPORT lookup log
return pd;
}
}Boot dev mode:
# application-dev.yml
server:
error:
include-stacktrace: on_param # ?trace=true → show (CHI dev)
include-message: always
include-binding-errors: always
# application-prod.yml
server:
error:
include-stacktrace: never # PROD: never expose
include-message: never
include-binding-errors: neverWorkflow production:
- User gặp 500, response chứa
requestId: "abc123". - User báo support: "Tôi gặp lỗi, request ID abc123".
- Support query Kibana/ELK với
mdc.requestId: abc123→ tìm log + stack trace. - Support phản hồi user + ticket dev fix.
Pattern này: client safe, support có info đầy đủ. Win-win.
Q3Khi nào nên dùng ResponseStatusException shortcut, khi nào nên dùng domain exception + advice?▸
ResponseStatusException shortcut, khi nào nên dùng domain exception + advice?`ResponseStatusException` shortcut:
@GetMapping("/orders/{id}")
public OrderDto get(@PathVariable Long id) {
return orderService.findOptional(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Order " + id + " not found"
));
}Domain exception + advice:
// Domain
public class OrderNotFoundException extends RuntimeException { ... }
// Service throw
.orElseThrow(() -> new OrderNotFoundException(id));
// Advice map
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handle(...) { ... }| Aspect | ResponseStatusException | Domain exception |
|---|---|---|
| Setup | 0 — built-in | Class exception + advice handler |
| Domain layer purity | ❌ — domain import HTTP | ✅ — domain pure POJO |
| Reuse exception type | ❌ — throw inline với status | ✅ — class reusable nhiều endpoint |
| Test service layer | Phức tạp — assert HTTP status trong service test | Simple — assertThrows(OrderNotFoundException.class) |
| Custom field response | Khó — chỉ có message | Dễ — exception giữ field, advice map sang ProblemDetail.property |
| Refactor (đổi status code) | Sửa mọi chỗ throw | Sửa 1 advice handler |
Khi dùng ResponseStatusException:
- Quick prototype — code nhanh, ít file.
- Endpoint one-off — exception chỉ throw 1 chỗ.
- App nhỏ <10 controller — domain layer chưa cần phức tạp.
Khi dùng domain exception + advice (recommend):
- App enterprise scale.
- Service layer cần test ngoài Spring (JUnit + Mockito, không
@SpringBootTest). - Cần custom field response (orderId, sku, retryAfter).
- Cần audit trail exception (log specific exception type qua AOP).
- API public — error format cần documented + stable.
Khoá này (TaskFlow): dùng domain exception + advice. Phù hợp pattern enterprise senior.
Q4Spring 3 default spring.mvc.problemdetails.enabled=true — tác động cụ thể? Nếu set false (Boot 2 default), output khác thế nào?▸
spring.mvc.problemdetails.enabled=true — tác động cụ thể? Nếu set false (Boot 2 default), output khác thế nào?true (Boot 3.4+ default cho new project):
Built-in MVC exception (validation, type mismatch, etc.) → response Problem Details JSON:
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request content.",
"instance": "/api/orders"
}false (Boot 2 default):
Spring fall back default error page — HTML hoặc empty body tùy Accept header:
HTTP/1.1 400 Bad Request
Content-Type: text/html
<!DOCTYPE html>
<html><head><title>Whitelabel Error Page</title></head>
<body>...</body></html>Hoặc Boot's default JSON error format (legacy):
{
"timestamp": "2026-04-15T10:00:00.000+00:00",
"status": 400,
"error": "Bad Request",
"message": "Validation failed for argument [0] in ...",
"path": "/api/orders"
}Khác biệt thực tế:
| Aspect | problemdetails=true | =false (legacy) |
|---|---|---|
| Format | RFC 9457 standard | Boot's ad-hoc JSON |
| Content-Type | application/problem+json | application/json |
| Field name | type, title, status, detail, instance | timestamp, status, error, message, path |
| Industry standard | Yes — RFC | No — proprietary |
| Client lib support | Tốt — RFC documented | Phải parse custom shape |
| Custom field | Native via setProperty | Phải override ErrorAttributes |
Recommend 2026:
- Project mới: giữ
problemdetails=truedefault. Standard, future-proof. - Migrate từ Boot 2: bật
true, document breaking change cho client (field name đổi). Hoặc giữfalse+ tự build Problem Details qua@RestControllerAdviceđể giữ backward compat.
Verify runtime: /actuator/configprops filter spring.mvc.problemdetails → check value.
Q5App có 5 controller, mỗi cái có vài @ExceptionHandler riêng cho exception đặc thù. Nên có 1 @RestControllerAdvice global hay nhiều advice phân tán?▸
@ExceptionHandler riêng cho exception đặc thù. Nên có 1 @RestControllerAdvice global hay nhiều advice phân tán?Recommend: 1 advice global + advice riêng cho concern đặc thù.
Anti-pattern — exception handler rải rác trong controller:
@RestController
public class OrderController {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handle1(...) { ... }
}
@RestController
public class UserController {
@ExceptionHandler(UserNotFoundException.class)
public ProblemDetail handle2(...) { ... }
}
@RestController
public class PaymentController {
@ExceptionHandler(PaymentException.class)
public ProblemDetail handle3(...) { ... }
}Vấn đề:
- Logic format response duplicate ở 5 controller.
- Đổi format response — sửa 5 chỗ.
- Khó audit "endpoint nào throw exception nào" — phân tán.
Pattern recommended:
@RestControllerAdvice
public class GlobalExceptionHandler {
// Domain exception
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleOrderNotFound(...) { ... }
@ExceptionHandler(UserNotFoundException.class)
public ProblemDetail handleUserNotFound(...) { ... }
@ExceptionHandler(PaymentException.class)
public ProblemDetail handlePayment(...) { ... }
// Validation
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(...) { ... }
// Catch-all
@ExceptionHandler(Exception.class)
public ProblemDetail handleAll(...) { ... }
}Lợi ích:
- Single source of truth cho error format.
- Easy audit — mở 1 file thấy mọi exception map.
- DRY — helper method shared.
- Test tập trung — 1 file
GlobalExceptionHandlerTest.
Khi cần advice riêng (additional, không thay thế):
- API segmentation: public API vs admin API có format response khác:
@RestControllerAdvice(basePackages = "com.olhub.api.admin") public class AdminExceptionHandler { /* admin-specific format, more detail */ } @RestControllerAdvice(basePackages = "com.olhub.api.public") public class PublicExceptionHandler { /* public-safe, less detail */ } - Domain module: mỗi module có advice riêng cho exception đặc thù domain — Spring Modulith pattern (Module 14).
- Library: 3rd-party Spring lib có advice cho exception của lib (vd Spring Security advice cho
AccessDeniedException).
Quy tắc: 1 advice global + advice phân loại theo concern thật sự khác (security, admin/public). Tránh advice per-controller.
Q6Test class sau verify exception handler. Có thiếu test case quan trọng nào không?@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean OrderService orderService;
@Test
void getNonExistent_returns404() throws Exception {
when(orderService.findById(99L)).thenThrow(new OrderNotFoundException(99L));
mockMvc.perform(get("/api/orders/99"))
.andExpect(status().isNotFound());
}
}
▸
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean OrderService orderService;
@Test
void getNonExistent_returns404() throws Exception {
when(orderService.findById(99L)).thenThrow(new OrderNotFoundException(99L));
mockMvc.perform(get("/api/orders/99"))
.andExpect(status().isNotFound());
}
}Thiếu nhiều test case quan trọng.
Test case nên có:
- Verify Content-Type:
application/problem+json:.andExpect(content().contentType("application/problem+json")) - Verify body shape (RFC 9457 fields):
.andExpect(jsonPath("$.type").exists()) .andExpect(jsonPath("$.title").value("Order not found")) .andExpect(jsonPath("$.status").value(404)) .andExpect(jsonPath("$.detail").value("Order 99 not found")) .andExpect(jsonPath("$.instance").value("/api/orders/99")) .andExpect(jsonPath("$.orderId").value(99)) // custom field - Test validation error → 400 với violations:
@Test void invalidRequest_returns400WithViolations() throws Exception { String invalidJson = "{\"customer\":\"\",\"total\":-10}"; mockMvc.perform(post("/api/orders") .contentType(MediaType.APPLICATION_JSON) .content(invalidJson)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.title").value("Validation failed")) .andExpect(jsonPath("$.violations", hasSize(2))) .andExpect(jsonPath("$.violations[*].field", containsInAnyOrder("customer", "total"))); } - Test malformed JSON → 400:
@Test void malformedJson_returns400() throws Exception { mockMvc.perform(post("/api/orders") .contentType(MediaType.APPLICATION_JSON) .content("{invalid json")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.status").value(400)); } - Test wrong content type → 415:
@Test void wrongContentType_returns415() throws Exception { mockMvc.perform(post("/api/orders") .contentType(MediaType.TEXT_PLAIN) .content("plain text")) .andExpect(status().isUnsupportedMediaType()); } - Test wrong HTTP method → 405:
@Test void wrongMethod_returns405() throws Exception { mockMvc.perform(patch("/api/orders/42")) // PATCH khong support .andExpect(status().isMethodNotAllowed()); } - Test path variable invalid → 400:
@Test void invalidPathVariable_returns400() throws Exception { mockMvc.perform(get("/api/orders/abc")) // not Long .andExpect(status().isBadRequest()); } - Test catch-all 500 — KHONG leak stack trace:
@Test void unhandledException_returns500WithoutStackTrace() throws Exception { when(orderService.findById(any())) .thenThrow(new RuntimeException("internal db error: schema=public.orders")); mockMvc.perform(get("/api/orders/42")) .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.detail").value( not(containsString("schema=public.orders")) // KHONG leak )) .andExpect(jsonPath("$.requestId").exists()); // co requestId }
Bài học: exception path quan trọng như happy path. Test coverage cho mỗi @ExceptionHandler trong @RestControllerAdvice. Bug exception handler khó phát hiện vì user-facing — chỉ lộ khi production gặp edge case.
Module 06 (Testing) sẽ đào sâu MockMvc + AssertJ MockMvc DSL Boot 3.4.
Bài tiếp theo: Validation — Jakarta Bean Validation, custom validator, cross-field
Bài này có giúp bạn hiểu bản chất không?
Bình luận (0)
Đang tải...