Centralized Exception Handling in Spring Boot.
Have you ever seen this in production?
Centralized Exception Handling in Spring Boot.

Have you ever seen this in production?
{
"timestamp": "2024-12-23T10:30:45.123+00:00",
"status": 500,
"error": "Internal Server Error",
"message": "",
"path": "/api/users/123"
}
Frustrating, right? Your API throws an error, but the user has no idea what went wrong. Is it their fault? Is the server down? Should they try again?
Now imagine this instead:
{
"success": false,
"timestamp": "2024-12-23T10:30:45.123Z",
"status": 404,
"error": "USER_NOT_FOUND",
"message": "User with ID 123 does not exist in our system",
"path": "/api/users/123",
"suggestions": ["Check the user ID", "Ensure the user hasn't been deleted"]
}
Much better! Clear, helpful, and professional.
In this comprehensive guide, you’ll learn how to build a production-ready, industry-standard exception handling system in Spring Boot that will make your APIs robust, user-friendly, and maintainable.
Table of Contents
- Why Exception Handling Matters
- The Problem with Default Exception Handling
- Understanding @RestControllerAdvice
- Building a Generic Response Structure
- Creating Custom Exceptions
- Implementing Global Exception Handler
- Real-World Example: E-Commerce API
- Advanced Techniques
- Best Practices
- Testing Your Exception Handlers
- Conclusion
Why Exception Handling Matters
The Business Impact
Imagine you’re running an e-commerce platform. A customer tries to checkout:
Without proper exception handling:
Error 500: Internal Server Error
Result: Customer abandons cart. Lost sale. Poor reviews.
With proper exception handling:
{
"message": "Your credit card was declined. Please use a different payment method.",
"errorCode": "PAYMENT_DECLINED",
"retryable": true
}
Result: Customer tries another card. Sale completed. Happy customer!
Technical Benefits
- Debugging Made Easy: Know exactly what went wrong and where
- Better User Experience: Clear, actionable error messages
- Monitoring & Alerts: Track error patterns and fix issues proactively
- Security: Don’t expose sensitive stack traces to end users
- Consistency: Uniform error format across all endpoints
The Problem with Default Exception Handling
Let’s see what happens without centralized exception handling:
Example: User Service Without Exception Handling
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/{id}")
public User getUserById(@PathVariable Long id) {
// What if user doesn't exist?
return userService.findById(id);
}
@PostMapping
public User createUser(@RequestBody User user) {
// What if email already exists?
// What if validation fails?
return userService.save(user);
}
@DeleteMapping("/{id}")
public void deleteUser(@PathVariable Long id) {
// What if user doesn't exist?
// What if user has active orders?
userService.delete(id);
}
}
Problems:
No error handling — Application crashes on errors Inconsistent responses — Different endpoints return different error formats Poor user experience — Generic 500 errors everywhere Security risk — Stack traces exposed to users Difficult to debug — No structured logging
Understanding @RestControllerAdvice
@RestControllerAdvice is Spring’s superhero for exception handling! Think of it as a global error manager that catches all exceptions thrown by your controllers.
How It Works
┌─────────────┐
│ Client │
└──────┬──────┘
│ Request
▼
┌─────────────────┐
│ Controller │
└────────┬────────┘
│
│ Exception thrown!
▼
┌──────────────────────┐
│ @RestControllerAdvice│ ◄── Catches the exception
└─────────┬────────────┘
│ Processes & formats
▼
┌─────────────────────┐
│ Formatted Response │
└──────────┬──────────┘
│
▼
┌─────────────┐
│ Client │ ◄── Gets meaningful error
└─────────────┘
Key Features
- Global Scope: Catches exceptions from all controllers
- Type-Safe: Handle specific exception types differently
- HTTP Status Mapping: Automatically set appropriate HTTP status codes
- Response Formatting: Convert exceptions to JSON/XML responses
- Logging Integration: Easy to add logging for monitoring
Building a Generic Response Structure
Before we handle exceptions, let’s create a standardized response structure that works for both success and error cases.
Step 1: Create ApiResponse Class
package com.example.demo.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL) // Only include non-null fields
public class ApiResponse<T> {
private boolean success; // true for success, false for error
private String message; // User-friendly message
private T data; // Actual data (for success responses)
private ErrorDetails error; // Error details (for error responses)
private LocalDateTime timestamp; // When did this happen?
private String path; // Which endpoint was called?
// Success response factory method
public static <T> ApiResponse<T> success(T data, String message) {
return ApiResponse.<T>builder()
.success(true)
.message(message)
.data(data)
.timestamp(LocalDateTime.now())
.build();
}
// Error response factory method
public static <T> ApiResponse<T> error(ErrorDetails error, String path) {
return ApiResponse.<T>builder()
.success(false)
.error(error)
.timestamp(LocalDateTime.now())
.path(path)
.build();
}
}
Step 2: Create ErrorDetails Class
package com.example.demo.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ErrorDetails {
private String errorCode; // Machine-readable error code
private String message; // User-friendly message
private int statusCode; // HTTP status code
private List<String> details; // Additional error details
private List<String> suggestions; // What user should do next
private String debugMessage; // For developers (only in dev mode)
}
Why This Structure?
- Consistent Format: All responses follow the same structure
- Clear Status: success field immediately tells if request succeeded
- Detailed Errors: Multiple levels of error information
- Actionable: suggestions field tells users what to do
- Debuggable: debugMessage helps developers troubleshoot
- Flexible: Generic type <T> works with any data type
Creating Custom Exceptions
Let’s create custom exceptions that represent different business scenarios.
Step 1: Base Custom Exception
package com.example.demo.exception;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
public class BaseException extends RuntimeException {
private final String errorCode;
private final HttpStatus httpStatus;
private final String[] suggestions;
public BaseException(String message, String errorCode,
HttpStatus httpStatus, String... suggestions) {
super(message);
this.errorCode = errorCode;
this.httpStatus = httpStatus;
this.suggestions = suggestions;
}
}
Step 2: Specific Custom Exceptions
package com.example.demo.exception;
import org.springframework.http.HttpStatus;
// 1. Resource Not Found Exception
public class ResourceNotFoundException extends BaseException {
public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
super(
String.format("%s not found with %s: '%s'", resourceName, fieldName, fieldValue),
"RESOURCE_NOT_FOUND",
HttpStatus.NOT_FOUND,
"Check if the " + fieldName + " is correct",
"Verify the resource hasn't been deleted"
);
}
}
// 2. Resource Already Exists Exception
public class ResourceAlreadyExistsException extends BaseException {
public ResourceAlreadyExistsException(String resourceName, String fieldName, Object fieldValue) {
super(
String.format("%s already exists with %s: '%s'", resourceName, fieldName, fieldValue),
"RESOURCE_ALREADY_EXISTS",
HttpStatus.CONFLICT,
"Use a different " + fieldName,
"Update the existing resource instead of creating new one"
);
}
}
// 3. Invalid Request Exception
public class InvalidRequestException extends BaseException {
public InvalidRequestException(String message) {
super(
message,
"INVALID_REQUEST",
HttpStatus.BAD_REQUEST,
"Check your request parameters",
"Refer to API documentation for correct format"
);
}
}
// 4. Business Logic Exception
public class BusinessLogicException extends BaseException {
public BusinessLogicException(String message, String... suggestions) {
super(
message,
"BUSINESS_LOGIC_ERROR",
HttpStatus.UNPROCESSABLE_ENTITY,
suggestions
);
}
}
// 5. Unauthorized Exception
public class UnauthorizedException extends BaseException {
public UnauthorizedException(String message) {
super(
message,
"UNAUTHORIZED",
HttpStatus.UNAUTHORIZED,
"Check your authentication credentials",
"Ensure your session hasn't expired"
);
}
}
// 6. Forbidden Exception
public class ForbiddenException extends BaseException {
public ForbiddenException(String message) {
super(
message,
"FORBIDDEN",
HttpStatus.FORBIDDEN,
"You don't have permission to access this resource",
"Contact your administrator for access"
);
}
}
Implementing Global Exception Handler
Now, let’s create the star of the show — our Global Exception Handler!
package com.example.demo.exception;
import com.example.demo.dto.ApiResponse;
import com.example.demo.dto.ErrorDetails;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@Value("${app.debug:false}")
private boolean debugMode;
// 1. Handle Custom Base Exceptions
@ExceptionHandler(BaseException.class)
public ResponseEntity<ApiResponse<Void>> handleBaseException(
BaseException ex,
HttpServletRequest request) {
log.error("BaseException occurred: {}", ex.getMessage(), ex);
ErrorDetails errorDetails = ErrorDetails.builder()
.errorCode(ex.getErrorCode())
.message(ex.getMessage())
.statusCode(ex.getHttpStatus().value())
.suggestions(Arrays.asList(ex.getSuggestions()))
.debugMessage(debugMode ? ex.toString() : null)
.build();
ApiResponse<Void> response = ApiResponse.error(errorDetails, request.getRequestURI());
return new ResponseEntity<>(response, ex.getHttpStatus());
}
// 2. Handle Validation Errors
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationException(
MethodArgumentNotValidException ex,
HttpServletRequest request) {
log.error("Validation error occurred: {}", ex.getMessage());
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.toList());
ErrorDetails errorDetails = ErrorDetails.builder()
.errorCode("VALIDATION_ERROR")
.message("Invalid request parameters")
.statusCode(HttpStatus.BAD_REQUEST.value())
.details(errors)
.suggestions(Arrays.asList(
"Check all required fields",
"Ensure data types are correct",
"Refer to API documentation"
))
.debugMessage(debugMode ? ex.toString() : null)
.build();
ApiResponse<Void> response = ApiResponse.error(errorDetails, request.getRequestURI());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
// 3. Handle Type Mismatch Errors (e.g., String instead of Integer)
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiResponse<Void>> handleTypeMismatchException(
MethodArgumentTypeMismatchException ex,
HttpServletRequest request) {
log.error("Type mismatch error: {}", ex.getMessage());
String message = String.format(
"Parameter '%s' should be of type '%s'",
ex.getName(),
ex.getRequiredType().getSimpleName()
);
ErrorDetails errorDetails = ErrorDetails.builder()
.errorCode("TYPE_MISMATCH")
.message(message)
.statusCode(HttpStatus.BAD_REQUEST.value())
.suggestions(Arrays.asList(
"Check the parameter type in your request",
"Ensure you're sending the correct data type"
))
.debugMessage(debugMode ? ex.toString() : null)
.build();
ApiResponse<Void> response = ApiResponse.error(errorDetails, request.getRequestURI());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
// 4. Handle Null Pointer Exceptions
@ExceptionHandler(NullPointerException.class)
public ResponseEntity<ApiResponse<Void>> handleNullPointerException(
NullPointerException ex,
HttpServletRequest request) {
log.error("Null pointer exception occurred: {}", ex.getMessage(), ex);
ErrorDetails errorDetails = ErrorDetails.builder()
.errorCode("NULL_POINTER_ERROR")
.message("An unexpected error occurred while processing your request")
.statusCode(HttpStatus.INTERNAL_SERVER_ERROR.value())
.suggestions(Arrays.asList(
"Please try again",
"If the problem persists, contact support"
))
.debugMessage(debugMode ? ex.toString() : null)
.build();
ApiResponse<Void> response = ApiResponse.error(errorDetails, request.getRequestURI());
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
// 5. Handle All Other Exceptions (Catch-All)
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGlobalException(
Exception ex,
HttpServletRequest request) {
log.error("Unexpected error occurred: {}", ex.getMessage(), ex);
ErrorDetails errorDetails = ErrorDetails.builder()
.errorCode("INTERNAL_SERVER_ERROR")
.message("An unexpected error occurred. Our team has been notified.")
.statusCode(HttpStatus.INTERNAL_SERVER_ERROR.value())
.suggestions(Arrays.asList(
"Please try again later",
"Contact support if the issue persists"
))
.debugMessage(debugMode ? ex.toString() : null)
.build();
ApiResponse<Void> response = ApiResponse.error(errorDetails, request.getRequestURI());
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
What’s Happening Here?
- @RestControllerAdvice: Makes this class handle exceptions globally
- @ExceptionHandler: Each method handles a specific exception type
- Logging: All exceptions are logged for monitoring
- Debug Mode: Shows detailed error info only in development
- Structured Response: Converts exceptions to our ApiResponse format
- HTTP Status Codes: Automatically sets appropriate status codes
- Catch-All Handler: Catches unexpected exceptions gracefully
Real-World Example: E-Commerce API
Let’s build a complete e-commerce API with proper exception handling!
Step 1: Domain Model
package com.example.demo.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import javax.validation.constraints.*;
import java.math.BigDecimal;
@Entity
@Table(name = "products")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Product name is required")
@Size(min = 3, max = 100, message = "Product name must be between 3 and 100 characters")
private String name;
@NotBlank(message = "Description is required")
@Size(min = 10, max = 500, message = "Description must be between 10 and 500 characters")
private String description;
@NotNull(message = "Price is required")
@DecimalMin(value = "0.01", message = "Price must be greater than 0")
private BigDecimal price;
@NotNull(message = "Stock quantity is required")
@Min(value = 0, message = "Stock cannot be negative")
private Integer stockQuantity;
@NotBlank(message = "Category is required")
private String category;
@Email(message = "Seller email must be valid")
@NotBlank(message = "Seller email is required")
private String sellerEmail;
private boolean active = true;
}
@Entity
@Table(name = "orders")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull
private Long productId;
@NotNull
@Min(1)
private Integer quantity;
@NotBlank
@Email
private String customerEmail;
@NotNull
private BigDecimal totalPrice;
@Enumerated(EnumType.STRING)
private OrderStatus status = OrderStatus.PENDING;
private LocalDateTime orderDate = LocalDateTime.now();
}
enum OrderStatus {
PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}
Step 2: Repository Layer
package com.example.demo.repository;
import com.example.demo.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
Optional<Product> findByName(String name);
boolean existsByName(String name);
}
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByCustomerEmail(String email);
}
Step 3: Service Layer with Business Logic
package com.example.demo.service;
import com.example.demo.exception.*;
import com.example.demo.model.Order;
import com.example.demo.model.Product;
import com.example.demo.repository.OrderRepository;
import com.example.demo.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductService {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
// Get product by ID
public Product getProductById(Long id) {
log.info("Fetching product with ID: {}", id);
return productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException(
"Product", "id", id
));
}
// Get all products
public List<Product> getAllProducts() {
log.info("Fetching all products");
return productRepository.findAll();
}
// Create new product
@Transactional
public Product createProduct(Product product) {
log.info("Creating new product: {}", product.getName());
// Check if product with same name already exists
if (productRepository.existsByName(product.getName())) {
throw new ResourceAlreadyExistsException(
"Product", "name", product.getName()
);
}
// Business validation
if (product.getPrice().compareTo(BigDecimal.ZERO) <= 0) {
throw new InvalidRequestException("Product price must be greater than zero");
}
return productRepository.save(product);
}
// Update product
@Transactional
public Product updateProduct(Long id, Product productDetails) {
log.info("Updating product with ID: {}", id);
Product existingProduct = getProductById(id);
// Check if new name conflicts with another product
if (!existingProduct.getName().equals(productDetails.getName()) &&
productRepository.existsByName(productDetails.getName())) {
throw new ResourceAlreadyExistsException(
"Product", "name", productDetails.getName()
);
}
existingProduct.setName(productDetails.getName());
existingProduct.setDescription(productDetails.getDescription());
existingProduct.setPrice(productDetails.getPrice());
existingProduct.setStockQuantity(productDetails.getStockQuantity());
existingProduct.setCategory(productDetails.getCategory());
return productRepository.save(existingProduct);
}
// Delete product
@Transactional
public void deleteProduct(Long id) {
log.info("Deleting product with ID: {}", id);
Product product = getProductById(id);
// Business rule: Can't delete product with pending orders
List<Order> pendingOrders = orderRepository.findByProductId(id)
.stream()
.filter(order -> order.getStatus() == OrderStatus.PENDING)
.toList();
if (!pendingOrders.isEmpty()) {
throw new BusinessLogicException(
"Cannot delete product with pending orders",
"Cancel all pending orders first",
"Mark product as inactive instead of deleting"
);
}
productRepository.delete(product);
log.info("Product deleted successfully: {}", id);
}
// Place an order
@Transactional
public Order placeOrder(Long productId, Integer quantity, String customerEmail) {
log.info("Placing order for product: {}, quantity: {}", productId, quantity);
// Validate product exists
Product product = getProductById(productId);
// Check if product is active
if (!product.isActive()) {
throw new BusinessLogicException(
"Product is currently unavailable",
"Choose a different product",
"Contact seller for availability"
);
}
// Check stock availability
if (product.getStockQuantity() < quantity) {
throw new BusinessLogicException(
String.format("Insufficient stock. Only %d items available",
product.getStockQuantity()),
"Reduce order quantity",
"Set up stock alert for this product"
);
}
// Calculate total price
BigDecimal totalPrice = product.getPrice().multiply(new BigDecimal(quantity));
// Create order
Order order = Order.builder()
.productId(productId)
.quantity(quantity)
.customerEmail(customerEmail)
.totalPrice(totalPrice)
.status(OrderStatus.PENDING)
.build();
// Update stock
product.setStockQuantity(product.getStockQuantity() - quantity);
productRepository.save(product);
Order savedOrder = orderRepository.save(order);
log.info("Order placed successfully: {}", savedOrder.getId());
return savedOrder;
}
}
Step 4: Controller Layer
package com.example.demo.controller;
import com.example.demo.dto.ApiResponse;
import com.example.demo.model.Order;
import com.example.demo.model.Product;
import com.example.demo.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
// Get all products
@GetMapping
public ResponseEntity<ApiResponse<List<Product>>> getAllProducts() {
List<Product> products = productService.getAllProducts();
ApiResponse<List<Product>> response = ApiResponse.success(
products,
"Products retrieved successfully"
);
return ResponseEntity.ok(response);
}
// Get product by ID
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Product>> getProductById(@PathVariable Long id) {
Product product = productService.getProductById(id);
ApiResponse<Product> response = ApiResponse.success(
product,
"Product retrieved successfully"
);
return ResponseEntity.ok(response);
}
// Create new product
@PostMapping
public ResponseEntity<ApiResponse<Product>> createProduct(
@Valid @RequestBody Product product) {
Product createdProduct = productService.createProduct(product);
ApiResponse<Product> response = ApiResponse.success(
createdProduct,
"Product created successfully"
);
return new ResponseEntity<>(response, HttpStatus.CREATED);
}
// Update product
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<Product>> updateProduct(
@PathVariable Long id,
@Valid @RequestBody Product product) {
Product updatedProduct = productService.updateProduct(id, product);
ApiResponse<Product> response = ApiResponse.success(
updatedProduct,
"Product updated successfully"
);
return ResponseEntity.ok(response);
}
// Delete product
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> deleteProduct(@PathVariable Long id) {
productService.deleteProduct(id);
ApiResponse<Void> response = ApiResponse.success(
null,
"Product deleted successfully"
);
return ResponseEntity.ok(response);
}
// Place order
@PostMapping("/{id}/order")
public ResponseEntity<ApiResponse<Order>> placeOrder(
@PathVariable Long id,
@RequestParam Integer quantity,
@RequestParam String customerEmail) {
Order order = productService.placeOrder(id, quantity, customerEmail);
ApiResponse<Order> response = ApiResponse.success(
order,
"Order placed successfully"
);
return new ResponseEntity<>(response, HttpStatus.CREATED);
}
}
Response Examples
Let’s see how our exception handling works in action!
Example 1: Successful Request
Request:
GET /api/products/1
Response (200 OK):
{
"success": true,
"message": "Product retrieved successfully",
"data": {
"id": 1,
"name": "MacBook Pro",
"description": "16-inch laptop with M3 chip",
"price": 2499.99,
"stockQuantity": 15,
"category": "Electronics",
"sellerEmail": "apple@store.com",
"active": true
},
"timestamp": "2024-12-23T10:30:45.123"
}
Example 2: Resource Not Found
Request:
GET /api/products/999
Response (404 NOT FOUND):
{
"success": false,
"error": {
"errorCode": "RESOURCE_NOT_FOUND",
"message": "Product not found with id: '999'",
"statusCode": 404,
"suggestions": [
"Check if the id is correct",
"Verify the resource hasn't been deleted"
]
},
"timestamp": "2024-12-23T10:31:20.456",
"path": "/api/products/999"
}
Example 3: Validation Error
Request:
POST /api/products
Content-Type: application/json
{
"name": "AB",
"description": "Short",
"price": -10,
"stockQuantity": -5
}
Response (400 BAD REQUEST):
{
"success": false,
"error": {
"errorCode": "VALIDATION_ERROR",
"message": "Invalid request parameters",
"statusCode": 400,
"details": [
"name: Product name must be between 3 and 100 characters",
"description: Description must be between 10 and 500 characters",
"price: Price must be greater than 0",
"stockQuantity: Stock cannot be negative"
],
"suggestions": [
"Check all required fields",
"Ensure data types are correct",
"Refer to API documentation"
]
},
"timestamp": "2024-12-23T10:32:10.789",
"path": "/api/products"
}
Example 4: Resource Already Exists
Request:
POST /api/products
Content-Type: application/json
{
"name": "MacBook Pro",
"description": "Already exists in database",
"price": 2499.99,
"stockQuantity": 10
}
Response (409 CONFLICT):
{
"success": false,
"error": {
"errorCode": "RESOURCE_ALREADY_EXISTS",
"message": "Product already exists with name: 'MacBook Pro'",
"statusCode": 409,
"suggestions": [
"Use a different name",
"Update the existing resource instead of creating new one"
]
},
"timestamp": "2024-12-23T10:33:45.234",
"path": "/api/products"
}
Example 5: Business Logic Error
Request:
Response (422 UNPROCESSABLE ENTITY):
POST /api/products/1/order?quantity=100&customerEmail=john@example.com
{
"success": false,
"error": {
"errorCode": "BUSINESS_LOGIC_ERROR",
"message": "Insufficient stock. Only 15 items available",
"statusCode": 422,
"suggestions": [
"Reduce order quantity",
"Set up stock alert for this product"
]
},
"timestamp": "2024-12-23T10:34:30.567",
"path": "/api/products/1/order"
}
Example 6: Type Mismatch Error
Request:
GET /api/products/abc
Response (400 BAD REQUEST):
{
"success": false,
"error": {
"errorCode": "TYPE_MISMATCH",
"message": "Parameter 'id' should be of type 'Long'",
"statusCode": 400,
"suggestions": [
"Check the parameter type in your request",
"Ensure you're sending the correct data type"
]
},
"timestamp": "2024-12-23T10:35:15.890",
"path": "/api/products/abc"
}
Best Practices
1. Exception Hierarchy
Create a clear exception hierarchy:
BaseException
├── ClientException (4xx errors)
│ ├── ResourceNotFoundException (404)
│ ├── ResourceAlreadyExistsException (409)
│ ├── InvalidRequestException (400)
│ ├── UnauthorizedException (401)
│ └── ForbiddenException (403)
└── ServerException (5xx errors)
├── DatabaseException (500)
├── ExternalServiceException (503)
└── ConfigurationException (500)
2. Log Everything
// Always log with context
log.error("Failed to create user [email={}]: {}",
user.getEmail(),
ex.getMessage(),
ex);
// Not just this
log.error("Error: {}", ex.getMessage());
3. Don’t Expose Sensitive Information
// Good ✅
"Invalid credentials"
"Authentication failed"
// Bad ❌
"User 'admin' not found in database"
"Password 'abc123' is incorrect"
"Connection string: jdbc://..."
Use HTTP Status Codes Correctly
Status Code Use Case 200 OK Request successful 201 Created Resource created 204 No Content Successful deletion 400 Bad Request Invalid input 401 Unauthorized Authentication required 403 Forbidden No permission 404 Not Found Resource doesn’t exist 409 Conflict Resource already exists 422 Unprocessable Entity Business logic error 429 Too Many Requests Rate limit exceeded 500 Internal Server Error Server error 503 Service Unavailable Service down
Conclusion
You’ve now mastered centralized exception handling in Spring Boot!
Next Steps:
- Implement this in your project today
- Add monitoring and alerting (Prometheus, Grafana)
- Integrate with APM tools (New Relic, DataDog)
- Document your error codes for API consumers
- Create a error code reference page
Complete Project Structure
spring-boot-exception-handling/
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/example/demo/
│ │ │ ├── DemoApplication.java
│ │ │ ├── controller/
│ │ │ │ └── ProductController.java
│ │ │ ├── dto/
│ │ │ │ ├── ApiResponse.java
│ │ │ │ └── ErrorDetails.java
│ │ │ ├── exception/
│ │ │ │ ├── BaseException.java
│ │ │ │ ├── ResourceNotFoundException.java
│ │ │ │ ├── ResourceAlreadyExistsException.java
│ │ │ │ ├── InvalidRequestException.java
│ │ │ │ ├── BusinessLogicException.java
│ │ │ │ ├── UnauthorizedException.java
│ │ │ │ ├── ForbiddenException.java
│ │ │ │ └── GlobalExceptionHandler.java
│ │ │ ├── model/
│ │ │ │ ├── Product.java
│ │ │ │ └── Order.java
│ │ │ ├── repository/
│ │ │ │ ├── ProductRepository.java
│ │ │ │ └── OrderRepository.java
│ │ │ └── service/
│ │ │ └── ProductService.java
│ │ └── resources/
│ │ └── application.properties
│ └── test/
│ └── java/
│ └── com/example/demo/
│ ├── ProductControllerTest.java
│ └── ProductIntegrationTest.java
├── pom.xml
└── README.md
🙋♂️ Questions?
Drop your questions in the comments! I’d love to help you implement this in your project.
Did This Help?
If you found this guide helpful:
- Give it a clap! 👏
- Share it with your team
- Follow for more Spring Boot content
Happy Coding!
메타데이터
- post_id
- 1584ef760c89
- slug
- centralized-exception-handling-in-spring-boot-1584ef760c89
- url
- https://medium.com/@JavaFusion/centralized-exception-handling-in-spring-boot-1584ef760c89
- canonical_url
- https://medium.com/@JavaFusion/centralized-exception-handling-in-spring-boot-1584ef760c89
- author_url
- https://medium.com/@JavaFusion
- status
- ok
- fetched_at
- 2026-06-24 11:06:28