Sitemap

Spring Boot — Global Exception Handler

5 min readSep 29, 2025

--

รวม Global Exception Handler ที่พร้อมใช้งาน ครบทุกเคสสำคัญ (Spring MVC + Spring Data JPA + Hibernate + Security + Web/Multipart + Upstream) พร้อม DTO มาตรฐาน, ErrorCode enum, Config แนะนำ, และ Testing Idea

1) Error Response DTO

อันนี้คือที่ผมมักจะใช้เสมอใน Project

package com.zengcode.api;

import java.time.OffsetDateTime;
import java.util.Map;
public record ErrorResponse(
OffsetDateTime timestamp,
int status,
String error,
String code,
String message,
String path,
String traceId,
Map<String, Object> details
) {
public static ErrorResponse of(
int status, String error, String code, String message, String path, String traceId, Map<String, Object> details
) {
return new ErrorResponse(OffsetDateTime.now(), status, error, code, message, path, traceId, details);
}
}

2) ErrorCode Enum (Optional)

package com.zengcode.api;

public enum ErrorCode {
VALIDATION_ERROR,
BIND_ERROR,
CONSTRAINT_VIOLATION,
JSON_PARSE_ERROR,
TYPE_MISMATCH,
MISSING_PARAMETER,
MISSING_PATH_VARIABLE,
AUTHENTICATION_FAILED,
ACCESS_DENIED,
NOT_FOUND,
METHOD_NOT_ALLOWED,
UNSUPPORTED_MEDIA_TYPE,
NOT_ACCEPTABLE,
PAYLOAD_TOO_LARGE,
MULTIPART_ERROR,
CONVERSION_FAILED,
RESPONSE_WRITE_ERROR,
RESPONSE_STATUS_EXCEPTION,
OPTIMISTIC_LOCK_CONFLICT,
ENTITY_NOT_FOUND,
DUPLICATE_KEY,
DATA_INTEGRITY_VIOLATION,
DB_CONSTRAINT_VIOLATION,
CONCURRENCY_FAILURE,
TRANSACTION_ERROR,
SQL_ERROR,
UPSTREAM_ERROR,
RESOURCE_NOT_FOUND,
INTERNAL_ERROR
}

3) Global Exception Handler (All-in-One)

package com.zengcode.api;

import jakarta.persistence.EntityNotFoundException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.dao.ConcurrencyFailureException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.*;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.transaction.TransactionSystemException;
import org.springframework.transaction.UnexpectedRollbackException;
import org.springframework.web.*;
import org.springframework.web.bind.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.MultipartException;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.sql.SQLException;
import java.util.*;
import java.util.stream.Collectors;
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class GlobalExceptionHandler {
private String getTraceId() {
return Optional.ofNullable(MDC.get("traceId")).orElse(null);
}
private ResponseEntity<ErrorResponse> respond(HttpStatus status, String code, String message,
HttpServletRequest req, Map<String, Object> details) {
var body = ErrorResponse.of(
status.value(), status.getReasonPhrase(), code, message,
req.getRequestURI(), getTraceId(), details
);
return ResponseEntity.status(status).contentType(MediaType.APPLICATION_JSON).body(body);
}
private static Map<String, Object> fieldErrors(BindingResult binding) {
Map<String, String> fields = binding.getFieldErrors().stream()
.collect(Collectors.toMap(
fe -> fe.getField(),
fe -> Optional.ofNullable(fe.getDefaultMessage()).orElse("invalid"),
(a,b) -> a, LinkedHashMap::new
));
List<String> globals = binding.getGlobalErrors().stream()
.map(oe -> Optional.ofNullable(oe.getDefaultMessage()).orElse(oe.getObjectName()+" invalid"))
.toList();
Map<String, Object> details = new LinkedHashMap<>();
details.put("fields", fields);
if (!globals.isEmpty()) details.put("global", globals);
return details;
}
private static Map<String, Object> constraintViolations(Set<ConstraintViolation<?>> violations) {
var list = violations.stream().map(v -> Map.of(
"property", v.getPropertyPath().toString(),
"invalidValue", String.valueOf(v.getInvalidValue()),
"message", v.getMessage()
)).toList();
return Map.of("violations", list);
}
// ---------- 400 Validation/Parsing ----------
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpServletRequest req) {
return respond(HttpStatus.BAD_REQUEST, ErrorCode.VALIDATION_ERROR.name(), "Request validation failed.", req, fieldErrors(e.getBindingResult()));
}
@ExceptionHandler(BindException.class)
public ResponseEntity<ErrorResponse> handleBindException(BindException e, HttpServletRequest req) {
return respond(HttpStatus.BAD_REQUEST, ErrorCode.BIND_ERROR.name(), "Binding failed.", req, fieldErrors(e.getBindingResult()));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException e, HttpServletRequest req) {
return respond(HttpStatus.BAD_REQUEST, ErrorCode.CONSTRAINT_VIOLATION.name(), "Constraint violation.", req, constraintViolations(e.getConstraintViolations()));
}
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class, MissingServletRequestParameterException.class, MissingPathVariableException.class})
public ResponseEntity<ErrorResponse> handleBadRequest(Exception e, HttpServletRequest req) {
String code;
String msg;
if (e instanceof HttpMessageNotReadableException) { code = ErrorCode.JSON_PARSE_ERROR.name(); msg = "Malformed JSON body."; }
else if (e instanceof MethodArgumentTypeMismatchException) { code = ErrorCode.TYPE_MISMATCH.name(); msg = "Parameter type mismatch."; }
else if (e instanceof MissingServletRequestParameterException) { code = ErrorCode.MISSING_PARAMETER.name(); msg = "Missing parameter."; }
else { code = ErrorCode.MISSING_PATH_VARIABLE.name(); msg = "Missing path variable."; }
return respond(HttpStatus.BAD_REQUEST, code, msg, req, Map.of("detail", e.getMessage()));
}
@ExceptionHandler(org.springframework.core.convert.ConversionFailedException.class)
public ResponseEntity<ErrorResponse> handleConversionFailed(org.springframework.core.convert.ConversionFailedException e, HttpServletRequest req) {
return respond(HttpStatus.BAD_REQUEST, ErrorCode.CONVERSION_FAILED.name(), "Conversion failed.", req, Map.of("detail", e.getMessage()));
}
// ---------- 401 / 403 Security ----------
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuth(AuthenticationException e, HttpServletRequest req) {
return respond(HttpStatus.UNAUTHORIZED, ErrorCode.AUTHENTICATION_FAILED.name(), "Authentication failed.", req, Map.of("detail", e.getMessage()));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException e, HttpServletRequest req) {
return respond(HttpStatus.FORBIDDEN, ErrorCode.ACCESS_DENIED.name(), "Forbidden.", req, null);
}
// ---------- 404 / 405 / 406 / 415 ----------
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(NoHandlerFoundException e, HttpServletRequest req) {
return respond(HttpStatus.NOT_FOUND, ErrorCode.NOT_FOUND.name(), "Resource not found.", req, Map.of("path", e.getRequestURL()));
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ErrorResponse> handleNoResource(NoResourceFoundException e, HttpServletRequest req) {
return respond(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND.name(), "Resource not found.", req, Map.of("path", e.getResourcePath()));
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleMethodNotAllowed(HttpRequestMethodNotSupportedException e, HttpServletRequest req) {
return respond(HttpStatus.METHOD_NOT_ALLOWED, ErrorCode.METHOD_NOT_ALLOWED.name(), "Method not allowed.", req, Map.of("supported", e.getSupportedHttpMethods()));
}
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleUnsupportedMedia(HttpMediaTypeNotSupportedException e, HttpServletRequest req) {
return respond(HttpStatus.UNSUPPORTED_MEDIA_TYPE, ErrorCode.UNSUPPORTED_MEDIA_TYPE.name(), "Unsupported media type.", req, Map.of("supported", e.getSupportedMediaTypes()));
}
@ExceptionHandler(org.springframework.web.HttpMediaTypeNotAcceptableException.class)
public ResponseEntity<ErrorResponse> handleNotAcceptable(org.springframework.web.HttpMediaTypeNotAcceptableException e, HttpServletRequest req) {
return respond(HttpStatus.NOT_ACCEPTABLE, ErrorCode.NOT_ACCEPTABLE.name(), "Not acceptable.", req, Map.of("supported", e.getSupportedMediaTypes()));
}
// ---------- Upload ----------
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ErrorResponse> handleMaxUpload(MaxUploadSizeExceededException e, HttpServletRequest req) {
return respond(HttpStatus.PAYLOAD_TOO_LARGE, ErrorCode.PAYLOAD_TOO_LARGE.name(), "File too large.", req, Map.of("detail", e.getMessage()));
}
@ExceptionHandler(MultipartException.class)
public ResponseEntity<ErrorResponse> handleMultipart(MultipartException e, HttpServletRequest req) {
return respond(HttpStatus.BAD_REQUEST, ErrorCode.MULTIPART_ERROR.name(), "Multipart error.", req, Map.of("detail", e.getMessage()));
}
// ---------- JPA/DB ----------
@ExceptionHandler({org.springframework.orm.ObjectOptimisticLockingFailureException.class, jakarta.persistence.OptimisticLockException.class})
public ResponseEntity<ErrorResponse> handleOptimisticLock(Exception e, HttpServletRequest req) {
return respond(HttpStatus.CONFLICT, ErrorCode.OPTIMISTIC_LOCK_CONFLICT.name(), "Optimistic lock conflict.", req, null);
}
@ExceptionHandler({EntityNotFoundException.class, NoSuchElementException.class})
public ResponseEntity<ErrorResponse> handleEntityNotFound(RuntimeException e, HttpServletRequest req) {
return respond(HttpStatus.NOT_FOUND, ErrorCode.ENTITY_NOT_FOUND.name(), "Entity not found.", req, null);
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrity(DataIntegrityViolationException e, HttpServletRequest req) {
Throwable root = rootCause(e);
String msg = root != null ? root.getMessage() : e.getMessage();
String code = (msg != null && msg.toLowerCase().contains("duplicate")) ? ErrorCode.DUPLICATE_KEY.name() : ErrorCode.DATA_INTEGRITY_VIOLATION.name();
return respond(HttpStatus.CONFLICT, code, "Data integrity violation.", req, Map.of("detail", msg));
}
@ExceptionHandler(org.hibernate.exception.ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleHibernateConstraint(org.hibernate.exception.ConstraintViolationException e, HttpServletRequest req) {
return respond(HttpStatus.CONFLICT, ErrorCode.DB_CONSTRAINT_VIOLATION.name(), "DB constraint violation.", req, Map.of("sqlState", e.getSQLState(), "constraint", e.getConstraintName()));
}
// ---------- Transaction ----------
@ExceptionHandler({UnexpectedRollbackException.class, TransactionSystemException.class})
public ResponseEntity<ErrorResponse> handleTx(Exception e, HttpServletRequest req) {
return respond(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.TRANSACTION_ERROR.name(), "Transaction error.", req, Map.of("detail", e.getMessage()));
}
// ---------- Concurrency ----------
@ExceptionHandler(ConcurrencyFailureException.class)
public ResponseEntity<ErrorResponse> handleConcurrency(ConcurrencyFailureException e, HttpServletRequest req) {
return respond(HttpStatus.SERVICE_UNAVAILABLE, ErrorCode.CONCURRENCY_FAILURE.name(), "Concurrency issue.", req, null);
}
// ---------- SQL ----------
@ExceptionHandler(SQLException.class)
public ResponseEntity<ErrorResponse> handleSql(SQLException e, HttpServletRequest req) {
Map<String, Object> details = new LinkedHashMap<>();
details.put("sqlState", e.getSQLState());
details.put("errorCode", e.getErrorCode());
details.put("detail", e.getMessage());
return respond(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.SQL_ERROR.name(), "SQL error.", req, details);
}
// ---------- ResponseStatus & Upstream ----------
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException e, HttpServletRequest req) {
var status = HttpStatus.resolve(e.getStatusCode().value());
return respond(status != null ? status : HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.RESPONSE_STATUS_EXCEPTION.name(), e.getReason(), req, Map.of("detail", e.getMessage()));
}
@ExceptionHandler(RestClientResponseException.class)
public ResponseEntity<ErrorResponse> handleRestClientResponse(RestClientResponseException e, HttpServletRequest req) {
HttpStatus status = HttpStatus.resolve(e.getRawStatusCode());
Map<String, Object> details = new LinkedHashMap<>();
details.put("upstreamStatus", e.getRawStatusCode());
details.put("upstreamBody", e.getResponseBodyAsString());
details.put("upstreamHeaders", e.getResponseHeaders());
return respond(status != null ? status : HttpStatus.BAD_GATEWAY, ErrorCode.UPSTREAM_ERROR.name(), "Upstream service error.", req, details);
}
// ---------- Write Response Fail ----------
@ExceptionHandler(HttpMessageNotWritableException.class)
public ResponseEntity<ErrorResponse> handleNotWritable(HttpMessageNotWritableException e, HttpServletRequest req) {
return respond(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.RESPONSE_WRITE_ERROR.name(), "Response write error.", req, Map.of("detail", e.getMessage()));
}
// ---------- Fallback ----------
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnknown(Exception e, HttpServletRequest req) {
return respond(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_ERROR.name(), "Unexpected error.", req, Map.of("detail", e.getMessage()));
}
private static Throwable rootCause(Throwable t) {
Throwable cause = t;
while (cause.getCause() != null && cause.getCause() != cause) cause = cause.getCause();
return cause;
}
}

Get Chiwa Kantawong (Pea)’s stories in your inbox

Join Medium for free to get updates from this writer.

สิ่งที่อาจเพิ่มได้

  1. Configurable Properties
  • ทำให้ทีมสามารถเปิด/ปิดรายละเอียดบางอย่างได้ เช่น log stacktrace ทั้งหมด, show traceId, show upstream response body
  • → อาจทำเป็น application.yml property เช่น:
app:
error:
show-trace-id: true
show-upstream-body: false

Logging Strategy

  • ตอนนี้ handler ส่ง response อย่างเดียว
  • แนะนำเพิ่ม log.error(...) พร้อมกับ traceId สำหรับ error ร้ายแรง เช่น 500, DB error, upstream error
  • จะช่วยเวลา incident monitoring หรือ ELK/Grafana

ErrorResponse Extensions

  • เพิ่ม field errors (list of field errors) โดยตรง → ใช้แทน details ตอน validation error จะอ่านง่ายขึ้น
  • เพิ่ม requestId (จาก filter) หรือ correlationId แยกจาก traceId ได้ ถ้าองค์กรใช้ระบบ distributed tracing

เอาไปประยุกต์ใช้กับ Project ได้นะครับ แก้ไขเพิ่มเติมเท่าที่จำเป็นต้องใช้ได้เลย หรือจะเขียนเป็น Spring Starter ให้ทีมใช้ได้กับทุก Project ก็น่าจะแหล่ม

--

--

Chiwa Kantawong (Pea)
Chiwa Kantawong (Pea)

Written by Chiwa Kantawong (Pea)

Software Engineering บนดาวเคราะห์ใบนึง Youtube : https://www.youtube.com/@ThaiJavaZone