Back to Spring Boot Course
Spring Boot Course

Spring Boot provides us tools to handle exceptions beyond simple ‘try-catch' blocks. To use these tools, we apply a couple of annotations that allow us to treat exception handling as a cross-cutting concern:
- @ResponseStatus
- @ExceptionHandler
- @ControllerAdvice or @RestControllerAdvice
Before Jumping into the above annotations lets see the challenges with SpringBoot Default Exception Handling.
Types of Errors in APIs
- Client Errors (4xx): Issues with user input. Example: 400 Bad Request, 404 Not Found.
- Server Errors (5xx): Issues within the server. Example: 500 Internal Server Error.
- Validation Errors: Missing or invalid fields in a request.
For any exception, SpringBoot provides default error like 400 or 500 based on the scenario.
{ "timestamp": "2019-11-26T21:54:27.957+00:00", "status": 400, "error": "Bad Request", "path": "/api/v1_0/employees" }
{ "timestamp": "2019-11-26T21:59:27.957+00:00", "status": 500, "error": "Internal Server Error", "message": "", "path": "/api/v1_0/employees/search/Praveen" }
Standardizing API Error Responses
A well-structured error response helps clients handle errors effectively
Now lets fix this by creating GlobalExceptionHandler Class
package com.praveen.employee.controller.advice; import com.google.common.collect.Maps; import com.praveen.employee.bo.EmployeeResponse; import com.praveen.employee.bo.ErrorMessage; import com.praveen.employee.bo.ValidationError; import com.praveen.employee.common.Constants; import com.praveen.employee.config.AppConfig; import com.praveen.employee.exception.EmployeeNotFoundException; import com.praveen.employee.filters.InputRequestContextHolder; import jakarta.validation.ConstraintViolationException; import org.apache.commons.lang3.StringUtils; import org.apache.commons.text.StringSubstitutor; import org.hibernate.validator.internal.engine.path.PathImpl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.FieldError; import org.springframework.validation.ObjectError; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.List; import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); private final AppConfig applicationConfig; public GlobalExceptionHandler(AppConfig applicationConfig) { this.applicationConfig = applicationConfig; } @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity<EmployeeResponse> handleHttpMessageNotReadableException() { Map<String, String> validationErrors = Maps.newHashMap(); validationErrors.put(applicationConfig.getMessageNotReadableError().code(), applicationConfig.getMessageNotReadableError().description()); EmployeeResponse empResponse = new EmployeeResponse(Constants.FAIL, InputRequestContextHolder.getTransactionId(), null, new ErrorMessage(applicationConfig.getClientError().code(), applicationConfig.getClientError().description(), validationErrors)); return errorResponse(HttpStatus.BAD_REQUEST, empResponse); } @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity<EmployeeResponse> handleHttpMethodNotAllowedException() { Map<String, String> validationErrors = Maps.newHashMap(); validationErrors.put(applicationConfig.getInvalidHttpMethodError().code(), applicationConfig.getInvalidHttpMethodError().description()); EmployeeResponse empResponse = new EmployeeResponse(Constants.FAIL, InputRequestContextHolder.getTransactionId(), null, new ErrorMessage(applicationConfig.getClientError().code(), applicationConfig.getClientError().description(), validationErrors)); return errorResponse(HttpStatus.METHOD_NOT_ALLOWED, empResponse); } @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity<EmployeeResponse> handleValidationErrors( ConstraintViolationException constraintViolationException) { Map<String, String> validationErrors = Maps.newHashMap(); constraintViolationException.getConstraintViolations().forEach(violationErrors -> { String field = ((PathImpl) violationErrors.getPropertyPath()).getLeafNode().asString().trim(); List<ValidationError> validationErrorList = applicationConfig.getValidationErrors().get(field.trim()); validationErrorList.forEach(validationError -> validationErrors.put(validationError.code(), replaceTemplateValues(validationError.description(), field.trim()))); }); EmployeeResponse empResponse = new EmployeeResponse(Constants.FAIL, InputRequestContextHolder.getTransactionId(), null, new ErrorMessage(applicationConfig.getClientError().code(), applicationConfig.getClientError().description(), validationErrors)); log.error(Constants.INPUT_VALIDATION_ERROR, constraintViolationException, empResponse); return errorResponse(HttpStatus.BAD_REQUEST, empResponse); } @ExceptionHandler(RuntimeException.class) public ResponseEntity<EmployeeResponse> handleInternalServerError(RuntimeException exception) { Map<String, String> serverErrors = Maps.newHashMap(); serverErrors.put(applicationConfig.getServerErrors().code(), applicationConfig.getServerErrors().description() + " " + exception.getMessage()); EmployeeResponse empResponse = new EmployeeResponse(Constants.FAIL, InputRequestContextHolder.getTransactionId(), null, new ErrorMessage(applicationConfig.getServerError().code(), applicationConfig.getServerError().description(), serverErrors)); log.error(Constants.INTERNAL_SERVICE_ERROR, exception, empResponse); return errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, empResponse); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<EmployeeResponse> handleClientErrors( MethodArgumentNotValidException methodArgumentNotValidException) { Map<String, String> validationErrors = Maps.newHashMap(); if (methodArgumentNotValidException.getBindingResult().getGlobalError() != null) { populateGlobalError(validationErrors, methodArgumentNotValidException.getBindingResult().getGlobalError()); } methodArgumentNotValidException.getBindingResult().getFieldErrors() .forEach(fieldError -> populateValidationErrors(validationErrors, fieldError)); EmployeeResponse empResponse = new EmployeeResponse(Constants.FAIL, InputRequestContextHolder.getTransactionId(), null, new ErrorMessage(applicationConfig.getClientError().code(), applicationConfig.getClientError().description(), validationErrors)); log.error(Constants.INPUT_VALIDATION_ERROR, methodArgumentNotValidException, empResponse); return errorResponse(HttpStatus.BAD_REQUEST, empResponse); } @ExceptionHandler(EmployeeNotFoundException.class) public ResponseEntity<EmployeeResponse> handleEmployeeNotFound( EmployeeNotFoundException employeeNotFoundException) { Map<String, String> validationErrors = Maps.newHashMap(); validationErrors.put("40010", "Employee Not Found"); EmployeeResponse empResponse = new EmployeeResponse(Constants.FAIL, InputRequestContextHolder.getTransactionId(), null, new ErrorMessage("4000", "Input Validation Failure", validationErrors)); log.error(Constants.INPUT_VALIDATION_ERROR, employeeNotFoundException, empResponse); return errorResponse(HttpStatus.BAD_REQUEST, empResponse); } private void populateValidationErrors(Map<String, String> validationErrors, FieldError fieldError) { String fieldName = deriveFieldName(fieldError); List<ValidationError> validationErrorsList = applicationConfig.getValidationErrors().get(fieldName); validationErrorsList.forEach(validationError -> validationErrors.put(validationError.code(), replaceTemplateValues(validationError.description(), fieldName))); } private String deriveFieldName(FieldError fieldError) { String fieldName = fieldError.getField(); if (fieldName.contains(".")) { fieldName = StringUtils.substringAfter(fieldName, "."); } return fieldName; } private void populateGlobalError(Map<String, String> validationErrors, ObjectError fieldError) { List<ValidationError> validationErrorsList = applicationConfig.getValidationErrors() .get(fieldError.getDefaultMessage()); validationErrorsList.forEach(validationError -> validationErrors.put(validationError.code(), replaceTemplateValues(validationError.description(), fieldError.getDefaultMessage()))); } private String replaceTemplateValues(String templateMessage, String fieldName) { Map<String, String> validationOverrides = Maps.newHashMap(); if (StringUtils.equalsIgnoreCase("appId", fieldName)) { validationOverrides.put("appIds", StringUtils.join(applicationConfig.getAppIds())); } StringSubstitutor sub = new StringSubstitutor(validationOverrides); return sub.replace(templateMessage); } private ResponseEntity<EmployeeResponse> errorResponse(HttpStatus status, EmployeeResponse empResponse) { return ResponseEntity.status(status).body(empResponse); } }
Controller
package com.praveen.employee.controller; import com.praveen.employee.aop.LoggingAspect; import com.praveen.employee.aop.TrackExecutionTime; import com.praveen.employee.bo.EmployeeRequest; import com.praveen.employee.bo.EmployeeResponse; import com.praveen.employee.entity.Employee; import com.praveen.employee.exception.EmployeeNotFoundException; import com.praveen.employee.service.EmployeeManagementService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @Validated @RequestMapping("/api/v1") @Tag(description = "Employee Management Operations", name = "Employee Management Operations") public class EmployeeManagementController { private final EmployeeManagementService employeeManagementService; public EmployeeManagementController(EmployeeManagementService employeeManagementService) { this.employeeManagementService = employeeManagementService; } @GetMapping(value = "/employees", produces = MediaType.APPLICATION_JSON_VALUE) @LoggingAspect @TrackExecutionTime @Operation(description = "Fetch All Employees", summary = "Fetch All Employees") public List<Employee> fetchAllEmployees() { return employeeManagementService.fetchAllEmployees(); } @GetMapping(value = "/employees/search", produces = MediaType.APPLICATION_JSON_VALUE) @LoggingAspect @TrackExecutionTime @Operation(description = "Fetch Employee by Employee Name", summary = "Fetch Employee by Employee Name") public List<Employee> getEmployeeByName(@RequestParam @NotBlank @Size(min = 5) String name) { return employeeManagementService.getEmployeeByName(name); } @GetMapping(value = "/employees/search/location/{location}", produces = MediaType.APPLICATION_JSON_VALUE) @LoggingAspect @TrackExecutionTime @Operation(description = "Fetch Employee by Location", summary = "Fetch Employee by Location") public List<Employee> getEmployeeByLocation(@PathVariable @NotBlank String location) { return employeeManagementService.getEmployeeByLocation(location); } @GetMapping(value = "/employees/{id}", produces = MediaType.APPLICATION_JSON_VALUE) @LoggingAspect @TrackExecutionTime @Operation(description = "Fetch Employee By Employee Id", summary = "Fetch Employee By Employee Id") public EmployeeResponse getEmployeeById(@PathVariable @Min(1) Long id) throws EmployeeNotFoundException { return employeeManagementService.getEmployeeById(id); } @PostMapping(value = "/employees", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE) @LoggingAspect @TrackExecutionTime @Operation(description = "Create Employee", summary = "Create Employee") public EmployeeResponse createEmployee(@RequestBody @Valid EmployeeRequest emp) { return employeeManagementService.createEmployee(emp); } @PutMapping(value = "/employees/{id}", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE) @LoggingAspect @TrackExecutionTime @Operation(description = "Create Employee", summary = "Create Employee") public EmployeeResponse updateEmployeeById(@RequestBody @Valid EmployeeRequest emp, @PathVariable @Min(1) Long id) throws EmployeeNotFoundException { return employeeManagementService.updateEmployeeById(emp, id); } @DeleteMapping(value = "/employees/{id}", produces = MediaType.APPLICATION_JSON_VALUE) @LoggingAspect @TrackExecutionTime @Operation(description = "Delete Employee By Employee Id", summary = "Delete Employee By Employee Id") public EmployeeResponse deleteEmployeeById(@PathVariable @Min(1) Long id) { return employeeManagementService.deleteEmployeeById(id); } }
BO
package com.praveen.employee.bo; import com.praveen.employee.validators.AppId; import jakarta.validation.constraints.Digits; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; public record EmployeeRequest(@AppId @NotNull String appId, @NotEmpty @Size(min = 0, max = 20) String transactionId, @NotEmpty @Size(min = 0, max = 20) String name, @NotEmpty @Size(min = 0, max = 20) String designation, @Digits(fraction = 2, integer = 25) Long salary, @NotEmpty @Size(min = 0, max = 20) String location) { } package com.praveen.employee.bo; public record EmployeeResponse(String status, String transactionId, String appId, EmployeeResponseData data, ErrorMessage errors) { } package com.praveen.employee.bo; public record EmployeeResponseData(Long id, String name, String designation, Long salary, String location) { } package com.praveen.employee.bo; import java.util.Map; import io.swagger.v3.oas.annotations.media.Schema; @Schema(name = "ErrorMessage", example = "Error Details to describe the Client, Business and System Errors") public record ErrorMessage(@Schema(name = "code", description = "4000,6000,5000", example = "4000") String code, @Schema(name = "description", description = "Input Request Validation Failure and Internal Service Failures", example = "Input Request Validation Failure") String description, @Schema(name = "errors", example = "{\"400001\":\"Request Json is malformed or invalid\"}") Map<String, String> errors) { } package com.praveen.employee.bo; public record ValidationError(String code, String description) { } package com.praveen.employee.bo; public record CommonError(String code, String description) { }
application.yml
spring: application: name: emp-mgmt-service datasource: url: jdbc:postgresql://localhost:5432/praveenoruganti username: postgres password: praveen hikari: minimum-idle: 5 maximum-pool-size: 10 jpa: hibernate: ddl-auto: update show-sql: true format-sql: true springdoc: use-fqn: true swagger-ui: doc-expansion: none operationsSorter: method # supportedSubmitMethods: [] logging: structured: format: console: ecs app: openApiTitle: Employee Management Service openApiDescription: | Employee Management Service is used to manage employees. validationErrors: id: - code: 400010 description: Invalid Employee Id name: - code: 400004 description: Invalid Name transactionId: - code: 400005 description: Invalid Transaction Id and max length should be less then 20 appId: - code: 400006 description: Invalid App ID salary: - code: 400007 description: Invalid Salary Amount and it needs to be with 2 decimals max designation: - code: 400008 description: Invalid Designation location: - code: 400009 description: Invalid Location messageNotReadableError: code: 400001 description: Request Json is malformed or invalid invalidHttpMethodError: code: 400002 description: Request Method is not allowed invalidInputError: code: 400003 description: Dynamic Content type not supported clientError: code: 4000 description: Input Validation Failure businessError: code: 6000 description: Business Validation Failure serverError: code: 5000 description: Internal Server Error serverErrors: code: 500001 description: Unable to process the request. Please contact support team.
Lets try out some scenarios,
Success
{ "status": "Success", "transactionId": "bf663c80-bf34-4fe1-a961-8b581dd7d2c1", "appId": null, "data": { "id": 1, "name": "Praveen", "designation": "Technical Architect", "salary": 5000000, "location": "Hyderabad" }, "errors": null }
Input validation Failure
{ "status": "Fail", "transactionId": null, "appId": null, "data": null, "errors": { "code": "4000", "description": "Input Validation Failure", "errors": { "400005": "Invalid Transaction Id and max length should be less then 20", "400006": "Invalid App ID", "400009": "Invalid Location" } } }