← Back to the section

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.

Below is one and the same payment for order #4021 — charge 1500 ₽ against a balance of 300 ₽ — and two ways to report it.

POST /orders/4021/pay — charge 1500 ₽, balance 300 ₽ null or an error code typed exception return null; // no fundsthe caller does not checkNullPointerExceptiontwo layers up throw newInsufficientBalanceException(1500, 300) — data insidenot caught in the logic catch (Exception e)500 Internal Server Error @RestControllerAdvice422 Unprocessable Entity “something went wrong”nothing to fix: cause is in the logsthe on-call engineer is pagedrequired 1500, available 300top up 1200 and retrythe user fixes itnot the failure but the type: 500 pages the on-call, 422 asks the user

A business rule violation handed back as null reaches the client as a 500 without a single number. The same rule thrown as a typed exception reaches the client as a 422 with the amounts — and the user fixes the payment on their own.

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 errorHTTP statusExample
Object not found404 Not FoundOrderNotFoundException
Business rule violation422 Unprocessable EntityInsufficientBalanceException
Invalid request400 Bad Requestvalidation errors
Technical failure500 Internal Server ErrorDataAccessException

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 unchecked exceptions (RuntimeException) — they don't clutter signatures and don't require an explicit throws in every layer.
  • Don't return null or 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.