Mapping Domain Exceptions to Status Codes

Keep services free of HTTP by throwing domain exceptions, and map each one to the right status code in a single advice.

Syntax@ExceptionHandler ProblemDetail handle(DomainException ex) { return ProblemDetail.forStatus(switch (ex) { case NotFoundException _ -> HttpStatus.NOT_FOUND; case ConflictException _ -> HttpStatus.CONFLICT; }); }

Services should describe what went wrong in business terms: the account does not exist, the funds are insufficient, the order already shipped. Which HTTP status that becomes is a web concern. Keeping the two apart means the same service can be called from a controller, a Kafka listener or a scheduled job, and the status codes for the whole API are decided in one place.

A small hierarchy

You do not need an exception per status, but you do need a handful of categories. A sealed base class lists them, and a switch over it in the advice is checked by the compiler: add a new category and the advice fails to compile until someone gives it a status.

Choosing the code

SituationStatus
The thing addressed by the URL does not exist404 Not Found
Duplicate, or the resource is in the wrong state (already shipped, already cancelled)409 Conflict
Concurrent edit detected by @Version409 Conflict, or 412 when the client sent If-Match
Well-formed request that breaks a business rule (withdrawal above the limit)422 Unprocessable Content
Authenticated but not allowed403 Forbidden
A dependency is down or timing out503 or 504

Use 400 for requests that are malformed or fail validation, and 422 for requests that are valid in form but refused by the rules. Whichever line you draw, draw it once and apply it everywhere.

Mistakes that reach production

  • Mapping IllegalArgumentException to 400 globally. Libraries and your own code throw it for programming errors, so real bugs get reported as client mistakes and never alert anyone.
  • Returning 404 for a referenced resource. POST /orders with an unknown product id is not a missing URL; prefer 422 with a field name.
  • Leaking persistence messages. DataIntegrityViolationException messages contain SQL and constraint names. Map it to a generic 409 and log the cause.
  • @ResponseStatus on domain exceptions. It works, but it couples the domain to Spring Web, the body (with or without a reason) comes from Boot's /error instead of your Problem Details format, and a catch-all Exception handler in an advice runs first and turns it into a 500.

Exceptions that carry data the client can use, such as the account id or the remaining balance, should expose it through accessors so the advice can add extension members without parsing the message.

Example

Example · java
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

// The domain layer knows nothing about HTTP.
abstract sealed class DomainException extends RuntimeException
        permits NotFoundException, ConflictException, RuleViolationException {
    DomainException(String message) {
        super(message);
    }
}

final class NotFoundException extends DomainException {
    NotFoundException(String what, Object id) {
        super(what + " " + id + " not found");
    }
}

final class ConflictException extends DomainException {
    ConflictException(String message) {
        super(message);
    }
}

final class RuleViolationException extends DomainException {
    RuleViolationException(String message) {
        super(message);
    }
}

// The web layer owns the mapping. The switch is exhaustive: add a subtype
// and this class stops compiling until someone picks its status.
@RestControllerAdvice
public class DomainExceptionHandler {

    @ExceptionHandler
    ProblemDetail handle(DomainException ex) {
        HttpStatus status = switch (ex) {
            case NotFoundException _ -> HttpStatus.NOT_FOUND;
            case ConflictException _ -> HttpStatus.CONFLICT;
            case RuleViolationException _ -> HttpStatus.UNPROCESSABLE_CONTENT;
        };
        return ProblemDetail.forStatusAndDetail(status, ex.getMessage());
    }
}

When to use it

  • Return 422 when a withdrawal exceeds the balance while the same service drives retries in a queue consumer.
  • Answer a lost optimistic locking race with 409 and a message telling the client to reload.
  • Force every new domain exception category to get a status code at compile time.

More examples

A service that throws without knowing about HTTP

The service states the business outcome and stops. An advice in the web layer then decides that <code>AccountNotFoundException</code> is a 404 and <code>InsufficientFundsException</code> a 422. When the same service runs from a queue listener, those exceptions drive retries or dead-lettering instead, with no web types on the classpath of the domain module.

Example · java
import java.util.Optional;

import org.springframework.stereotype.Service;

class AccountNotFoundException extends RuntimeException {
    AccountNotFoundException(long id) {
        super("Account " + id + " not found");
    }
}

class InsufficientFundsException extends RuntimeException {
    InsufficientFundsException(long id) {
        super("Account " + id + " has insufficient funds");
    }
}

record Account(long id, long balancePence) {
    Account withdraw(long amountPence) {
        return new Account(id, balancePence - amountPence);
    }
}

interface AccountStore {
    Optional<Account> find(long id);
    void save(Account account);
}

// No HttpStatus, no ResponseEntity: the same service works from a
// message listener or a scheduled job, where HTTP codes mean nothing.
@Service
public class TransferService {

    private final AccountStore accounts;

    TransferService(AccountStore accounts) {
        this.accounts = accounts;
    }

    public void withdraw(long accountId, long amountPence) {
        Account account = accounts.find(accountId)
                .orElseThrow(() -> new AccountNotFoundException(accountId));
        if (account.balancePence() < amountPence) {
            throw new InsufficientFundsException(accountId);
        }
        accounts.save(account.withdraw(amountPence));
    }
}

Mapping Spring Data exceptions safely

Spring translates JPA and JDBC failures into its <code>DataAccessException</code> hierarchy, so you can map them without depending on Hibernate or the driver. Both cases are 409 because the client can fix them by reloading or choosing another value. The constraint detail is logged at warn level for whoever investigates, while the response says nothing about the schema. The 409 suits constraints a client can trip, such as a unique email; a NOT NULL or foreign key violation usually means validation is missing, which is why the warn log matters. PostgreSQL puts the duplicate value in that message, so treat it as personal data or log only the constraint name.

Example · java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class PersistenceExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(PersistenceExceptionHandler.class);

    // Someone else saved the row first (@Version mismatch).
    @ExceptionHandler
    ProblemDetail staleUpdate(OptimisticLockingFailureException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
                "The resource was changed by another request. Reload it and try again.");
    }

    // A unique or foreign key constraint fired. The message names tables and
    // constraints, so it goes to the log, never to the client.
    @ExceptionHandler
    ProblemDetail constraint(DataIntegrityViolationException ex) {
        log.warn("Constraint violation: {}", ex.getMostSpecificCause().getMessage());
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
                "The request conflicts with existing data.");
    }
}

The @ResponseStatus shortcut and its cost

Annotating the exception is the least code, and fine in a small service or a prototype. With <code>reason</code> set, Spring sends the error through the servlet container, so the client gets Boot's default error body rather than Problem Details, and the constructor message is never shown. Without <code>reason</code>, the status still applies but the body is still Boot's. Prefer the advice mapping once you have a defined error format.

Example · java
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

// Quick, but it ties a domain class to HTTP. With a reason set, Spring calls
// sendError, the body comes from /error, and the reason replaces any message.
@ResponseStatus(code = HttpStatus.NOT_FOUND, reason = "Invoice not found")
public class InvoiceNotFoundException extends RuntimeException {

    public InvoiceNotFoundException(String invoiceNumber) {
        super("Invoice " + invoiceNumber + " not found");
    }
}

Discussion

  • Be the first to comment on this lesson.