Lesson 8 of 31
Errors in Domain Design
A design that lists its entities and methods but not its failures is incomplete. What happens when a customer withdraws more than the balance, when a parking lot is full, when a coupon expired yesterday? Java offers two channels for a failure: a returned value (a sealed type or Optional) and a thrown exception. Error design is API design, because the channel and the type decide what a caller must handle. This article covers how to shape domain errors, when to return and when to throw, how to chain layers, how fine-grained an error type should be, and when a failure is a bug.
Failures are part of the model
Separate three kinds of failure, because each is handled differently.
| Kind | Example | Right tool |
|---|---|---|
| Business rule rejection | insufficient funds, seat already taken, coupon expired | A sealed error type returned in a result, or a domain exception when the failure usually propagates |
| Environment failure | disk full, network down, file missing | An exception that wraps the original (UncheckedIOException, a storage exception with a cause) |
| Programmer error (a bug) | index out of range, "impossible" state reached | IllegalStateException, IllegalArgumentException, AssertionError, NullPointerException |
A method that "returns false on failure" or prints a message loses the reason and hides the failure from the caller. See the interview framework for where error cases belong in the flow.
A sealed error type and a Result
A sealed interface lists every type that may implement it. A switch over a sealed type must cover every case, so adding a failure reason breaks the code that forgot to handle it, at compile time. Each failure is a record, a compact immutable class that carries the data a caller needs to react, such as the amount available.
The Result type below is a sealed interface with two records: Ok holds a value and Err holds an error. The JDK has no such type, so the lesson defines the smallest version.
import java.util.Map;
import java.util.TreeMap;
sealed interface Result<T, E> {
record Ok<T, E>(T value) implements Result<T, E> {}
record Err<T, E>(E error) implements Result<T, E> {}
}
sealed interface BankError {
record AccountNotFound(int id) implements BankError {}
record InsufficientFunds(long available, long requested) implements BankError {}
record InvalidAmount() implements BankError {}
}
final class Bank {
private final Map<Integer, Long> balances = new TreeMap<>();
Bank(Map<Integer, Long> initial) {
balances.putAll(initial);
}
Result<Long, BankError> withdraw(int id, long amount) {
if (amount <= 0) {
return new Result.Err<>(new BankError.InvalidAmount());
}
Long balance = balances.get(id);
if (balance == null) {
return new Result.Err<>(new BankError.AccountNotFound(id));
}
if (balance < amount) {
return new Result.Err<>(new BankError.InsufficientFunds(balance, amount));
}
balances.put(id, balance - amount);
return new Result.Ok<>(balance - amount);
}
}
public class Main {
static String reaction(BankError error) {
return switch (error) {
case BankError.AccountNotFound(var id) -> "redirect to sign-up for account " + id;
case BankError.InsufficientFunds(var available, var requested) ->
"show the balance " + available + " (asked for " + requested + ")";
case BankError.InvalidAmount() -> "ask for a positive amount";
};
}
static void show(Result<Long, BankError> result) {
System.out.println(result);
if (result instanceof Result.Err<Long, BankError> err) {
System.out.println(" -> " + reaction(err.error()));
}
}
public static void main(String[] args) {
var bank = new Bank(Map.of(1, 500L));
show(bank.withdraw(1, 200));
show(bank.withdraw(1, 900));
show(bank.withdraw(2, 10));
show(bank.withdraw(1, 0));
}
}Ok[value=300]
Err[error=InsufficientFunds[available=300, requested=900]]
-> show the balance 300 (asked for 900)
Err[error=AccountNotFound[id=2]]
-> redirect to sign-up for account 2
Err[error=InvalidAmount[]]
-> ask for a positive amountThe caller matches on the record type to choose a response. A String error could not support that without parsing text. The switch has no default branch, so a new BankError record makes reaction fail to compile until it handles the new case. Records also give equals and toString for free, which lets tests compare errors directly (see testability and dependency injection).
The balances.get(id) call returns null for a missing key. That null stays inside the private method and is turned into AccountNotFound before it leaves the class.
Returning a value or throwing
Both channels are valid. The choice follows from what the caller does with the failure.
| Question | Return a Result |
Throw an unchecked exception |
|---|---|---|
| Is the failure an expected outcome of the operation? | Yes (insufficient funds, seat taken) | No (storage down, corrupt data) |
| Does the immediate caller decide what to do? | Yes | Usually a layer far above |
| Does the compiler force the caller to look? | Yes, through the sealed switch |
No |
| Cost per failure | A small object | An object plus a stack trace |
| Fits a method chain such as streams or lambdas | Yes | Yes, but a lambda cannot declare a checked exception |
Checked exceptions (subclasses of Exception that are not RuntimeException) force every method on the path to declare or catch them. In a layered design this leaks storage details into every signature, so domain code uses unchecked exceptions (RuntimeException subclasses) for failures that cross layers and sealed results for rejections the caller handles on the spot.
Exception classes with messages
A useful exception prints a human message and carries the data a handler needs. A sealed base class lists the business failures, and each subclass is final. The message goes through getMessage(), which is meant for a person or a log line. toString() on the exception adds the class name, which suits a developer.
sealed class BankException extends RuntimeException
permits AccountNotFoundException, InsufficientFundsException {
BankException(String message) {
super(message);
}
}
final class AccountNotFoundException extends BankException {
private final int id;
AccountNotFoundException(int id) {
super("account " + id + " does not exist");
this.id = id;
}
int id() {
return id;
}
}
final class InsufficientFundsException extends BankException {
private final long available;
private final long requested;
InsufficientFundsException(long available, long requested) {
super("cannot withdraw " + requested + " cents, only " + available + " available");
this.available = available;
this.requested = requested;
}
long shortfall() {
return requested - available;
}
}
public class Main {
static void risky(boolean fail) {
if (fail) {
throw new InsufficientFundsException(100, 250);
}
}
public static void main(String[] args) {
BankException e = new AccountNotFoundException(7);
System.out.println("message: " + e.getMessage());
System.out.println("string: " + e);
try {
risky(true);
} catch (InsufficientFundsException ex) {
System.out.println("caught: " + ex.getMessage());
System.out.println("short by " + ex.shortfall());
}
}
}message: account 7 does not exist
string: AccountNotFoundException: account 7 does not exist
caught: cannot withdraw 250 cents, only 100 available
short by 150Message text lives in the exception constructor only, so rewording messages never touches the code that catches the subclasses. A handler catches the narrowest type it can act on. Exceptions cannot be generic and cannot be records (a record cannot extend a class), so exceptions carry their data in fields.
The cost of creating an exception is mostly the stack trace. For a rejection that is expected and frequent, return a Result instead. A subclass can also call the protected RuntimeException constructor that takes writableStackTrace as its last argument to skip the trace.
Layers and wrapping with a cause
Real designs have layers: a repository talks to storage, a service applies rules, a controller talks to the user. Each layer has its own failure types, and a higher layer wraps the lower one. Unchecked exceptions propagate through the layers with no code at all. The only code needed sits at a boundary: catch the lower exception and throw one of the upper layer with the original as the cause. The cause keeps the full chain for logs.
import java.util.Map;
final class StorageException extends RuntimeException {
StorageException(String message) {
super(message);
}
}
final class ServiceUnavailableException extends RuntimeException {
ServiceUnavailableException(String message, Throwable cause) {
super(message, cause);
}
}
final class NotEnoughMoneyException extends RuntimeException {
NotEnoughMoneyException(long available, long requested) {
super("need " + requested + ", have " + available);
}
}
final class BalanceStore {
long load(int id) {
return switch (id) {
case 1 -> 500;
case 2 -> throw new StorageException("corrupt data (checksum mismatch)");
default -> throw new StorageException("storage unavailable");
};
}
}
final class WithdrawService {
private final BalanceStore store;
WithdrawService(BalanceStore store) {
this.store = store;
}
long checkWithdraw(int id, long amount) {
long balance;
try {
balance = store.load(id);
} catch (StorageException e) {
throw new ServiceUnavailableException("storage failure: " + e.getMessage(), e);
}
if (balance < amount) {
throw new NotEnoughMoneyException(balance, amount);
}
return balance - amount;
}
}
public class Main {
public static void main(String[] args) {
var service = new WithdrawService(new BalanceStore());
record Attempt(int id, long amount) {}
for (var a : new Attempt[] {new Attempt(1, 200), new Attempt(1, 900), new Attempt(2, 1), new Attempt(3, 1)}) {
try {
System.out.println("ok, " + service.checkWithdraw(a.id(), a.amount()) + " left");
} catch (RuntimeException e) {
System.out.println("error: " + e.getMessage());
if (e.getCause() != null) {
System.out.println(" cause: " + e.getCause().getMessage());
}
}
}
}
}ok, 300 left
error: need 900, have 500
error: storage failure: corrupt data (checksum mismatch)
cause: corrupt data (checksum mismatch)
error: storage failure: storage unavailable
cause: storage unavailableServiceUnavailableException keeps the original StorageException as its cause, so nothing is lost, and the business failure (NotEnoughMoneyException) stays separate from infrastructure failures. The wrapping is explicit: one catch at the layer boundary, which is where the design decides what the upper layer is allowed to know.
Do not translate a lower failure into a business exception that lies. Mapping a corrupt file to AccountNotFoundException gives a caller wrong information and hides a real fault.
Error granularity
How many error types, and per what scope? Two extremes are both wrong.
- One global
AppErrorfor the whole program. Every method can fail with every reason, so a caller cannot tell which ones are possible.switchbranches handle cases that cannot happen. - One error type per method. Precise, but verbose, and the number of types multiplies.
A workable middle: one sealed type per operation or aggregate (ReserveError, CancelError), with variants describing what the caller can do differently. Two failures that call for the same reaction can share a variant. Two that call for different reactions cannot.
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
sealed interface Result<T, E> {
record Ok<T, E>(T value) implements Result<T, E> {}
record Err<T, E>(E error) implements Result<T, E> {}
}
sealed interface ReserveError {
record UnknownSeat() implements ReserveError {}
record AlreadyTaken(String by) implements ReserveError {}
}
sealed interface CancelError {
record UnknownSeat() implements CancelError {}
record NotYours() implements CancelError {}
}
final class Theater {
private final Set<Integer> seats;
private final Map<Integer, String> owners = new TreeMap<>();
Theater(Set<Integer> seats) {
this.seats = Set.copyOf(seats);
}
Result<Integer, ReserveError> reserve(int seat, String who) {
if (!seats.contains(seat)) {
return new Result.Err<>(new ReserveError.UnknownSeat());
}
String owner = owners.get(seat);
if (owner != null) {
return new Result.Err<>(new ReserveError.AlreadyTaken(owner));
}
owners.put(seat, who);
return new Result.Ok<>(seat);
}
Result<Integer, CancelError> cancel(int seat, String who) {
if (!seats.contains(seat)) {
return new Result.Err<>(new CancelError.UnknownSeat());
}
if (!who.equals(owners.get(seat))) {
return new Result.Err<>(new CancelError.NotYours());
}
owners.remove(seat);
return new Result.Ok<>(seat);
}
}
public class Main {
public static void main(String[] args) {
var theater = new Theater(Set.of(1, 2));
System.out.println(theater.reserve(1, "ana"));
System.out.println(theater.reserve(1, "ben"));
System.out.println(theater.reserve(9, "ben"));
System.out.println(theater.cancel(1, "ben"));
System.out.println(theater.cancel(1, "ana"));
}
}Ok[value=1]
Err[error=AlreadyTaken[by=ana]]
Err[error=UnknownSeat[]]
Err[error=NotYours[]]
Ok[value=1]reserve cannot fail with NotYours, and cancel cannot fail with AlreadyTaken, so the types say so. When two operations of one aggregate share most failures, one shared type is fine. The test is whether a caller's switch has branches that can never run.
Choosing the return type
| Situation | Return type |
|---|---|
| Domain code with actionable, expected rejections | Result<T, DomainError> with a sealed error type |
| Absence is normal, not a failure (lookup by key) | Optional<T> |
| A failure that most callers cannot handle locally | Unchecked domain exception |
| Boundary parsing (text to domain value) | Result<T, ParseError> with a small sealed type |
| The caller passed an argument that violates the documented precondition | IllegalArgumentException |
| The object is in a state that does not allow the call | IllegalStateException |
| The caller cannot recover and it is a bug | An unchecked exception or AssertionError, and a fix |
Optional versus Result is a design decision: Optional<User> find(int id) says absence is expected; a Result<User, UserError> or an exception says the caller believed the user exists and needs to know why not. Optional is for return values only, never for fields or parameters.
When a failure is a bug
An unchecked exception for a precondition is not a business error to catch and continue. It signals that the calling code is wrong. Use it in these cases.
- A violated precondition on an argument that the caller controls, such as a constructor receiving a capacity of zero. Throw
IllegalArgumentExceptionand let the bug surface. - A violated internal invariant that no caller input can cause. If the class's own code breaks a promise, there is no meaningful recovery.
- Truly impossible cases, documented with a message.
Bad user input, a missing record or a full parking lot is never a bug. The rule: if a well-behaved caller can trigger it, return a Result or throw a domain exception that the design lists. Text typed by a user is parsed at the boundary into a Result; once it is a valid domain value, the inner code throws IllegalArgumentException for anything else.
import java.util.Arrays;
final class Ring {
private final int[] slots;
private int head; // invariant: 0 <= head < slots.length, and slots is never empty
Ring(int capacity) {
if (capacity <= 0) {
throw new IllegalArgumentException("capacity must be positive: " + capacity);
}
this.slots = new int[capacity];
}
void push(int value) {
slots[head] = value; // an index error here would be a bug in this class
head = (head + 1) % slots.length;
}
@Override
public String toString() {
return "Ring[slots=" + Arrays.toString(slots) + ", head=" + head + "]";
}
}
public class Main {
public static void main(String[] args) {
try {
new Ring(0);
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
var ring = new Ring(2);
for (int v = 1; v <= 3; v++) {
ring.push(v);
}
System.out.println(ring);
}
}rejected: capacity must be positive: 0
Ring[slots=[3, 2], head=1]A bad capacity is a caller mistake, so the constructor throws. The index inside push can never fail if the constructor did its job, so a returned error there would only add noise. This is also why encapsulation helps error design: a well-guarded invariant turns many potential run-time errors into impossibilities.
A constructor cannot return a Result, so it throws. When the capacity comes from outside input, a static factory method that returns a Result is the alternative.
Errors in the whole design
A short checklist when drawing the class diagram (see class diagrams and relationships):
- Every public method that can be rejected returns a
Resultwith a sealed error type, or documents the domain exception it throws. The error type appears in the diagram next to the entity. - Each variant names a business reason and carries the data needed to act on it.
- Money and quantities are integers (cents) or
BigDecimal, so an error message can show exact numbers. - A failed operation leaves the state unchanged, so a caller can retry.
- Domain code does not print or log the error. It returns or throws it, and the outermost layer decides what to show.
- Exceptions that cross a layer keep the original as the cause.
For complete uses of this approach, see design ATM and design parking lot.
Interview points
- Explain the difference between a business rejection, an environment failure, and a bug, and name the channel for each.
- A sealed error type with a
switchwithoutdefaultmakes the compiler list every handler to update when a reason is added. - Checked exceptions leak into every signature on the call path. Unchecked domain exceptions with a cause suit failures that cross layers.
- A stack trace is captured when an exception is created, so frequent expected rejections fit a returned value better.
Optionalis a return type for absence. It is not a field or a parameter type.
Summary
- Model failures as part of the API: a sealed error type per operation or aggregate, returned in a
Resultor thrown as a domain exception. - Variants describe reasons a caller can act on and carry the data needed to do so.
- Put message text in the exception constructor or in one formatting method, away from the types that code matches on.
- Wrap a lower-layer exception at the boundary and pass the original as the cause.
- Do not use one giant error type, and do not map an infrastructure fault onto a misleading business failure.
- Use
Optionalwhen absence is normal and aResultor an exception when the caller needs to know why an operation failed. - Throw
IllegalArgumentExceptionorIllegalStateExceptionfor bugs and broken preconditions, never for input a well-behaved caller can send. - A failed operation should leave state unchanged.
