Skip to main content

Week 4 Day 5 — Exceptions in REST and Spring

Goal

Today I want the HTTP and transaction rules that interviews pair with Java exceptions.

Main questions:

  1. What should a REST service throw?
  2. How does @ControllerAdvice map types to status codes?
  3. What is the @Transactional rollback default?
  4. How do validation failures fit?
  5. What do I still new vs what Spring handles?

1. What a REST service throws

The service throws unchecked domain exceptions. The web layer maps them to HTTP. The service does not build ResponseEntity for errors.

FailureTypical typeHTTP
Missing aggregateOrderNotFoundException404
Bad input that passed JSON parseIllegalArgumentException / validation400
Bean Validation on the DTOMethodArgumentNotValidException400
Duplicate / conflictDuplicateOrderException409
Downstream timeout I cannot hidePaymentFailedException502 / 503
Buguncaught RuntimeException500

I do not throw IOException from OrderService.place. I do not return null to mean 404.

Memory sentence:

Services throw domain types. The web layer chooses the status code.


2. Mapping at the boundary

@RestControllerAdvice
public class RestExceptionHandler {

@ExceptionHandler(OrderNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
ProblemDetail notFound(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}

@ExceptionHandler(DuplicateOrderException.class)
@ResponseStatus(HttpStatus.CONFLICT)
ProblemDetail conflict(DuplicateOrderException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
}

@ControllerAdvice is that boundary from Day 4. One place, one log if needed, stable JSON (ProblemDetail is the Spring Boot 3 default shape, RFC 9457).

@ResponseStatus(HttpStatus.NOT_FOUND) on the exception class itself is fine for tiny apps. Advice is better when the body must include an id or a list of field errors.

Controllers stay thin:

@GetMapping("/{id}")
OrderResponse get(@PathVariable long id) {
return OrderResponse.from(orders.get(id)); // throws OrderNotFoundException
}

No try/catch in the controller unless this one endpoint compensates in a unique way.


3. @Transactional and rollback

Default Spring rule:

A @Transactional method rolls back on unchecked exceptions (RuntimeException and Error). It commits on a checked Exception unless I configure otherwise.

@Transactional
public void place(CreateOrderRequest request) throws InvoiceException {
orders.save(...);
invoices.issue(...); // throws checked InvoiceException
}

If issue throws a checked InvoiceException, the transaction commits by default. The order is saved. That surprise is a classic interview trap.

Fixes:

  • throw unchecked domain exceptions from the service
  • or @Transactional(rollbackFor = Exception.class) when I must keep a checked type

Self-invocation still skips the proxy (Week 3). A this.place() call also skips the rollback interceptor.

Memory sentence:

Default rollback is for runtime exceptions. Checked exceptions commit unless I set rollbackFor.


4. Validation vs domain

Bean Validation (@NotBlank, @Min) on a request record fails before the controller method, as MethodArgumentNotValidException (or ConstraintViolationException on @Validated params). That is not a domain exception. Advice maps it to 400 with field names.

Domain rules that need the database (“sku exists”, “quantity within stock”) throw from the service after a lookup. Do not pretend @Min(1) is the whole business.


5. What Spring already did

  • Servlet container: uncaught exceptions become 500 unless I handle them.
  • DataAccessException: JDBC/JPA translated, unchecked, rollback-friendly.
  • RestClient / WebClient: HTTP errors as exceptions I translate in a client adapter.
  • I still use try-with-resources for files I open in an import job.

I do not close EntityManager in these handlers.


6. Common traps

Trap 1: catch (Exception e) { return ResponseEntity.ok(null); } in a controller.

Trap 2: Assuming a checked exception from a transactional method undoes the insert.

Trap 3: @ResponseStatus on a type and a conflicting @ExceptionHandler I forgot about.

Trap 4: Mapping every RuntimeException to 400. Bugs become client errors. Unknown runtime → 500.

Trap 5: Putting @Transactional on the controller so exceptions “just work.” Transactions belong on the use case (service), with rollback rules I can test.


Practice Questions and Answers

Question 1

What should a REST service throw?

Answer:

Unchecked domain exceptions that name the business failure. @ControllerAdvice maps them to HTTP status and a body. The service does not catch-and-return status codes.


Question 2

Does @Transactional roll back on Exception?

Answer:

Not by default. It rolls back on RuntimeException and Error. A checked Exception commits unless I set rollbackFor. That is why domain failures should be unchecked.


Question 3

Why is @ControllerAdvice better than try/catch in every controller method?

Answer:

One mapping, one JSON shape, one place to log. Controllers stay as the HTTP adapter. New endpoints inherit the policy.


Question 4

How do I handle a missing order vs a missing JSON field?

Answer:

Missing JSON / validation: MethodArgumentNotValidException → 400. Missing order after a lookup: OrderNotFoundException → 404. They are different types on purpose.


Question 5

Why does Spring’s DataAccessException hierarchy exist?

Answer:

To translate vendor-specific checked SQLException into an unchecked, portable tree (DuplicateKeyException, …) so services stay readable, transactions roll back, and I can map persistence failures without importing JDBC into the web layer.


Memory sentences

Services throw domain types. The web layer chooses the status code.

Default rollback is unchecked. Checked commits unless rollbackFor.

Unknown RuntimeException is a 500, not a 400.

Next: Week 4 Review