Exception design for services
A hierarchy with a business base, a technical base, and a translation layer at the edge — so a stack trace never reaches a client.
A service has three audiences for a failure: the code that called the method, the client on the other end of the HTTP connection, and the engineer reading the log at 2am. Each needs something different, and an exception design is the plan for giving each of them exactly that — without a stack trace ever reaching a client or a 500 ever hiding a validation error.
Two families
Every exception a service throws is one of two kinds:
- Business — the request was understood and refused: not found, already exists, insufficient funds, not allowed in this state. The caller did something the domain does not permit. These map to 4xx.
- Technical — the system failed: the database is down, a downstream timed out, a file is corrupt. The caller did nothing wrong. These map to 5xx, and the details are for the log, not the client.
Model that distinction in the type hierarchy:
public abstract class ServiceException extends RuntimeException {
protected ServiceException(String message, Throwable cause) { super(message, cause); }
}
public abstract class BusinessException extends ServiceException {
private final String code; // stable, machine-readable
protected BusinessException(String code, String message) { super(message, null); this.code = code; }
public String code() { return code; }
}
public final class NotFoundException extends BusinessException {
public NotFoundException(String entity, Object id) {
super("not_found", entity + " " + id + " was not found");
}
}
public final class ConflictException extends BusinessException { ... } // "already_exists", "version_conflict"
public final class InvalidStateException extends BusinessException { ... } // "order_already_shipped"
public final class InfrastructureException extends ServiceException {
public InfrastructureException(String message, Throwable cause) { super(message, cause); }
}Unchecked throughout — the previous lesson's rule — and a small number of types. Resist one class per error; the code field carries the specific case, and codes are what clients switch on.
Messages
An exception message should let the log reader reconstruct what happened without a debugger: the operation, the identifiers, the state. "Order 4231 cannot be cancelled: status is SHIPPED" — not "Invalid state". Include values, never secrets: no passwords, tokens, card numbers, personal data. The message will be logged, and logs are read by more people than the code.
Translation at the edge
Business logic throws domain exceptions and knows nothing about HTTP. One handler at the boundary maps them:
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(NotFoundException.class)
ProblemDetail notFound(NotFoundException e) {
return problem(HttpStatus.NOT_FOUND, e);
}
@ExceptionHandler(ConflictException.class)
ProblemDetail conflict(ConflictException e) {
return problem(HttpStatus.CONFLICT, e);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail validation(MethodArgumentNotValidException e) { ... } // 400 with field errors
@ExceptionHandler(Exception.class)
ProblemDetail unexpected(Exception e, HttpServletRequest req) {
String id = UUID.randomUUID().toString();
log.error("unhandled [{}] {} {}", id, req.getMethod(), req.getRequestURI(), e);
ProblemDetail p = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
p.setDetail("Something went wrong. Reference: " + id);
return p; // no message, no trace
}
private ProblemDetail problem(HttpStatus status, BusinessException e) {
ProblemDetail p = ProblemDetail.forStatusAndDetail(status, e.getMessage());
p.setProperty("code", e.code());
return p;
}
}ProblemDetail is Spring's implementation of RFC 9457 — a standard JSON shape (type, title, status, detail, instance) that clients can rely on. The unexpected-exception handler is the important one: it logs everything with a correlation id, and returns only that id. A client sees "Reference: 3f1c..."; support searches the log for it.
What never leaves the server
- Stack traces.
server.error.include-stacktrace=neverin Boot, and the handler above. - Exception class names.
com.shop.billing.LedgerRepositoryin a response is free reconnaissance. - SQL, table names, constraint names.
duplicate key value violates unique constraint "users_email_key"tells an attacker your schema. TranslateDataIntegrityViolationExceptioninto a business conflict with your own message. - Internal hostnames and downstream error bodies.
Logging
Log an exception once, at the boundary that handles it. The anti-pattern is log-and-rethrow at every layer, which produces four stack traces for one failure and buries the useful line. Pass the exception object as the last logger argument so the trace is included; log.error("failed: " + e.getMessage()) drops it.
Business exceptions are usually not errors from the service's point of view — a 404 is a normal outcome — log them at INFO or DEBUG, or not at all, and count them as metrics instead. Technical exceptions are ERROR with the trace.
Wrapping infrastructure failures
try {
return paymentClient.charge(amount, token);
} catch (HttpTimeoutException | ConnectException e) {
throw new InfrastructureException("payment gateway unreachable for order " + orderId, e);
}Keep the cause. Add the context the low-level exception lacks (which order, which operation). Do not catch broadly here — a NullPointerException in your own code is a bug, not an infrastructure failure, and wrapping it mislabels it.