Spring Boot Exception Handling – From Basics to Pro

Spring Boot Exception Handling – From Basics to Pro

When you build a real application in Spring Boot, errors are as important as success.

Let’s say you are developing a banking application and a user tries to withdraw more money than available, you don’t want to show a scary stack trace — you want to show a clear error message.

In this post, we’ll go step by step, with real examples from a banking system.

We’ll cover:

✅ Why exception handling is important

✅ A great response structure (success & error)

✅ Controller‑level exception handling

✅ Multiple exceptions in the same method

✅ Global exception handling with @ControllerAdvice

✅ Interview Q&A with code examples

Let’s dive in. 🚀

💡 1. Why Exception Handling is Important

Imagine an API in a banking app:

POST /api/accounts/withdraw

Body: { “accountId”: 101, “amount”: 5000 }

If account 101 doesn’t have enough balance, what should you return?

❌ Bad way:

A long stack trace or generic 500 Internal Server Error.

✅ Good way:

{
  "success": false,
  "error": {
    "errorCode": "INSUFFICIENT_BALANCE",
    "message": "Your account balance is too low for this transaction"
  },
  "timestamp": "2025-07-17T21:00:00Z"
}

📦 2. A Great Response Structure

Let’s define a simple response wrapper for all APIs:

public class ApiResponse<T> {

    private boolean success;

    private T data; // success response

    private ApiError error; // error response

    private LocalDateTime timestamp = LocalDateTime.now();

    // getters, setters, constructors

}

public class ApiError {

    private String errorCode;

    private String message;

    // getters, setters, constructors

}

✅ Success example:

{
  "success": true,
  "data": {
    "transactionId": 9091,
    "newBalance": 1500
  },
  "timestamp": "2025-07-17T21:01:00"
}

✅ Error example:

{
  "success": false,
  "error": { "errorCode": "ACCOUNT_NOT_FOUND", "message": "No account with ID 9999" },
  "timestamp": "2025-07-17T21:01:30"
}

✨ 3. Controller‑Level Exception Handling (Basic)

Sometimes you want a quick handler inside the same controller:

@RestController
@RequestMapping("/api/accounts")
public class AccountController {

    private final AccountService accountService;
    
    public AccountController(AccountService accountService) {
        this.accountService = accountService;
    }

    @PostMapping("/withdraw")
    public ApiResponse<TransactionDto> withdraw(@RequestBody WithdrawRequest request) {
        TransactionDto dto = accountService.withdraw(request.getAccountId(), request.getAmount());
        return new ApiResponse<>(true, dto, null);
    }
    // ✅ Controller-level exception handler

    @ExceptionHandler(InsufficientBalanceException.class)
    public ApiResponse<?> handleInsufficientBalance(InsufficientBalanceException ex) {
        return new ApiResponse<>(false, null,

                new ApiError("INSUFFICIENT_BALANCE", ex.getMessage()));
    }
}

👉 Pros: Quick and local.

👉 Cons: Duplicated in many controllers.

🔀 4. Multiple Exceptions in the Same Method

What if withdraw() can throw multiple errors?

E.g. AccountNotFoundException or InsufficientBalanceException.

Add more handlers in the same controller:

@ExceptionHandler(AccountNotFoundException.class)
public ApiResponse<?> handleAccountNotFound(AccountNotFoundException ex) {

    return new ApiResponse<>(false, null,new ApiError("ACCOUNT_NOT_FOUND", ex.getMessage()));

}

@ExceptionHandler(InsufficientBalanceException.class)
public ApiResponse<?> handleBalance(InsufficientBalanceException ex) {
    return new ApiResponse<>(false, null,new ApiError("INSUFFICIENT_BALANCE", ex.getMessage()));
}

✅ Now your controller is getting cluttered… time for a better way!

🌍 5. Global Exception Handling with 

@RestControllerAdvice

Instead of repeating handlers in every controller, use a global exception handler:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(AccountNotFoundException.class)
    public ApiResponse<?> handleAccountNotFound(AccountNotFoundException ex) {
        return new ApiResponse<>(false, null,new ApiError("ACCOUNT_NOT_FOUND", ex.getMessage()));
    }
    @ExceptionHandler(InsufficientBalanceException.class)
    public ApiResponse<?> handleBalance(InsufficientBalanceException ex) {
        return new ApiResponse<>(false, null,new ApiError("INSUFFICIENT_BALANCE", ex.getMessage()));
    }
    // catch-all fallback
    @ExceptionHandler(Exception.class)
    public ApiResponse<?> handleGeneric(Exception ex) {
        return new ApiResponse<>(false, null,new ApiError("INTERNAL_ERROR", "Something went wrong"));
    }
}

✅ Benefits:

  • Centralized logic
  • Cleaner controllers
  • Easy to add new exception types later

🔥 Real Banking Example Flow

ScenarioRequestResponse
Withdraw more than balance{“accountId”:101,”amount”:5000}{"success":false,"error":{"errorCode":"INSUFFICIENT_BALANCE","message":"Your account balance is too low"}}
Withdraw from invalid account{“accountId”:9999,”amount”:500}{"success":false,"error":{"errorCode":"ACCOUNT_NOT_FOUND","message":"No account with ID 9999"}}
Withdraw valid{“accountId”:101,”amount”:500}{"success":true,"data":{"transactionId":4501,"newBalance":1500}}

💼 Interview Questions with Code Examples

Q1️⃣: What’s the difference between @ExceptionHandler in a controller and @ControllerAdvice?

✅ Answer:

@ExceptionHandler inside a controller handles exceptions only for that controller.

@RestControllerAdvice is a global handler for all REST controllers.

// Controller-level
@ExceptionHandler(MyCustomException.class)
public ApiResponse<?> handleLocal(MyCustomException ex) {

}

// Global-level
@RestControllerAdvice
public class GlobalHandler {

    @ExceptionHandler(MyCustomException.class)

    public ApiResponse<?> handleGlobal(MyCustomException ex) { … }

}

Q2️⃣: How can you handle multiple exceptions in the same handler?

✅ Answer: Use an array in @ExceptionHandler.

@ExceptionHandler({ AccountNotFoundException.class, InsufficientBalanceException.class })
public ApiResponse<?> handleMultiple(RuntimeException ex) {

    return new ApiResponse<>(false, null,new ApiError("BANK_ERROR", ex.getMessage()));

}

Q3️⃣: How do you return proper HTTP status codes with your custom response?

✅ Answer: Annotate the exception class or set status in handler.

@ResponseStatus(HttpStatus.NOT_FOUND)
public class AccountNotFoundException extends RuntimeException {
    public AccountNotFoundException(String msg) { super(msg); }
}

or

@ExceptionHandler(AccountNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<?> handleNotFound(AccountNotFoundException ex) {
    return new ApiResponse<>(false, null,new ApiError("ACCOUNT_NOT_FOUND", ex.getMessage()));
}

Q4️⃣: How do you log exceptions without exposing sensitive info?

✅ Answer: Use a logger in your handler:

private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
public ApiResponse<?> handleGeneric(Exception ex) {
    logger.error("Unexpected error", ex);
    return new ApiResponse<>(false, null,new ApiError("INTERNAL_ERROR", "Something went wrong"));

}

🚀 Final Thoughts

✅ Controller-level exception handling is good for quick fixes.

✅ @ControllerAdvice is the professional way for real applications.

✅ Always design a clear API response structure so your frontend knows what to expect.

✅ These patterns not only make your app reliable but also impress in interviews.

💬 What do you think?

✨ Happy coding and may your APIs never crash! ✨