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
Scenario | Request | Response |
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! ✨
Leave a Reply