Spring Boot/Exception handling — @ExceptionHandler, @ControllerAdvice, Problem Details RFC 9457
~24 phútREST API với Spring MVCMiễn phí

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
LayerScopeUse case
@ExceptionHandler trong controllerChỉ controller đóHiếm — exception đặc thù 1 endpoint
@ControllerAdvice / @RestControllerAdviceToàn appStandard 2026 — pattern chính
HandlerExceptionResolver Spring built-inToàn Spring frameworkDefault 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:

ExceptionStatusCause
MethodArgumentNotValidException400Validation @Valid fail
MethodArgumentTypeMismatchException400Path variable type wrong
HttpRequestMethodNotSupportedException405Wrong HTTP method
HttpMediaTypeNotSupportedException415Wrong Content-Type
HttpMediaTypeNotAcceptableException406Wrong Accept
MissingServletRequestParameterException400Missing required param
HttpMessageNotReadableException400Cannot parse JSON body
MaxUploadSizeExceededException413File too large
NoHandlerFoundException404URL không match handler
NoResourceFoundException404Static resource không có
AsyncRequestTimeoutException503Async 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ới Content-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:

FieldRequiredDescription
typeOptionalURI định nghĩa error type. Default about:blank.
titleOptionalTiêu đề ngắn (1 dòng), không phụ thuộc instance.
statusRequiredHTTP status code (duplicate header — convenience cho client).
detailOptionalDetail cụ thể cho instance này.
instanceOptionalURI 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+json cho 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.

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:

MetricLabelUse case
api.exceptionexception, endpoint, methodCount exception per type/endpoint
http.server.requests (auto)status, uriPhân biệt 4xx vs 5xx rate
spring.security.authentications.failurereasonAuth 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 rangeOwnerAlert?
2xxn/aNo
3xxn/aNo
4xx (client error)Client/UX teamSustained spike → check API contract change
5xx (server error)Backend on-callAlways 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:

CheckPass 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:

  1. Kibana/Loki query: path:"/api/orders" AND log.level:ERROR → gom log trong 5 phút spike.
  2. Group by exClass field → top 3 exception type.
  3. 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.

  1. Check DB health (/actuator/health).
  2. Check Redis / cache (HEALTH ping).
  3. 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:

  1. Add field OK — client ignore unknown.
  2. Remove field BREAKING — version bump.
  3. Change field type BREAKING — vd orderId từ 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

📚 Tài liệu chính chủ

RFC standards:

Spring Framework Reference:

Spring Boot:

Source:

Pattern reference:

Tool:

  • HTTPie / Bruno — test error response status code + format.
  • Spring Boot Actuator /actuator/configprops — verify problemdetails.enabled=true.

13. Tóm tắt

  • 3 layer exception handling: @ExceptionHandler local controller → @RestControllerAdvice global → HandlerExceptionResolver Spring 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ó ProblemDetail class native.
  • Boot 3.4+ enable mặc định: spring.mvc.problemdetails.enabled=true. Set Content-Type application/problem+json.
  • 5 standard field: type, title, status, detail, instance. Custom field qua setProperty(key, value).
  • Domain exception pure (không import HTTP) + mapping qua @RestControllerAdvice — testable, clean architecture.
  • ResponseStatusException shortcut 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 MethodArgumentNotValidException handler để include violations array — UX frontend friendly.
  • Test exception path với MockMvc — verify status, content-type, body shape.

14. Tự kiểm tra

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);
  }
}

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;
  }
}
  1. Leak ex.getMessage() trong detail: 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.
    Attacker probe endpoint với input ngẫu nhiên → ghi nhận message → reverse engineer schema/code.
  2. 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: never

Workflow production:

  1. User gặp 500, response chứa requestId: "abc123".
  2. User báo support: "Tôi gặp lỗi, request ID abc123".
  3. Support query Kibana/ELK với mdc.requestId: abc123 → tìm log + stack trace.
  4. Support phản hồi user + ticket dev fix.

Pattern này: client safe, support có info đầy đủ. Win-win.

Q3
Khi nào nên dùng 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(...) { ... }
AspectResponseStatusExceptionDomain exception
Setup0 — built-inClass 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 layerPhức tạp — assert HTTP status trong service testSimple — assertThrows(OrderNotFoundException.class)
Custom field responseKhó — chỉ có messageDễ — exception giữ field, advice map sang ProblemDetail.property
Refactor (đổi status code)Sửa mọi chỗ throwSử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.

Q4
Spring 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?

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ế:

Aspectproblemdetails=true=false (legacy)
FormatRFC 9457 standardBoot's ad-hoc JSON
Content-Typeapplication/problem+jsonapplication/json
Field nametype, title, status, detail, instancetimestamp, status, error, message, path
Industry standardYes — RFCNo — proprietary
Client lib supportTốt — RFC documentedPhải parse custom shape
Custom fieldNative via setPropertyPhải override ErrorAttributes

Recommend 2026:

  • Project mới: giữ problemdetails=true default. 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.

Q5
App 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?

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ế):

  1. 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 */ }
  2. Domain module: mỗi module có advice riêng cho exception đặc thù domain — Spring Modulith pattern (Module 14).
  3. 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.

Q6
Test 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());
  }
}

Thiếu nhiều test case quan trọng.

Test case nên có:

  1. Verify Content-Type: application/problem+json:
    .andExpect(content().contentType("application/problem+json"))
  2. 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
  3. 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")));
    }
  4. 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));
    }
  5. 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());
    }
  6. Test wrong HTTP method → 405:
    @Test
    void wrongMethod_returns405() throws Exception {
      mockMvc.perform(patch("/api/orders/42"))      // PATCH khong support
          .andExpect(status().isMethodNotAllowed());
    }
  7. Test path variable invalid → 400:
    @Test
    void invalidPathVariable_returns400() throws Exception {
      mockMvc.perform(get("/api/orders/abc"))       // not Long
          .andExpect(status().isBadRequest());
    }
  8. 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...