Java Tutorial
🔍

Java Custom Exceptions

Java Custom Exceptions

A custom exception is a class you write that extends Exception or RuntimeException to represent a failure specific to your application's domain. InsufficientStockException, InvalidOrderStateException, PaymentDeclinedException — none of these exist in the JDK, because the JDK has no idea what an "order" or a "payment" is in your system. Custom exceptions are how Java's generic exception mechanism becomes specific to the problem you are actually solving. Used well, they make error handling self-documenting. Used poorly, they become an unmanageable pile of near-identical classes that add ceremony without adding meaning.

What Is a Custom Exception?

A custom exception is any class that extends Throwable — in practice, almost always Exception (for checked) or RuntimeException (for unchecked), since extending Throwable or Error directly is reserved for JVM-level conditions. The class inherits all of Throwable's behaviour — message, cause, stack trace — and adds whatever domain-specific fields and methods make sense for the failure it represents.

MINIMAL CUSTOM EXCEPTION:
  public class InsufficientStockException extends RuntimeException {
      public InsufficientStockException(String message) {
          super(message);
      }
  }

WHAT YOU GET FOR FREE BY EXTENDING Exception OR RuntimeException:
  getMessage()        — the message passed to the constructor
  getCause()          — the wrapped exception, if any
  getStackTrace()     — array of stack frame elements
  printStackTrace()   — prints type, message, and full trace
  addSuppressed()     — for try-with-resources suppression

WHAT YOU TYPICALLY ADD:
  Domain-specific fields: the order ID, the requested quantity, the limit
  A constructor that takes those fields and builds a clear message from them
  Getters for those fields, so catching code can react programmatically
  (not just read a string message)

DECISION 1 — Exception or RuntimeException?
  extends Exception        → CHECKED — callers must catch or declare
  extends RuntimeException → UNCHECKED — no compiler enforcement
  (covered in depth in the Checked vs Unchecked Exceptions article —
   this article assumes that decision is already made and focuses on
   HOW to design the exception class itself)

DECISION 2 — Flat hierarchy or exception family?
  FLAT:    each custom exception extends Exception/RuntimeException directly
  FAMILY:  a common abstract base (e.g., OrderException) that all
           domain-specific exceptions extend — enables catching the
           whole family with one catch block

Basic Overview — The Anatomy of a Well-Designed Custom Exception

public class InsufficientStockException extends RuntimeException {

    private final String productId;     <- domain field 1: WHAT failed
    private final int    requestedQty;  <- domain field 2: the input that caused it
    private final int    availableQty;  <- domain field 3: the actual constraint

    public InsufficientStockException(
            String productId, int requestedQty, int availableQty) {
        super(String.format(                 <- message built FROM the fields
            "Insufficient stock for %s: requested %d, available %d",
            productId, requestedQty, availableQty));
        this.productId    = productId;
        this.requestedQty = requestedQty;
        this.availableQty = availableQty;
    }

    public String getProductId()    { return productId;    }  <- getters let
    public int    getRequestedQty() { return requestedQty; }  <- catching code
    public int    getAvailableQty() { return availableQty; }  <- react programmatically

}

WHY EACH PART MATTERS:
  Fields:       catching code can do "if (e.getAvailableQty() == 0)" —
                a string message cannot be safely parsed for this
  Constructor:  builds the message ONCE, consistently, from the fields —
                every throw site produces an identically-formatted message
  Getters:      expose the fields without exposing mutability —
                no setters; exceptions should be immutable after construction

Extending Exception vs RuntimeException

The first decision when writing a custom exception is which class to extend. This determines whether the compiler enforces handling — and it should be a deliberate choice based on whether callers have a genuine recovery path, not a default.

// File: CheckedVsUncheckedCustomDemo.java public class CheckedVsUncheckedCustomDemo { // CHECKED custom exception — extends Exception // Use when: callers have a REAL recovery strategy and should be // forced to acknowledge this failure mode static class SeatNotAvailableException extends Exception { private final String seatNumber; SeatNotAvailableException(String seatNumber) { super("Seat not available: " + seatNumber); this.seatNumber = seatNumber; } String getSeatNumber() { return seatNumber; } } // UNCHECKED custom exception — extends RuntimeException // Use when: the failure is a programming error or system-level problem // that should propagate to a single boundary handler static class BookingSystemException extends RuntimeException { BookingSystemException(String message, Throwable cause) { super(message, cause); } } // Method declares throws for the CHECKED exception only static String bookSeat(String seatNumber, boolean seatTaken, boolean systemDown) throws SeatNotAvailableException { if (systemDown) { // Unchecked — no throws needed, propagates to global handler throw new BookingSystemException( "Booking system unavailable", new RuntimeException("DB connection refused")); } if (seatTaken) { // Checked — REQUIRES throws on this method throw new SeatNotAvailableException(seatNumber); } return "Booked: " + seatNumber; } public static void main(String[] args) { System.out.println("=== Checked custom exception — caller has a recovery path ==="); try { System.out.println(bookSeat("A12", false, false)); System.out.println(bookSeat("B07", true, false)); // seat taken } catch (SeatNotAvailableException snae) { // Recovery: suggest an alternative seat System.out.println("Seat " + snae.getSeatNumber() + " unavailable — suggesting alternative seats nearby"); } System.out.println(); System.out.println("=== Unchecked custom exception — system failure, no recovery ==="); try { bookSeat("C03", false, true); // system down } catch (BookingSystemException bse) { System.out.println("System error: " + bse.getMessage()); System.out.println("Root cause: " + bse.getCause().getMessage()); } catch (SeatNotAvailableException snae) { System.out.println("Seat unavailable: " + snae.getSeatNumber()); } } }
Output:
=== Checked custom exception — caller has a recovery path ===
Booked: A12
Seat B07 unavailable — suggesting alternative seats nearby

=== Unchecked custom exception — system failure, no recovery ===
System error: Booking system unavailable
Root cause: DB connection refused

Constructors — Following the Throwable Convention

Throwable provides four standard constructors. A well-designed custom exception typically implements all four — even if your code only ever uses one or two — because libraries, frameworks, and other developers calling your exception class expect this convention to be available.

// File: ExceptionConstructorsDemo.java public class ExceptionConstructorsDemo { // Implementing all four standard Throwable-style constructors static class ServiceException extends RuntimeException { // 1. No-argument constructor — rarely used directly, but completes the contract public ServiceException() { super(); } // 2. Message only — the most commonly used form public ServiceException(String message) { super(message); } // 3. Message and cause — used when wrapping another exception public ServiceException(String message, Throwable cause) { super(message, cause); } // 4. Cause only — message is derived from the cause's toString() public ServiceException(Throwable cause) { super(cause); } } // Demonstrating when each constructor form is the right choice static void demonstrateConstructorUsage() { // Form 2 — message only: a NEW failure detected here, no underlying cause try { throw new ServiceException("Order total cannot exceed Rs.50,000 per transaction"); } catch (ServiceException se) { System.out.println("Form 2 [message only]:"); System.out.println(" Message: " + se.getMessage()); System.out.println(" Cause: " + se.getCause()); } System.out.println(); // Form 3 — message + cause: WRAPPING an underlying failure with domain context try { try { throw new java.io.IOException("Connection reset"); } catch (java.io.IOException ioException) { throw new ServiceException("Failed to process payment", ioException); } } catch (ServiceException se) { System.out.println("Form 3 [message + cause]:"); System.out.println(" Message: " + se.getMessage()); System.out.println(" Cause: " + se.getCause()); } System.out.println(); // Form 4 — cause only: less common, message defaults to cause.toString() try { try { throw new NumberFormatException("For input string: \"abc\""); } catch (NumberFormatException nfe) { throw new ServiceException(nfe); // no custom message — uses cause's toString() } } catch (ServiceException se) { System.out.println("Form 4 [cause only]:"); System.out.println(" Message: " + se.getMessage()); // derived from cause System.out.println(" Cause: " + se.getCause()); } } public static void main(String[] args) { demonstrateConstructorUsage(); } }
Output:
Form 2 [message only]:
  Message: Order total cannot exceed Rs.50,000 per transaction
  Cause:   null

Form 3 [message + cause]:
  Message: Failed to process payment
  Cause:   java.io.IOException: Connection reset

Form 4 [cause only]:
  Message: java.lang.NumberFormatException: For input string: "abc"
  Cause:   java.lang.NumberFormatException: For input string: "abc"

Domain Fields and Error Codes

The single most valuable addition a custom exception can make over a generic RuntimeException with a string message is structured data: fields that catching code can read programmatically, without parsing a message string.

// File: DomainFieldsDemo.java public class DomainFieldsDemo { // Custom exception with structured domain fields AND an error code // The error code is what API responses, logs, and monitoring dashboards // actually key on — the message is for humans, the code is for systems static class PaymentDeclinedException extends RuntimeException { public enum DeclineReason { INSUFFICIENT_FUNDS, CARD_EXPIRED, FRAUD_SUSPECTED, BANK_UNAVAILABLE } private final String transactionId; private final double amount; private final DeclineReason reason; public PaymentDeclinedException( String transactionId, double amount, DeclineReason reason) { super(String.format("Payment declined for transaction %s (Rs.%.2f): %s", transactionId, amount, reason)); this.transactionId = transactionId; this.amount = amount; this.reason = reason; } public String getTransactionId() { return transactionId; } public double getAmount() { return amount; } public DeclineReason getReason() { return reason; } // Convenience method — catching code can check WITHOUT string comparison public boolean isRetryable() { return reason == DeclineReason.BANK_UNAVAILABLE; } } static void chargeCard(String transactionId, double amount, String cardStatus) { if (cardStatus.equals("EXPIRED")) { throw new PaymentDeclinedException( transactionId, amount, PaymentDeclinedException.DeclineReason.CARD_EXPIRED); } if (cardStatus.equals("LOW_BALANCE")) { throw new PaymentDeclinedException( transactionId, amount, PaymentDeclinedException.DeclineReason.INSUFFICIENT_FUNDS); } if (cardStatus.equals("BANK_DOWN")) { throw new PaymentDeclinedException( transactionId, amount, PaymentDeclinedException.DeclineReason.BANK_UNAVAILABLE); } System.out.println(" Charged Rs." + amount + " for " + transactionId); } public static void main(String[] args) { System.out.println("=== Successful charge ==="); chargeCard("TXN-001", 999.0, "VALID"); System.out.println(); System.out.println("=== Declined: different reasons drive different responses ==="); String[][] cases = { {"TXN-002", "1499.0", "EXPIRED"}, {"TXN-003", "299.0", "LOW_BALANCE"}, {"TXN-004", "5000.0", "BANK_DOWN"} }; for (String[] testCase : cases) { try { chargeCard(testCase[0], Double.parseDouble(testCase[1]), testCase[2]); } catch (PaymentDeclinedException pde) { System.out.println(" " + pde.getMessage()); // Reading STRUCTURED fields, not parsing the message string switch (pde.getReason()) { case CARD_EXPIRED -> System.out.println(" Action: prompt user to update card"); case INSUFFICIENT_FUNDS -> System.out.println(" Action: suggest alternative payment method"); case BANK_UNAVAILABLE -> System.out.println(" Action: retryable=" + pde.isRetryable() + " — queue for automatic retry"); case FRAUD_SUSPECTED -> System.out.println(" Action: flag for manual review"); } } } } }
Output:
=== Successful charge ===
  Charged Rs.999.0 for TXN-001

=== Declined: different reasons drive different responses ===
  Payment declined for transaction TXN-002 (Rs.1499.00): CARD_EXPIRED
    Action: prompt user to update card
  Payment declined for transaction TXN-003 (Rs.299.00): INSUFFICIENT_FUNDS
    Action: suggest alternative payment method
  Payment declined for transaction TXN-004 (Rs.5000.00): BANK_UNAVAILABLE
    Action: retryable=true — queue for automatic retry

Building an Exception Hierarchy

When an application has many related custom exceptions, a common abstract base class lets callers catch the entire family with one catch block when they do not need to distinguish between specific failures, while still allowing specific catches when they do.

// File: ExceptionHierarchyDemo.java import java.util.List; public class ExceptionHierarchyDemo { // Abstract base — never thrown directly, only extended // Carries fields common to ALL order-related failures static abstract class OrderException extends RuntimeException { private final String orderId; protected OrderException(String orderId, String message) { super(message); this.orderId = orderId; } public String getOrderId() { return orderId; } } // Specific exceptions — each extends the common base static class OrderNotFoundException extends OrderException { OrderNotFoundException(String orderId) { super(orderId, "Order not found: " + orderId); } } static class OrderAlreadyShippedException extends OrderException { private final String shippedDate; OrderAlreadyShippedException(String orderId, String shippedDate) { super(orderId, "Order " + orderId + " already shipped on " + shippedDate + " — cannot modify"); this.shippedDate = shippedDate; } String getShippedDate() { return shippedDate; } } static class OrderCancellationWindowExpiredException extends OrderException { private final int hoursElapsed; OrderCancellationWindowExpiredException(String orderId, int hoursElapsed) { super(orderId, "Order " + orderId + " cancellation window expired (" + hoursElapsed + "h elapsed, limit is 24h)"); this.hoursElapsed = hoursElapsed; } int getHoursElapsed() { return hoursElapsed; } } static void cancelOrder(String orderId, String status, int hoursElapsed) { if (status.equals("NOT_FOUND")) { throw new OrderNotFoundException(orderId); } if (status.equals("SHIPPED")) { throw new OrderAlreadyShippedException(orderId, "2026-06-10"); } if (hoursElapsed > 24) { throw new OrderCancellationWindowExpiredException(orderId, hoursElapsed); } System.out.println(" Cancelled: " + orderId); } public static void main(String[] args) { System.out.println("=== Catching the FAMILY with one block (generic handling) ==="); List<String[]> requests = List.of( new String[]{"ORD-001", "PENDING", "5"}, new String[]{"ORD-002", "NOT_FOUND", "0"}, new String[]{"ORD-003", "SHIPPED", "0"}, new String[]{"ORD-004", "PENDING", "30"} ); for (String[] req : requests) { try { cancelOrder(req[0], req[1], Integer.parseInt(req[2])); } catch (OrderException oe) { // Catches ALL OrderException subtypes with ONE block — // useful for generic logging/metrics that apply to any order failure System.out.println(" [LOG] Order failure for " + oe.getOrderId() + " [" + oe.getClass().getSimpleName() + "]: " + oe.getMessage()); } } System.out.println(); System.out.println("=== Catching SPECIFIC types for different recovery actions ==="); try { cancelOrder("ORD-005", "SHIPPED", 0); } catch (OrderAlreadyShippedException oase) { // SPECIFIC catch — access to shippedDate, which the base type does not expose System.out.println(" Shipped on " + oase.getShippedDate() + " — offer return instead of cancellation"); } catch (OrderException oe) { // FALLBACK — any other order exception System.out.println(" General order failure: " + oe.getMessage()); } try { cancelOrder("ORD-006", "PENDING", 48); } catch (OrderCancellationWindowExpiredException ocwee) { // SPECIFIC catch — access to hoursElapsed System.out.println(" " + ocwee.getHoursElapsed() + "h elapsed — offer partial refund instead of cancellation"); } catch (OrderException oe) { System.out.println(" General order failure: " + oe.getMessage()); } } }
Output:
=== Catching the FAMILY with one block (generic handling) ===
  Cancelled: ORD-001
  [LOG] Order failure for ORD-002 [OrderNotFoundException]: Order not found: ORD-002
  [LOG] Order failure for ORD-003 [OrderAlreadyShippedException]: Order ORD-003 already shipped on 2026-06-10 — cannot modify
  [LOG] Order failure for ORD-004 [OrderCancellationWindowExpiredException]: Order ORD-004 cancellation window expired (30h elapsed, limit is 24h)

=== Catching SPECIFIC types for different recovery actions ===
  Shipped on 2026-06-10 — offer return instead of cancellation
  48h elapsed — offer partial refund instead of cancellation

Basic Example

public class Main { // A custom exception for one specific problem: withdrawing more than the balance static class InsufficientBalanceException extends Exception { public InsufficientBalanceException(String message) { super(message); } } static void withdraw(double balance, double amount) throws InsufficientBalanceException { if (amount > balance) { throw new InsufficientBalanceException( "Cannot withdraw " + amount + ", balance is only " + balance); } System.out.println("Withdrawal successful: " + amount); } public static void main(String[] args) { try { withdraw(500.0, 200.0); withdraw(500.0, 800.0); } catch (InsufficientBalanceException e) { System.out.println("Transaction failed: " + e.getMessage()); } } }

Output

Withdrawal successful: 200.0
Transaction failed: Cannot withdraw 800.0, balance is only 500.0

How it works:

  1. ›InsufficientBalanceException is a custom exception class that extends Exception, giving it its own type distinct from any built-in exception.
  2. ›It defines one constructor that passes a message to super(message), so getMessage() returns a description specific to this failure.
  3. ›withdraw declares throws InsufficientBalanceException and throws it only when amount exceeds balance.
  4. ›The first call succeeds and prints normally; the second call throws the custom exception, which is caught in main and its message is printed instead of the program crashing.

Best Practices

Make custom exceptions immutable — set fields once in the constructor, never add setters. An exception object represents a fact about something that already happened. Adding setters invites code that catches an exception, mutates it, and rethrows it — which makes the exception's history confusing and can corrupt the stack trace's meaning. All fields should be final and assigned only in the constructor.

Name custom exceptions for the failure, not the layer. InsufficientStockException describes what went wrong. ServiceLayerException describes where the code lives — it tells callers nothing about the actual problem. The name should let a developer reading a catch block understand the failure without opening the exception's source file.

Group related exceptions under a common abstract base only when callers genuinely need to catch the group. If every catch site in your codebase ends up writing catch (OrderException e) and then immediately checking instanceof to figure out which specific subtype it is, the hierarchy is not earning its complexity — separate, unrelated exceptions with no common base would serve the same purpose with less ceremony. Build a hierarchy when there is a real, recurring need to handle the family generically (logging, metrics, a catch-all API error mapper) AND specifically (different recovery per subtype).

Always provide the message-and-cause constructor, even if you think you will never wrap another exception. Code evolves. A custom exception that starts as "this is always a fresh failure, never a wrapped one" frequently ends up needing to wrap an underlying IOException or SQLException six months later. Having super(message, cause) available from day one means that change does not require modifying the exception class itself — only the throw site.

Common Mistakes

Mistake 1 — Forgetting to Call a super Constructor With the Message

// WRONG — message and cause are never passed to Throwable // getMessage() returns null, getCause() returns null — debugging is much harder public class BadOrderException extends RuntimeException { private final String orderId; public BadOrderException(String orderId) { // super() implicitly called — message is NEVER SET this.orderId = orderId; } public String getOrderId() { return orderId; } } // e.getMessage() returns null — printStackTrace() shows no message at all // CORRECT — always pass a message to super() public class GoodOrderException extends RuntimeException { private final String orderId; public GoodOrderException(String orderId) { super("Order processing failed: " + orderId); // message IS set this.orderId = orderId; } public String getOrderId() { return orderId; } }

Mistake 2 — Creating a New Exception Class for Every Slightly Different Message

// WRONG — five nearly-identical classes, each with one hardcoded message variant class InvalidEmailException extends RuntimeException { InvalidEmailException() { super("Invalid email format"); } } class InvalidPhoneException extends RuntimeException { InvalidPhoneException() { super("Invalid phone format"); } } class InvalidPincodeException extends RuntimeException { InvalidPincodeException() { super("Invalid pincode format"); } } // ...two more like this — five classes for what is really ONE concept // CORRECT — one exception class, parameterized by WHAT FAILED VALIDATION class InvalidFieldException extends RuntimeException { private final String fieldName; private final String invalidValue; InvalidFieldException(String fieldName, String invalidValue) { super("Invalid value for field '" + fieldName + "': " + invalidValue); this.fieldName = fieldName; this.invalidValue = invalidValue; } String getFieldName() { return fieldName; } String getInvalidValue() { return invalidValue; } } // throw new InvalidFieldException("email", "not-an-email"); // throw new InvalidFieldException("phone", "12345");

Mistake 3 — Putting Mutable State or Heavy Objects Inside an Exception

// WRONG — exception holds a reference to a large, mutable object // The object's state at the time of CATCHING may differ from // its state at the time of THROWING — misleading for debugging class OrderValidationException extends RuntimeException { private final Order order; // entire mutable Order object — large and mutable OrderValidationException(Order order, String reason) { super("Validation failed: " + reason); this.order = order; // if 'order' changes after this, the exception's view changes too } Order getOrder() { return order; } } // CORRECT — extract only the immutable, relevant identifying data at throw time class OrderValidationExceptionFixed extends RuntimeException { private final String orderId; // just the ID — small, immutable private final double attemptedTotal; // a snapshot value, not a live reference OrderValidationExceptionFixed(String orderId, double attemptedTotal, String reason) { super("Validation failed for order " + orderId + ": " + reason); this.orderId = orderId; this.attemptedTotal = attemptedTotal; } String getOrderId() { return orderId; } double getAttemptedTotal() { return attemptedTotal; } }

Mistake 4 — Extending Throwable or Error Directly Instead of Exception

// WRONG — extending Error suggests a JVM-level, unrecoverable condition // Application code should almost NEVER extend Error class OutOfInventoryError extends Error { // misleading severity signal OutOfInventoryError(String productId) { super("Out of inventory: " + productId); } } // Catching code, monitoring tools, and other developers will treat this // as if it were as severe as OutOfMemoryError or StackOverflowError — // it is not; it is a normal business condition // CORRECT — extend Exception or RuntimeException for application-level failures class OutOfInventoryException extends RuntimeException { OutOfInventoryException(String productId) { super("Out of inventory: " + productId); } } // Reserve extending Error for genuinely catastrophic, unrecoverable // conditions — and in practice, application code almost never does this; // Error subclasses are created by the JVM itself

Interview Questions

Q1. How do you create a custom exception in Java?

Extend Exception (for a checked custom exception) or RuntimeException (for unchecked), and provide at least a constructor that accepts a message and calls super(message). A well-designed custom exception typically also provides a constructor accepting (String message, Throwable cause) for wrapping scenarios, and adds domain-specific fields with getters — for example, InsufficientStockException might carry productId, requestedQuantity, and availableQuantity as fields, set once in the constructor and exposed via getters, so catching code can react to the specific numbers without parsing the message string.

Q2. Should a custom exception extend Exception or RuntimeException — what determines this choice?

The decision depends on whether callers have a genuine, differentiated recovery path and whether the failure represents an expected business condition or a programming/system error. Extend Exception (checked) when callers must be forced to acknowledge and handle the failure — InsufficientFundsException where the UI shows a top-up prompt. Extend RuntimeException (unchecked) when the failure is best handled at a single boundary (a global exception handler) or represents a contract violation — InvalidOrderStateException thrown when a method is called on an order in the wrong state. Most modern Java codebases lean toward unchecked custom exceptions to avoid throws-clause pollution through service layers, reserving checked exceptions for cases with genuinely distinct per-caller recovery.

Q3. What constructors should a custom exception class provide?

Following the Throwable convention: a no-argument constructor, a (String message) constructor, a (String message, Throwable cause) constructor, and optionally a (Throwable cause) constructor. In practice, the (String message) and (String message, Throwable cause) forms are used most often — the first for a freshly-detected failure, the second for wrapping an underlying exception while adding domain context. Providing all four, even if only two are currently used, means future code that needs to wrap an exception does not require modifying the custom exception class itself.

Q4. What is the benefit of adding fields to a custom exception instead of putting everything in the message string?

Fields allow catching code to react programmatically without parsing strings. catch (PaymentDeclinedException e) { if (e.getReason() == DeclineReason.BANK_UNAVAILABLE) retry(); } is reliable and type-safe. catch (RuntimeException e) { if (e.getMessage().contains("BANK_UNAVAILABLE")) retry(); } is fragile — it breaks if the message wording changes, and string-matching on exception messages is a maintenance hazard that code reviews routinely flag. Fields also enable structured logging and API error responses that include machine-readable error codes alongside human-readable messages.

Q5. When should you build an exception hierarchy with a common abstract base class?

When there is a genuine recurring need to catch a family of related exceptions generically — for logging, metrics, or a centralized API error mapper — while ALSO sometimes needing to catch specific subtypes for differentiated recovery. An abstract OrderException base carrying a common orderId field, extended by OrderNotFoundException, OrderAlreadyShippedException, and others, lets a generic handler do catch (OrderException e) { logFailure(e.getOrderId(), e); } while a specific handler elsewhere does catch (OrderAlreadyShippedException e) { offerReturn(e.getShippedDate()); }. If no code ever needs the generic catch, the hierarchy adds complexity without benefit — flat, unrelated exception classes are simpler.

Q6. What happens if a custom exception class does not call super() with a message?

If the constructor does not explicitly call super(message) (or any super(...) form that sets the message), Java implicitly calls the no-argument super(), and getMessage() returns null for that exception instance. This means printStackTrace() shows the exception type with no message, and any logging code that does logger.error(exception.getMessage()) logs null. This is a common mistake in fresher pull requests — the custom exception class compiles fine and even "works" in the sense that it is thrown and caught correctly, but it carries no diagnostic information, which only becomes apparent when someone tries to debug a production issue using it.

FAQs

Can a custom exception have multiple constructors?

Yes — and it should, for the same reason any well-designed class has multiple constructors: different call sites have different information available. A constructor taking just a product ID for a "not found" scenario, and another taking a product ID plus a cause for a "lookup failed due to infrastructure error" scenario, are both valid and commonly coexist on the same exception class.

Should custom exceptions be in their own package?

Many teams organize custom exceptions into a dedicated package (com.example.orders.exceptions) or co-locate them with the domain classes they relate to (com.example.orders alongside Order.java). Either convention works; the important thing is consistency across the codebase so developers know where to look. Co-location is often preferred for smaller exception families — package separation tends to help in larger codebases with many domain modules, each having their own exception sets.

Can a custom exception class be generic (use type parameters)?

Technically yes, but it is rare and usually unnecessary. Throwable and its subclasses are not designed with generics in mind, and a generic exception class (class ValidationException<T> extends RuntimeException) introduces complexity — catch clauses cannot use generic types (catch (ValidationException<String> e) is not valid Java) due to type erasure. If you need to carry a typed value, use a non-generic exception class with a field of type Object or a sealed set of specific subclasses instead.

Is it bad practice to have too many custom exception classes?

It depends on whether each class represents a genuinely distinct failure mode that callers handle differently. Five custom exceptions, each handled differently by callers, are five legitimate classes. Five custom exceptions that differ only in their hardcoded message text, all caught and handled identically, should likely be one parameterized exception class (see Common Mistake 2). The test: if removing a specific exception class and replacing all its throw sites with a more general one would not change any catching code's behaviour, the specific class was probably unnecessary.

Do custom exceptions need to be serializable?

Exception and RuntimeException already implement Serializable (inherited from Throwable), so custom exceptions are serializable by default as long as all their fields are also serializable types (or marked transient). This matters primarily in distributed systems — RMI, certain messaging frameworks, or any scenario where an exception object crosses a serialization boundary. If a custom exception field holds a non-serializable type (a database connection, a thread), mark it transient or avoid storing it directly — store an identifying string instead.

Can you catch a custom exception using its parent class type?

Yes — this is standard polymorphic catching and is one of the main reasons to build an exception hierarchy. If InsufficientStockException extends RuntimeException, then catch (RuntimeException e) catches it (along with every other unchecked exception). If you built a custom hierarchy with InsufficientStockException extends InventoryException, then catch (InventoryException e) catches it along with any other InventoryException subtype, but not unrelated RuntimeException subtypes outside that hierarchy.

Summary

A custom exception class is a small, focused addition to Java's exception model: extend Exception or RuntimeException, call the appropriate super() constructor to set the message and optionally the cause, and add whatever domain fields make the failure actionable for catching code. The choice between checked and unchecked should reflect whether callers have a genuinely different recovery path per exception type — not be a default.

The decisions that separate well-designed custom exceptions from clutter: immutable fields set once in the constructor, names that describe the failure rather than the architectural layer, structured fields and error codes instead of string-parseable messages, and a hierarchy built only when generic-and-specific catching are both genuinely needed.

Every custom exception in a production codebase should answer one question clearly: when this is caught, what does the catching code now know that it did not know before, and what can it do differently because of it? If the answer is "nothing beyond what a generic RuntimeException with this message would provide," the custom exception is not pulling its weight — and that is a useful filter to apply during code reviews of new exception classes.

What to Read Next

Practice Programs

Put this topic into practice — each program has full code, output and a step-by-step explanation you can run online.

Browse all Java programs →