The full picture
public class DomainException extends RuntimeException {
private final ErrorCode code; // enum: stable, documented
private final Map<String, Object> context;
private final boolean retryable;
protected DomainException(ErrorCode code, String msg, Throwable cause,
boolean retryable, Map<String, Object> ctx) {
super(msg, cause);
this.code = code; this.retryable = retryable;
this.context = Map.copyOf(ctx);
}
}
public final class InsufficientStockException extends DomainException {
public InsufficientStockException(SkuId sku, int requested, int available) {
super(ErrorCode.STOCK_INSUFFICIENT,
"Requested %d of %s, only %d available".formatted(requested, sku, available),
null, false,
Map.of("sku", sku.value(), "requested", requested, "available", available));
}
}- Shallow beats deep: 2 levels is usually enough; deep trees invite
catchtargeting fragile intermediate types. - Codes vs types: types drive program behavior (catch/retry/map-to-status); codes drive human and client behavior (docs, support, i18n). You want both, generated into API docs.
- Context discipline: IDs yes, PII/secrets never — exceptions end up in logs, APMs, and sometimes client responses.
- Don't: one giant
AppExceptionwith an int code for everything (stringly-typed control flow), or exceptions for expected branches (parse failures in a validator loop — return results).