← Back to list

Centralized Exception Handling in Spring Boot.

Have you ever seen this in production?

Java Fusion · 2025-12-22 19:37 · 2 claps · 12.5 min read
#spring-boot #exception-handling #exception #java #erros
Open on Medium ↗

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

  1. Why Exception Handling Matters
  2. The Problem with Default Exception Handling
  3. Understanding @RestControllerAdvice
  4. Building a Generic Response Structure
  5. Creating Custom Exceptions
  6. Implementing Global Exception Handler
  7. Real-World Example: E-Commerce API
  8. Advanced Techniques
  9. Best Practices
  10. Testing Your Exception Handlers
  11. 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

  1. Debugging Made Easy: Know exactly what went wrong and where
  2. Better User Experience: Clear, actionable error messages
  3. Monitoring & Alerts: Track error patterns and fix issues proactively
  4. Security: Don’t expose sensitive stack traces to end users
  5. 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

  1. Global Scope: Catches exceptions from all controllers
  2. Type-Safe: Handle specific exception types differently
  3. HTTP Status Mapping: Automatically set appropriate HTTP status codes
  4. Response Formatting: Convert exceptions to JSON/XML responses
  5. 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?

  1. @RestControllerAdvice: Makes this class handle exceptions globally
  2. @ExceptionHandler: Each method handles a specific exception type
  3. Logging: All exceptions are logged for monitoring
  4. Debug Mode: Shows detailed error info only in development
  5. Structured Response: Converts exceptions to our ApiResponse format
  6. HTTP Status Codes: Automatically sets appropriate status codes
  7. 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:

  1. Implement this in your project today
  2. Add monitoring and alerting (Prometheus, Grafana)
  3. Integrate with APM tools (New Relic, DataDog)
  4. Document your error codes for API consumers
  5. 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