An application can "fail" for two entirely different reasons: a business rule was violated, or something broke in the infrastructure. Confusing the two means showing the user "500 Internal Server Error" where it should say "Insufficient balance", and vice versa.
Two kinds of errors
A domain error is an expected situation inside the business logic. A user tries to buy a ticket that doesn't exist; an account goes negative; a date is in the past. Such errors are predictable and form part of the API: the client should get a clear response, not a stack trace.
A technical failure is something unforeseen: the database is unavailable, a timeout expired, memory ran out. The application isn't to blame, the user has nothing to do with it — the job is to log it and return a neutral "something went wrong".
Short formula: a domain error = the business says "you can't"; a technical failure = the environment says "I can't".
Domain exceptions: how to declare them
Create a base class for all domain errors and subclass the specific cases:
public abstract class DomainException extends RuntimeException {
protected DomainException(String message) {
super(message);
}
}
public final class InsufficientBalanceException extends DomainException {
public InsufficientBalanceException(BigDecimal required, BigDecimal available) {
super("Insufficient funds: required %s, available %s"
.formatted(required, available));
}
}
public final class OrderNotFoundException extends DomainException {
public OrderNotFoundException(long orderId) {
super("Order #%d not found".formatted(orderId));
}
}
Usage in a handler:
public void pay(long orderId, BigDecimal amount) {
Order order = orders.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
if (order.balance().compareTo(amount) < 0) {
throw new InsufficientBalanceException(amount, order.balance());
}
order.debit(amount);
}
Each exception carries concrete data — exactly what went wrong. This matters: when handling it at the boundary (controller, @RestControllerAdvice) you'll be able to build a meaningful response.
Why not null and not error codes
Returning null is a silent error. The calling code is obliged to remember to check the result; if it forgets — a NullPointerException in some random place. An exception, on the other hand, can't be ignored: it interrupts execution right where it occurs.
Error codes (int status, String errorCode in the returned object) are a pattern from the C era, when exceptions didn't exist. In Java it's dead weight: you have to check the result every time, the "happy path" logic gets tangled with error handling, and the return type is cluttered with service fields.
A typed exception:
- interrupts execution immediately, not several calls later,
- carries a type and data — not a string with a code,
- requires no check after every call.
Unchecked (RuntimeException) is preferable to checked for domain errors: checked exceptions force the entire call chain to declare throws, which leads to boilerplate code and breaks encapsulation of layers.
From exception to client response
A domain exception thrown in a handler "bubbles up" to the controller layer and is caught by the global handler @RestControllerAdvice. There it turns into a structured Problem Details response (RFC 9457):
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(InsufficientBalanceException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public ProblemDetail handleInsufficientBalance(InsufficientBalanceException ex) {
ProblemDetail problem = ProblemDetail
.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
problem.setTitle("Insufficient funds");
return problem;
}
}
For details on the response format, see the article REST API errors and Problem Details. What matters to us here is the principle: a domain exception is not handled inside the business logic — it is propagated and caught at the layer boundary.
How to map errors to HTTP statuses
Domain errors and technical failures map onto different status ranges:
| Kind of error | HTTP status | Example |
|---|---|---|
| Object not found | 404 Not Found | OrderNotFoundException |
| Business rule violation | 422 Unprocessable Entity | InsufficientBalanceException |
| Invalid request | 400 Bad Request | validation errors |
| Technical failure | 500 Internal Server Error | DataAccessException |
The key rule: 4xx — the problem is on the client's side (it sent an invalid request or violated a rule), 5xx — the problem is on the server's side (the infrastructure failed).
In short
- Split errors into domain (a business rule was violated) and technical (the infrastructure is unavailable) — they are handled differently.
- Create a typed exception for each domain error, with concrete data in the constructor.
- Use
uncheckedexceptions (RuntimeException) — they don't clutter signatures and don't require an explicitthrowsin every layer. - Don't return
nullor error codes — an exception interrupts execution immediately and carries a type. - A domain exception is propagated to the layer boundary (
@RestControllerAdvice) and turned into a response there. - 4xx — a client error, 5xx — a server error: don't confuse them.
What to read next
- Global error handling in Spring Boot — how
@RestControllerAdviceworks and how to catch different types of exceptions. - Common pitfalls when working with exceptions — anti-patterns: swallowing, wrapping, abusing checked.
- REST API errors and Problem Details — the RFC 9457 format, the
type/title/detailfields, Spring Boot 3 out of the box.