Java · lesson 55 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Exception Handling in Spring Boot
Handle errors in a Spring Boot 3 API: default 500s, @ResponseStatus, @RestControllerAdvice, RFC 9457 ProblemDetail responses and a safe catch-all.
By SkillByExample · Updated
An API’s errors are part of its contract: a client needs a predictable status code and a body it
can parse. This lesson takes a small bank API built with Spring Boot 3.5, starts it with
java -jar target/bank-0.0.1-SNAPSHOT.jar, and improves its error responses step by step. Every
response is what curl received; code blocks start at the type declaration, with imports omitted.
What happens without any handling
@Service
public class AccountService {
private final Map<Long, BigDecimal> balances = new ConcurrentHashMap<>(Map.of(1L, new BigDecimal("100.00")));
public BigDecimal balance(long id) {
BigDecimal b = balances.get(id);
if (b == null) throw new IllegalStateException("no account " + id);
return b;
}
public BigDecimal withdraw(long id, BigDecimal amount) {
BigDecimal b = balance(id);
if (b.compareTo(amount) < 0) throw new IllegalArgumentException("balance " + b + " is less than " + amount);
balances.put(id, b.subtract(amount));
return b.subtract(amount);
}
}
@RestController
@RequestMapping("/api/accounts")
public class AccountController {
private final AccountService service;
public AccountController(AccountService service) { this.service = service; }
record Balance(long id, BigDecimal balance) {}
record Withdrawal(@NotNull @DecimalMin("0.01") BigDecimal amount) {}
@GetMapping("/{id}")
public Balance get(@PathVariable long id) {
return new Balance(id, service.balance(id));
}
@PostMapping("/{id}/withdraw")
public Balance withdraw(@PathVariable long id, @Valid @RequestBody Withdrawal w) {
return new Balance(id, service.withdraw(id, w.amount()));
}
}
$ curl -s -w '\n' localhost:8080/api/accounts/1
{"id":1,"balance":100.00}
$ curl -s -w '\n' localhost:8080/api/accounts/7
{"timestamp":"2026-10-07T22:17:11.450+00:00","status":500,"error":"Internal Server Error","path":"/api/accounts/7"}
$ curl -s -w '\n' localhost:8080/api/accounts/1/withdraw -H 'Content-Type: application/json' -d '{"amount":500}'
{"timestamp":"2026-10-07T22:17:11.553+00:00","status":500,"error":"Internal Server Error","path":"/api/accounts/1/withdraw"}
Server log for the first failure (stack trace trimmed):
2026-10-07T22:17:11.439Z ERROR 6536 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalStateException
...
java.lang.IllegalStateException: no account 7
at com.example.bank.AccountService.balance(AccountService.java:15) ~[!/:0.0.1-SNAPSHOT]
at com.example.bank.AccountController.get(AccountController.java:23) ~[!/:0.0.1-SNAPSHOT]
...
A missing account and an overdraft are ordinary outcomes, yet both came back as 500, which tells the client “our bug, retry later”. Spring Boot’s fallback error body also omits the exception message on purpose — messages can leak internals — so the client cannot tell the two cases apart.
Domain exceptions and @ResponseStatus
Give each failure its own exception type, carrying the data a client would need.
@ResponseStatus(HttpStatus.NOT_FOUND) // used when nothing else handles it
public class AccountNotFoundException extends RuntimeException {
public AccountNotFoundException(long id) {
super("Account " + id + " does not exist");
}
}
public class InsufficientFundsException extends RuntimeException {
private final BigDecimal balance;
private final BigDecimal requested;
public InsufficientFundsException(BigDecimal balance, BigDecimal requested) {
super("Cannot withdraw " + requested + " from a balance of " + balance);
this.balance = balance;
this.requested = requested;
}
public BigDecimal balance() { return balance; }
public BigDecimal requested() { return requested; }
}
// AccountService now throws them
if (b == null) throw new AccountNotFoundException(id);
// ...
if (b.compareTo(amount) < 0) throw new InsufficientFundsException(b, amount);
$ curl -s -w '\n' localhost:8080/api/accounts/7
{"timestamp":"2026-10-07T22:18:58.135+00:00","status":404,"error":"Not Found","path":"/api/accounts/7"}
@ResponseStatus on the exception class is the smallest fix: the status is now right. But the body is
still the generic one with no detail, and the annotation ties a domain class to HTTP. A global
handler fixes both.
@RestControllerAdvice and ProblemDetail
@RestControllerAdvice
public class ApiErrors {
@ExceptionHandler(AccountNotFoundException.class)
ProblemDetail notFound(AccountNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Account not found");
pd.setType(URI.create("https://bank.example.com/problems/account-not-found"));
return pd;
}
@ExceptionHandler(InsufficientFundsException.class)
ProblemDetail insufficientFunds(InsufficientFundsException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
pd.setTitle("Insufficient funds");
pd.setProperty("balance", ex.balance()); // extra members for clients to act on
pd.setProperty("requested", ex.requested());
return pd;
}
}
$ curl -si localhost:8080/api/accounts/7 | grep -iE '^(HTTP|Content-Type|\{)'
HTTP/1.1 404
Content-Type: application/problem+json
{"type":"https://bank.example.com/problems/account-not-found","title":"Account not found","status":404,"detail":"Account 7 does not exist","instance":"/api/accounts/7"}
$ curl -s -w '\n' localhost:8080/api/accounts/1/withdraw -H 'Content-Type: application/json' -d '{"amount":500}'
{"type":"about:blank","title":"Insufficient funds","status":422,"detail":"Cannot withdraw 500 from a balance of 100.00","instance":"/api/accounts/1/withdraw","balance":100.00,"requested":500}
$ curl -s -w '\n' localhost:8080/api/accounts/1/withdraw -H 'Content-Type: application/json' -d '{"amount":30}'
{"id":1,"balance":70.00}
ProblemDetail is Spring’s implementation of the standard error format of RFC 9457 (the successor
of RFC 7807). Spring fills in instance from the request path and sends
Content-Type: application/problem+json, which clients and API gateways recognise. type is a URI
naming the kind of problem (about:blank when unset); setProperty adds extra members, so a client
can show “you have 100.00” without parsing the detail text. The overdraft is a 422 Unprocessable
Entity: the request was well-formed but breaks a business rule.
The controller and service contain no error-handling code at all. But Spring’s own errors are still in the old format:
$ curl -s -w '\n' localhost:8080/api/accounts/1/withdraw -H 'Content-Type: application/json' -d '{"amount":0}'
{"timestamp":"2026-10-07T22:20:43.703+00:00","status":400,"error":"Bad Request","path":"/api/accounts/1/withdraw"}
$ curl -s -w '\n' -X DELETE localhost:8080/api/accounts/1
{"timestamp":"2026-10-07T22:20:43.719+00:00","status":405,"error":"Method Not Allowed","path":"/api/accounts/1"}
One format for every error, and a safe catch-all
Extending ResponseEntityExceptionHandler renders all of Spring MVC’s exceptions as ProblemDetail
and lets you override any one of them.
@RestControllerAdvice
public class ApiErrors extends ResponseEntityExceptionHandler { // Spring's own errors -> ProblemDetail
private static final Logger log = LoggerFactory.getLogger(ApiErrors.class);
@Override // add the field errors to the 400
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
Map<String, String> errors = new TreeMap<>();
ex.getBindingResult().getFieldErrors().forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
ProblemDetail pd = ex.getBody();
pd.setProperty("errors", errors);
return ResponseEntity.status(status).body(pd);
}
@ExceptionHandler(Exception.class) // last resort: never leak internals
ProblemDetail unexpected(Exception ex) {
log.error("Unhandled exception", ex);
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
}
// ... plus the notFound and insufficientFunds handlers from before
}
To see the catch-all, AccountService.balance gets a simulated bug:
if (id == 13) throw new IllegalStateException("ledger connection reset");
$ curl -s -w '\n' localhost:8080/api/accounts/1/withdraw -H 'Content-Type: application/json' -d '{"amount":0}'
{"type":"about:blank","title":"Bad Request","status":400,"detail":"Invalid request content.","instance":"/api/accounts/1/withdraw","errors":{"amount":"must be greater than or equal to 0.01"}}
$ curl -s -w '\n' localhost:8080/api/accounts/1/withdraw -H 'Content-Type: application/json' -d '{"amount": }'
{"type":"about:blank","title":"Bad Request","status":400,"detail":"Failed to read request","instance":"/api/accounts/1/withdraw"}
$ curl -s -w '\n' -X DELETE localhost:8080/api/accounts/1
{"type":"about:blank","title":"Method Not Allowed","status":405,"detail":"Method 'DELETE' is not supported.","instance":"/api/accounts/1"}
$ curl -s -w '\n' localhost:8080/api/accounts/abc
{"type":"about:blank","title":"Bad Request","status":400,"detail":"Failed to convert 'id' with value: 'abc'","instance":"/api/accounts/abc"}
$ curl -s -w '\n' localhost:8080/api/nothing-here
{"type":"about:blank","title":"Not Found","status":404,"detail":"No static resource api/nothing-here.","instance":"/api/nothing-here"}
$ curl -s -w '\n' localhost:8080/api/accounts/13
{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"An unexpected error occurred","instance":"/api/accounts/13"}
$ curl -s -w '\n' localhost:8080/api/accounts/7
{"type":"https://bank.example.com/problems/account-not-found","title":"Account not found","status":404,"detail":"Account 7 does not exist","instance":"/api/accounts/7"}
Server log for the /13 request:
2026-10-07T22:22:37.359Z ERROR 7511 --- [nio-8080-exec-7] com.example.bank.ApiErrors : Unhandled exception
java.lang.IllegalStateException: ledger connection reset
at com.example.bank.AccountService.balance(AccountService.java:15) ~[!/:0.0.1-SNAPSHOT]
...
Now every response — validation, malformed JSON, wrong method, bad path variable, unknown URL — has
the same shape. The overridden handleMethodArgumentNotValid added an errors member naming the
field. For the unexpected exception the client gets a generic 500 while the stack trace goes to
the log: never put ex.getMessage() of an unknown exception in a response. Specific handlers win
over the Exception.class one, which is why /7 is still a tailored 404. (Setting
spring.mvc.problemdetails.enabled=true gets the ProblemDetail format for Spring’s errors without
subclassing, if you don’t need to customise them.)
Testing error responses
@WebMvcTest(AccountController.class) // also picks up @RestControllerAdvice beans
@Import(AccountService.class)
class AccountControllerTest {
@Autowired MockMvc mvc;
@Test
void overdraftIsA422Problem() throws Exception {
mvc.perform(post("/api/accounts/1/withdraw")
.contentType(MediaType.APPLICATION_JSON).content("{\"amount\":500}"))
.andExpect(status().isUnprocessableEntity())
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.title").value("Insufficient funds"))
.andExpect(jsonPath("$.balance").value(100.00));
}
}
$ mvn -B test 2>&1 | grep -E 'Tests run:|BUILD'
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.421 s -- in com.example.bank.AccountControllerTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
@WebMvcTest loads controller advice along with the controller, so the error contract can be tested
without starting a server. Asserting on the content type catches a handler that accidentally falls
back to the plain JSON format.
Practice
- Account 2 (balance 50.00) is frozen. Withdrawing from it should return a 409 Conflict problem
with type
https://bank.example.com/problems/account-frozen.
Solution
public class AccountFrozenException extends RuntimeException {
public AccountFrozenException(long id) {
super("Account " + id + " is frozen");
}
}
// AccountService
private final Map<Long, BigDecimal> balances = new ConcurrentHashMap<>(Map.of(1L, new BigDecimal("100.00"), 2L, new BigDecimal("50.00")));
private final Set<Long> frozen = Set.of(2L);
// in withdraw(), after balance(id):
if (frozen.contains(id)) throw new AccountFrozenException(id);
// ApiErrors
@ExceptionHandler(AccountFrozenException.class)
ProblemDetail frozen(AccountFrozenException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
pd.setTitle("Account frozen");
pd.setType(URI.create("https://bank.example.com/problems/account-frozen"));
return pd;
}
$ curl -s -w '\n' localhost:8080/api/accounts/2/withdraw -H 'Content-Type: application/json' -d '{"amount":10}'
{"type":"https://bank.example.com/problems/account-frozen","title":"Account frozen","status":409,"detail":"Account 2 is frozen","instance":"/api/accounts/2/withdraw"}
- Add the missing account’s id to the 404 problem as an
accountIdmember, so clients don’t have to parsedetail.
Solution
public class AccountNotFoundException extends RuntimeException {
private final long accountId;
public AccountNotFoundException(long id) {
super("Account " + id + " does not exist");
this.accountId = id;
}
public long accountId() { return accountId; }
}
// in ApiErrors.notFound, after setTitle:
pd.setProperty("accountId", ex.accountId());
$ curl -s -w '\n' localhost:8080/api/accounts/7
{"type":"https://bank.example.com/problems/account-not-found","title":"Account not found","status":404,"detail":"Account 7 does not exist","instance":"/api/accounts/7","accountId":7}
- Why does
/api/accounts/13return"An unexpected error occurred"rather than"ledger connection reset", and what would go wrong if the catch-all usedex.getMessage()?
Solution
The catch-all deliberately returns a fixed message and logs the exception instead. Unknown exceptions
carry messages written for developers — driver errors with SQL, host names, file paths, sometimes
other users’ data — so echoing ex.getMessage() would leak them to any caller. Known domain
exceptions are safe to echo because you wrote their messages for clients.