Skip to content
Spring Boot sb exceptions 4 min read

Custom Exceptions

Custom exceptions give your domain a vocabulary for failure: ResourceNotFoundException, DuplicateEmailException, InsufficientStockException. They make controller and service code read like the business rules they enforce, and they give your global handler precise types to map onto HTTP responses. This page covers designing them, the runtime vs checked choice, carrying context, and two shortcuts Spring provides.

A domain exception

A custom exception is just a class extending RuntimeException. Give it constructors that capture the facts of the failure, not the HTTP details.

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String resource, Object id) {
        super("%s with id %s was not found".formatted(resource, id));
    }
}

Throw it from a service or controller wherever the rule is violated:

public Product findById(Long id) {
    return repository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Product", id));
}

A @RestControllerAdvice then maps the type to a status and body once, for the whole app.

Runtime vs checked

For Spring web exceptions, prefer unchecked (RuntimeException) types.

AspectRuntimeException (unchecked)Exception (checked)
throws clauseNot requiredRequired on every signature
@Transactional rollbackRolls back by defaultDoes not roll back by default
Fit for web layerIdeal — clean signaturesNoisy, forces try/catch

Warning: Spring’s @Transactional only rolls back on unchecked exceptions by default. A checked exception commits the transaction unless you add @Transactional(rollbackFor = MyCheckedException.class). This alone is a strong reason to use RuntimeException for domain errors.

Carrying context

Generic messages are hard to act on. Add fields so handlers (and logs) have structured data — resource name, identifier, error code — without parsing strings.

@Getter
public class ResourceNotFoundException extends RuntimeException {

    private final String resourceType;
    private final Object resourceId;

    public ResourceNotFoundException(String resourceType, Object resourceId) {
        super("%s with id %s was not found".formatted(resourceType, resourceId));
        this.resourceType = resourceType;
        this.resourceId = resourceId;
    }
}

The handler can now build a precise body:

@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiError handle(ResourceNotFoundException ex, HttpServletRequest req) {
    var body = ApiError.of(404, "RESOURCE_NOT_FOUND", ex.getMessage(), req.getRequestURI());
    body.addDetail("resourceType", ex.getResourceType());
    body.addDetail("resourceId", ex.getResourceId());
    return body;
}

Tip: Add a stable, machine-readable code (like RESOURCE_NOT_FOUND) to each exception family. Clients can branch on the code without parsing human-readable messages, which you are free to reword.

Shortcut 1 — @ResponseStatus on the exception

If an exception always maps to the same status and you do not need a custom body or advice method, annotate the exception class with @ResponseStatus. Spring sets the status automatically when it propagates uncaught.

@ResponseStatus(HttpStatus.NOT_FOUND)
public class ProductNotFoundException extends RuntimeException {
    public ProductNotFoundException(Long id) {
        super("Product " + id + " not found");
    }
}
return repository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));

Output:

{
  "timestamp": "2026-06-13T10:15:42.123+00:00",
  "status": 404,
  "error": "Not Found",
  "path": "/api/products/999"
}

(The body above is the DefaultErrorAttributes default — add a @RestControllerAdvice to replace it with your DTO.)

Note: @ResponseStatus is ignored if the exception is caught by a matching @ExceptionHandler that returns a ResponseEntity. Use one approach or the other per exception.

Shortcut 2 — ResponseStatusException

For one-off cases where defining a class is overkill, throw ResponseStatusException directly. It carries the status (and an optional reason) inline.

@GetMapping("/{id}")
public Product one(@PathVariable Long id) {
    return service.findById(id)
            .orElseThrow(() -> new ResponseStatusException(
                    HttpStatus.NOT_FOUND, "Product " + id + " not found"));
}

In Spring Framework 6 this produces an RFC 7807 ProblemDetail body:

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Product 999 not found",
  "instance": "/api/products/999"
}
ApproachBest when
Custom exception classReused across the codebase, carries context, mapped centrally
@ResponseStatus on the classFixed status, no custom body needed
ResponseStatusExceptionOne-off, local, no reuse — avoid scattering these

Warning: Overusing ResponseStatusException scatters HTTP concerns through your service layer. Prefer named domain exceptions plus a central advice; reserve ResponseStatusException for rare inline cases.

Designing a small hierarchy

A shared base class lets a single handler cover a whole family while still allowing specific handlers when needed.

public abstract class ApiException extends RuntimeException {
    protected ApiException(String message) { super(message); }
    public abstract String code();
    public abstract HttpStatus status();
}

public class ResourceNotFoundException extends ApiException {
    public ResourceNotFoundException(String r, Object id) {
        super("%s %s not found".formatted(r, id));
    }
    public String code() { return "RESOURCE_NOT_FOUND"; }
    public HttpStatus status() { return HttpStatus.NOT_FOUND; }
}
@ExceptionHandler(ApiException.class)
public ResponseEntity<ApiError> handle(ApiException ex, HttpServletRequest req) {
    return ResponseEntity.status(ex.status())
            .body(ApiError.of(ex.status().value(), ex.code(), ex.getMessage(),
                              req.getRequestURI()));
}
Last updated June 13, 2026
Was this helpful?