Here is the English translation of the article body.


When you develop REST APIs with Spring Boot, you encounter all kinds of exceptions: validation errors, business errors, system errors, and more. If you handle each of these individually in every Controller, the format of your error responses ends up inconsistent, and error handling on the client side becomes needlessly complicated.

In this article, we explain how to use @ControllerAdvice and @ExceptionHandler to return the exceptions raised in a REST API in a unified JSON format. We introduce a design pattern that assigns the appropriate HTTP status code to validation errors, business errors, and system errors, along with concrete code examples.

The Challenge of Exception Handling in REST APIs

When each Controller implements its own exception handling, the format of the error response ends up varying from developer to developer. One endpoint returns {"error": "message"} while another returns {"errorMessage": "message"}, and the client is forced to implement different error handling for each endpoint. Writing the same exception handling over and over also violates the DRY principle and increases the risk of missing something when the specification changes.

By centralizing exception handling in a single place and returning a unified error response, you can solve all of these problems at once.

Quick Reference: Exception Type × HTTP Status × Handler

The main patterns covered in this article are summarized below. Each is explained in detail in its own section.

Exception typeRecommended HTTP statusHandler methodMain use
MethodArgumentNotValidException400 Bad RequesthandleMethodArgumentNotValid (override)@Valid validation failure
IllegalArgumentException400 Bad Request@ExceptionHandler(IllegalArgumentException.class)Invalid argument
Custom BusinessException400 Bad Request@ExceptionHandler(BusinessException.class)Business rule violation
Custom ResourceNotFoundException404 Not Found@ExceptionHandler(ResourceNotFoundException.class)Resource does not exist
HttpRequestMethodNotSupportedException405 Method Not AllowedhandleHttpRequestMethodNotSupported (override)Unsupported HTTP method
HttpMediaTypeNotSupportedException415 Unsupported Media TypeResponseEntityExceptionHandler defaultUnsupported Content-Type
Other Exception500 Internal Server Error@ExceptionHandler(Exception.class)Unexpected system error

The goal of this article is to take this table as the foundation and implement every pattern in a unified way using @RestControllerAdvice combined with extending ResponseEntityExceptionHandler.

The Basics of @ControllerAdvice and @ExceptionHandler

@ControllerAdvice is an annotation for defining shared logic that applies across multiple Controllers. When you define methods annotated with @ExceptionHandler inside this class, exceptions thrown by any Controller in the application can be handled in one place.

For REST APIs, use @RestControllerAdvice. It is an annotation that combines @ControllerAdvice and @ResponseBody, so return values are automatically serialized to JSON. If you stick with plain @ControllerAdvice, you have to add @ResponseBody to each handler method individually.

You specify the exception type you want to handle in @ExceptionHandler. By writing an array such as @ExceptionHandler({Exception1.class, Exception2.class}), you can also handle multiple exception types in a single method. If you make the return type ResponseEntity, you gain flexible control over the HTTP status code, headers, and body.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgumentException(
            IllegalArgumentException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }
}

In this example, when an IllegalArgumentException occurs, the handler returns a 400 Bad Request status code together with an error response in the unified format.

Designing a Unified Error Response

The standard set of fields for an error response is timestamp (when the error occurred), status (the HTTP status code), error (a description of the status), message (a detailed message), and path (the request path). For validation errors, you additionally return the per-field error details in an errors list.

public class ErrorResponse {
    private LocalDateTime timestamp;
    private int status;
    private String error;
    private String message;
    private String path;
    private List<FieldError> errors;

    public ErrorResponse(LocalDateTime timestamp, int status, String error, 
                        String message, String path) {
        this.timestamp = timestamp;
        this.status = status;
        this.error = error;
        this.message = message;
        this.path = path;
    }

    public ErrorResponse(LocalDateTime timestamp, int status, String error, 
                        String message, String path, List<FieldError> errors) {
        this(timestamp, status, error, message, path);
        this.errors = errors;
    }

    // Getter実装(JSONシリアライゼーションに必須)
    public LocalDateTime getTimestamp() { return timestamp; }
    public int getStatus() { return status; }
    public String getError() { return error; }
    public String getMessage() { return message; }
    public String getPath() { return path; }
    public List<FieldError> getErrors() { return errors; }

    public static class FieldError {
        private String field;
        private Object rejectedValue;
        private String message;

        public FieldError(String field, Object rejectedValue, String message) {
            this.field = field;
            this.rejectedValue = rejectedValue;
            this.message = message;
        }

        // Getter実装
        public String getField() { return field; }
        public Object getRejectedValue() { return rejectedValue; }
        public String getMessage() { return message; }
    }
}

In real projects, using Lombok’s @Getter or @Data lets you generate the getters automatically and keep the class concise. With this class, you can return error responses with a consistent JSON structure for every exception.

Handling Validation Errors

When validation triggered by the @Valid annotation fails, a MethodArgumentNotValidException is thrown. Consider, for example, the following DTO and Controller.

public class UserCreateRequest {
    @NotBlank(message = "ユーザー名は必須です")
    @Size(min = 3, max = 20, message = "ユーザー名は3文字以上20文字以内で入力してください")
    private String username;

    @NotBlank(message = "メールアドレスは必須です")
    @Email(message = "メールアドレスの形式が正しくありません")
    private String email;

    @NotNull(message = "年齢は必須です")
    @Min(value = 0, message = "年齢は0以上で入力してください")
    @Max(value = 150, message = "年齢は150以下で入力してください")
    private Integer age;

    // Getter/Setterは省略
}

@RestController
@RequestMapping("/api/users")
public class UserController {

    @PostMapping
    public ResponseEntity<String> createUser(@Valid @RequestBody UserCreateRequest request) {
        // ユーザー作成処理
        return ResponseEntity.ok("User created successfully");
    }
}

MethodArgumentNotValidException contains a BindingResult, from which you can retrieve the error information for each field.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(
            MethodArgumentNotValidException ex, HttpServletRequest request) {
        
        List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> new ErrorResponse.FieldError(
                error.getField(),
                error.getRejectedValue(),
                error.getDefaultMessage()
            ))
            .collect(Collectors.toList());
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            "入力値の検証に失敗しました",
            request.getRequestURI(),
            fieldErrors
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }
}

This produces an error response like the following.

{
  "timestamp": "2025-01-15T10:30:00",
  "status": 400,
  "error": "Bad Request",
  "message": "入力値の検証に失敗しました",
  "path": "/api/users",
  "errors": [
    {
      "field": "username",
      "rejectedValue": "ab",
      "message": "ユーザー名は3文字以上20文字以内で入力してください"
    },
    {
      "field": "email",
      "rejectedValue": "invalid-email",
      "message": "メールアドレスの形式が正しくありません"
    }
  ]
}

For details on validation itself, see also How to Implement Validation with the @Valid Annotation in Spring Boot and How to Implement Group and Method-Level Validation with the @Validated Annotation in Spring Boot.

Handling Custom Business Exceptions

Application-specific business errors are expressed with custom exception classes. Create your business exceptions by extending RuntimeException. If you make them checked exceptions, every caller is forced to wrap them in try-catch, which clutters the code.

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

public class BusinessException extends RuntimeException {
    public BusinessException(String message) {
        super(message);
    }
}

They are used from a Controller as follows.

@RestController
@RequestMapping("/api/users")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        User user = userService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException(
                "ID: " + id + " のユーザーが見つかりません"));
        return ResponseEntity.ok(user);
    }

    @PostMapping("/{id}/activate")
    public ResponseEntity<String> activateUser(@PathVariable Long id) {
        User user = userService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException(
                "ID: " + id + " のユーザーが見つかりません"));
        
        if (user.isActive()) {
            throw new BusinessException("ユーザーは既にアクティブです");
        }
        
        userService.activate(user);
        return ResponseEntity.ok("User activated successfully");
    }
}

On the handler side, ResourceNotFoundException is mapped to 404 Not Found (the resource does not exist), and BusinessException is mapped to 400 Bad Request (a business rule violation).

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
            ResourceNotFoundException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.NOT_FOUND.value(),
            HttpStatus.NOT_FOUND.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(
            BusinessException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }
}

Handling System Errors and Unexpected Exceptions

If you provide a handler that catches the Exception class, every exception that was not handled individually can be processed as a 500 Internal Server Error.

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(
            Exception ex, HttpServletRequest request) {
        
        // システムエラーは詳細をログに出力
        logger.error("予期しないエラーが発生しました: {}", ex.getMessage(), ex);
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.INTERNAL_SERVER_ERROR.value(),
            HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(),
            "サーバー内部エラーが発生しました",
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
    }
}

In production, you must never return stack traces or internal error messages to the client. Doing so hands attackers information about your internal implementation. As in the example above, return only a generic message to the client and write the details to the server log. For Spring Boot’s default error page as well, setting server.error.include-stacktrace=never in application-prod.properties guarantees that stack traces are hidden under the production profile.

Taking Advantage of ResponseEntityExceptionHandler

Spring MVC provides a base class called ResponseEntityExceptionHandler. By extending it, you can handle the following standard Spring MVC exceptions in your unified format.

  • HttpRequestMethodNotSupportedException (unsupported HTTP method)
  • HttpMediaTypeNotSupportedException (unsupported Content-Type)
  • MissingServletRequestParameterException (missing required request parameter)
  • Many other standard Spring MVC exceptions

If you do not extend it, these standard exceptions receive Spring MVC’s default handling and are not returned in your own unified format. By overriding protected methods such as handleMethodArgumentNotValid and handleHttpRequestMethodNotSupported, you can replace the responses for standard exceptions with your own ErrorResponse format. The concrete implementation is shown in the complete example later in this article.

Guidelines for Choosing HTTP Status Codes

It is important to return the appropriate HTTP status code for each type of exception. Use 400 Bad Request when the problem lies with the request, such as validation errors or business rule violations. Use 404 Not Found when the specified resource, such as a user ID or product ID, does not exist. Use 500 Internal Server Error when processing could not complete because of a problem on the server side, such as a database connection error or an unexpected runtime error.

The following status codes can also be used as needed.

  • 401 Unauthorized (authentication required)
  • 403 Forbidden (authenticated but insufficient permissions)
  • 409 Conflict (resource conflict such as an optimistic locking failure)
  • 503 Service Unavailable (service temporarily down)

Note that handling authentication and authorization errors (401/403) with Spring Security requires separate dedicated configuration, so it is outside the scope of this article.

Using the Spring Boot 3.x Standard ProblemDetail (RFC 7807)

Starting with Spring Framework 6 / Spring Boot 3.0, a ProblemDetail class that conforms to RFC 7807 Problem Details for HTTP APIs is provided as the standard error response format. It has the fields type (a URI identifying the error), title (a short summary), status, detail (a detailed message), and instance (the URI where the error occurred), and you can add arbitrary extension properties through the properties map.

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleResourceNotFoundException(
            ResourceNotFoundException ex, HttpServletRequest request) {

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setType(URI.create("https://springboot-123.example.com/errors/resource-not-found"));
        problem.setTitle("Resource Not Found");
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("timestamp", Instant.now());
        return problem;
    }
}

The response is returned with Content-Type: application/problem+json and has the following structure.

{
  "type": "https://springboot-123.example.com/errors/resource-not-found",
  "title": "Resource Not Found",
  "status": 404,
  "detail": "ID: 1 のユーザーが見つかりません",
  "instance": "/api/users/1",
  "timestamp": "2025-06-01T10:30:00Z"
}

In Spring Boot 3.x, ResponseEntityExceptionHandler has been reworked to return standard Spring MVC exceptions in ProblemDetail format. Simply adding the following to application.properties makes built-in exceptions such as MethodArgumentNotValidException return RFC 7807-compliant responses.

spring.mvc.problemdetails.enabled=true

As a rule of thumb, make ProblemDetail your first choice for new Spring Boot 3.x projects. Because it is a standard format, it interoperates well with client-side libraries. When you need compatibility with existing clients, keep the existing schema using the ErrorResponse pattern from this article. Whichever you choose, the @RestControllerAdvice + @ExceptionHandler structure carries over unchanged.

Implementation Example: A Complete Global Exception Handler

Here is a complete exception handler class that brings together everything covered so far.

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // バリデーションエラー
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        
        List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> new ErrorResponse.FieldError(
                error.getField(),
                error.getRejectedValue(),
                error.getDefaultMessage()
            ))
            .collect(Collectors.toList());
        
        ServletWebRequest servletWebRequest = (ServletWebRequest) request;
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            status.value(),
            HttpStatus.valueOf(status.value()).getReasonPhrase(),
            "入力値の検証に失敗しました",
            servletWebRequest.getRequest().getRequestURI(),
            fieldErrors
        );
        
        return ResponseEntity.status(status).body(errorResponse);
    }

    // HTTPメソッド不正
    @Override
    protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
            HttpRequestMethodNotSupportedException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        
        ServletWebRequest servletWebRequest = (ServletWebRequest) request;
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            status.value(),
            HttpStatus.valueOf(status.value()).getReasonPhrase(),
            "HTTPメソッド " + ex.getMethod() + " はサポートされていません",
            servletWebRequest.getRequest().getRequestURI()
        );
        
        return ResponseEntity.status(status).body(errorResponse);
    }

    // リソース不存在
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
            ResourceNotFoundException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.NOT_FOUND.value(),
            HttpStatus.NOT_FOUND.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }

    // 業務例外
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(
            BusinessException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }

    // 不正な引数
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgumentException(
            IllegalArgumentException ex, HttpServletRequest request) {
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            HttpStatus.BAD_REQUEST.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
    }

    // その他全ての例外
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(
            Exception ex, HttpServletRequest request) {
        
        log.error("予期しないエラーが発生しました: {}", ex.getMessage(), ex);
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.INTERNAL_SERVER_ERROR.value(),
            HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(),
            "サーバー内部エラーが発生しました",
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
    }
}

How to Test Exception Handling

You can verify that exception handling works correctly with tests that use MockMvc.

@WebMvcTest(UserController.class)
class GlobalExceptionHandlerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private UserService userService;

    @Test
    void バリデーションエラーが発生した場合_400とエラー詳細が返ること() throws Exception {
        UserCreateRequest request = new UserCreateRequest();
        request.setUsername("ab"); // 3文字未満でエラー
        request.setEmail("invalid-email"); // メールアドレス形式エラー
        request.setAge(200); // 上限超過

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.status").value(400))
            .andExpect(jsonPath("$.message").value("入力値の検証に失敗しました"))
            .andExpect(jsonPath("$.errors").isArray())
            .andExpect(jsonPath("$.errors[*].field", 
                containsInAnyOrder("username", "email", "age")))
            .andExpect(jsonPath("$.errors[?(@.field=='username')].message")
                .value("ユーザー名は3文字以上20文字以内で入力してください"));
    }

    @Test
    void 存在しないリソースにアクセスした場合_404が返ること() throws Exception {
        when(userService.findById(99999L))
            .thenReturn(Optional.empty());

        mockMvc.perform(get("/api/users/99999"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.status").value(404))
            .andExpect(jsonPath("$.message", 
                containsString("ユーザーが見つかりません")));
    }

    @Test
    void サポートされていないHTTPメソッドの場合_405が返ること() throws Exception {
        mockMvc.perform(put("/api/users"))
            .andExpect(status().isMethodNotAllowed())
            .andExpect(jsonPath("$.status").value(405))
            .andExpect(jsonPath("$.message", 
                containsString("サポートされていません")));
    }
}

@WebMvcTest limits the test scope to the Controller layer, and @MockBean replaces the behavior of the service layer. Verifying not just the status code but also the contents of the errors array and the error message for specific fields is a practical approach, because it lets you notice changes to the response specification right away.

Implementation Notes and Best Practices

Priority of Exception Handlers

When multiple @ExceptionHandler methods are defined, the more specific exception type takes precedence. If you have handlers for both IllegalArgumentException and Exception, the former is invoked when an IllegalArgumentException occurs. Also, if you define multiple @ControllerAdvice classes, their order is not guaranteed, so control it explicitly with the @Order annotation. A smaller number means higher priority.

Exceptions in Asynchronous Processing Cannot Be Caught

Exceptions thrown inside @Async methods or CompletableFuture occur on a thread separate from the request-handling thread, so they cannot be caught by @ControllerAdvice. Handle @Async exceptions by implementing AsyncUncaughtExceptionHandler, and handle CompletableFuture exceptions explicitly with .exceptionally() or .handle().

Protecting Sensitive Information

Do not include sensitive information such as database connection strings, internal file paths, SQL statements, or stack traces in error responses. Attackers could exploit it. Error responses exist to give the client the information it needs to deal with the error, while logs exist to give developers the information they need to investigate the problem. Because the purposes differ, the key is to design them separately.

Internationalization

If you want error messages in multiple languages, prepare property files such as messages_ja.properties and resolve the message for the current locale with Spring Boot’s MessageSource.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @Autowired
    private MessageSource messageSource;

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
            ResourceNotFoundException ex, 
            HttpServletRequest request,
            Locale locale) {
        
        String message = messageSource.getMessage(
            "error.resource.notfound", 
            new Object[]{ex.getMessage()}, 
            locale
        );
        
        ErrorResponse errorResponse = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.NOT_FOUND.value(),
            HttpStatus.NOT_FOUND.getReasonPhrase(),
            message,
            request.getRequestURI()
        );
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }
}

Spring MVC automatically resolves the Locale parameter from the Accept-Language header and passes it in, so you can return error messages in the language appropriate for each request.

Summary

In this article, we explained how to return unified error responses from a Spring Boot REST API. The basic division of responsibilities is to centralize exception handling with @RestControllerAdvice and @ExceptionHandler, return 400 with per-field details for validation errors, return 400/404 for business exceptions, and return 500 for system errors while recording the details in the log. Extending ResponseEntityExceptionHandler lets you bring standard Spring MVC exceptions into the unified format as well, and on Spring Boot 3.x you can adopt the industry-standard format with ProblemDetail.

A unified error response design simplifies client-side implementation and improves the maintainability of the API as a whole. Use the patterns in this article as a starting point and customize them to fit your project’s requirements.