Skip to main content
Java · lesson 55 of 66
Open the Java page →
Java intermediate Lesson 55 of 66

Exception 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

  1. 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"}
  1. Add the missing account’s id to the 404 problem as an accountId member, so clients don’t have to parse detail.
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}
  1. Why does /api/accounts/13 return "An unexpected error occurred" rather than "ledger connection reset", and what would go wrong if the catch-all used ex.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.

Frequently Asked Questions

What is @RestControllerAdvice?
@RestControllerAdvice marks a class whose @ExceptionHandler methods apply to every controller in the application, with return values written as the response body. It lets you translate exceptions into HTTP responses in one place instead of try/catch blocks in each controller method.
Should custom exceptions extend RuntimeException or Exception?
For application errors such as not found, conflict or a broken business rule, extend RuntimeException. Unchecked exceptions pass through service and controller layers without throws clauses, and the global handler catches them at the edge. Give each one the data a client needs, such as the id or the current balance.
What is ProblemDetail in Spring Boot?
ProblemDetail is Spring's implementation of RFC 9457, which replaced RFC 7807, the standard JSON format for HTTP API errors with type, title, status, detail and instance members. Returning it from an exception handler produces a response with Content-Type application/problem+json, and you can add extra members with setProperty.
Why doesn't Spring Boot's default error response include the exception message?
Spring Boot's default error body leaves out the message and stack trace (server.error.include-message=never) because exception messages often contain internal details such as SQL, file paths or other customers' data. Decide deliberately what to expose by writing exception handlers that build the response yourself.
How do I make Spring's built-in errors such as 405 and 400 use ProblemDetail too?
Extend ResponseEntityExceptionHandler in your @RestControllerAdvice class, or set spring.mvc.problemdetails.enabled=true. Either way, Spring MVC's own exceptions, such as unsupported methods, unreadable JSON and failed validation, are rendered as ProblemDetail, and you can override individual handle methods to customise them.