Skip to content

Java · Exceptions

Java Custom Exceptions — Design, Chaining & Best Practices

8 min read Updated 2026-06-20 Share:

Practice Custom Exceptions interview questions

When a custom exception earns its keep

A custom exception is not about decorating your error messages — it is about giving a failure a name and a type that callers can act on. The moment a caller needs to react differently to one failure than to every other failure, or needs structured data out of it (the offending id, an error code), a dedicated type pays for itself. throw new RuntimeException("order 42 not found") forces every caller to parse a string; throw new OrderNotFoundException(42) lets them write a precise catch and pull the id back out. This guide is about the design decisions around those classes — which parent to extend, how to preserve diagnostics, and the conventions that keep an exception hierarchy maintainable rather than noisy.

Checked or unchecked: pick the parent first

The single most consequential decision is the superclass, because it encodes whether the error is expected or a defect. Extend Exception to make it checked — the compiler forces callers to catch or declare it. Extend RuntimeException to make it unchecked — no compiler obligation at all.

// checked: a recoverable, anticipated condition the caller should plan for
public class ConfigParseException extends Exception { }

// unchecked: a programming error or violated precondition — a bug to fix, not handle
public class InvalidOrderStateException extends RuntimeException { }

The litmus test is "can the caller do something useful about it?" A missing config file or a failed network call is recoverable — lean checked. A null argument or an illegal state is a defect the caller cannot meaningfully recover from — lean unchecked. Choosing the parent first means every later decision (whether to declare throws, how callers handle it) follows naturally.

The four standard constructors

Mirror the four constructors that Exception and RuntimeException expose, so callers can supply a message, a cause, or both. The two cause-bearing forms are the ones people forget — and they are the most important, because they are what makes chaining possible.

public class PaymentException extends RuntimeException {
  public PaymentException() { super(); }
  public PaymentException(String message) { super(message); }
  public PaymentException(String message, Throwable cause) {
    super(message, cause);                 // message + root cause — the workhorse
  }
  public PaymentException(Throwable cause) { super(cause); }
}

If you provide nothing else, provide at minimum (String message) and (String message, Throwable cause). The latter is what lets you wrap a lower-level failure without discarding the line that actually broke.

Chaining: never throw away the cause

Exception chaining is wrapping a low-level exception inside a higher-level one while keeping the original as the cause. It lets you raise a meaningful domain exception without losing the root-cause diagnostics. The cause surfaces in the stack trace under a Caused by: line, so you see both the abstract failure and the SQL error that triggered it.

try {
  return jdbc.query(sql);
} catch (SQLException e) {
  // wrap to your abstraction, but pass e as the cause so the trace survives
  throw new RepositoryException("failed to load user " + id, e);
}

The classic mistake is throw new RepositoryException("failed to load user " + id) with no e. The new exception's trace now starts at the throw site, and the line that truly failed is gone forever. When you rethrow, always carry the cause. If a class predates the cause constructors, initCause(e) does the same job — but it may be called exactly once, so the constructor form is strictly better when available.

Carrying structured data with custom fields

Put any data a handler might need into typed, final fields populated through the constructor and exposed via getters — not buried in the message string. This is half the reason custom exceptions exist.

public class OrderNotFoundException extends RuntimeException {
  private final long orderId;                 // immutable: an exception is a one-shot value

  public OrderNotFoundException(long orderId) {
    super("order not found: " + orderId);
    this.orderId = orderId;
  }
  public long getOrderId() { return orderId; }
}

Now a caller can write catch (OrderNotFoundException e) { retry(e.getOrderId()); } and get the id without scraping the message. Keep the fields final and defensively copy any mutable inputs (List.copyOf(...)) — an exception describes a moment in time and should never be mutated after it is thrown.

Naming and serialVersionUID

Two conventions keep a hierarchy professional. First, name the class for the problem and suffix it with ExceptionInsufficientFundsException, not FundsProblem or CheckBalanceException (name the condition, not the throwing method). It mirrors the JDK's own IllegalArgumentException / IOException and makes exceptions instantly recognizable in stack traces.

public class RemoteCallException extends RuntimeException {
  private static final long serialVersionUID = 1L;   // pin it so old instances deserialize
  private final String endpoint;                      // String is serializable — fine

  public RemoteCallException(String endpoint, Throwable cause) {
    super("call failed: " + endpoint, cause);
    this.endpoint = endpoint;
  }
}

Second, because Throwable already implements Serializable, every exception is serializable by inheritance — and exceptions genuinely cross JVM boundaries (RMI, distributed systems, app servers). Declare an explicit serialVersionUID; without one the compiler-generated id shifts on any edit, breaking deserialization of older instances. Mark any non-serializable custom field transient.

Exception translation at layer boundaries

Exception translation is catching a low-level exception and throwing a higher-level one that fits your API's abstraction — so callers depend on your types, not on SQLException or IOException leaking up from the data layer. It is chaining applied deliberately at a boundary.

public User findById(long id) {
  try {
    return jdbc.queryForObject(SQL, mapper, id);
  } catch (SQLException e) {
    throw new UserRepositoryException("findById " + id, e);  // translate + chain
  }
}

This decouples callers from the implementation: you could swap JDBC for an ORM and the exceptions they catch would not change. Always chain the cause so the low-level detail remains available for debugging. The same discipline separates business exceptions (InsufficientFundsException — expected, mapped to a friendly 4xx) from technical exceptions (ServiceUnavailableException — infrastructure broke, log/alert/5xx), and lets you handle each kind differently.

Stay inside Exception and RuntimeException

Subclass Exception or RuntimeException and nothing higher. Never extend Throwable directly — it produces a type that is neither a clean exception nor an error and confuses catch blocks. Never extend Error — that is reserved for serious JVM-level failures like OutOfMemoryError that applications are not meant to catch.

class DataImportException extends Error { }            // WRONG — implies the JVM is broken

class DataImportException extends RuntimeException { }  // right level of abstraction

Throwing an Error subtype is actively harmful: a catch (Exception e) handler will not catch it, so it slips past code designed to handle failures, while signalling a catastrophe that did not occur.

Modern style: default to unchecked

Much of the modern ecosystem — Spring chief among it — defaults to unchecked domain exceptions. They keep method signatures clean, work inside lambdas and streams (whose functional interfaces cannot declare checked exceptions), and avoid rippling throws clauses up the entire call stack every time a method can fail. Handling moves from scattered try/catch blocks to one centralized place.

// a single global handler translates domain exceptions into HTTP responses
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<?> handle(OrderNotFoundException e) {
  return ResponseEntity.status(404).body(e.getMessage());
}

The trade-off is real: the compiler no longer reminds callers that an error can occur, so teams rely on @throws Javadoc and convention instead of enforcement. Most accept that for the reduced ceremony. Whichever default you pick, resist over-creating: when a standard JDK type already says it exactly — IllegalArgumentException for a bad argument, IllegalStateException for a bad state — reuse it. A custom exception only earns its place when callers will catch it by type or need data from it.

Recap

A custom exception is a typed, named failure callers can target — create one only when that targeting or its data is needed. Decide checked (extends Exception) vs unchecked (extends RuntimeException) first, since it encodes "expected vs defect." Provide the four standard constructors, especially (String, Throwable), and always chain the cause so the stack trace and its Caused by: line survive. Carry context in final fields, follow the ...Exception naming convention, declare a serialVersionUID, and translate low-level exceptions to your abstraction at layer boundaries. Never reach above Exception/RuntimeException into Throwable or Error. Modern code leans unchecked by default with centralized handling and @throws docs — get the design right and your failures stay debuggable instead of mysterious.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel