Back to Exception Chaining & Stack Traces
Q2Question 2 of 4

What is exception chaining?

Key Concepts
  • Exception chaining wraps a low-level exception inside a domain-meaningful one using the cause constructor: super(message, cause)
  • getCause() retrieves the original exception, and stack traces show the full Caused by chain
  • Chaining provides abstraction (callers do not depend on implementation details), context (domain message), and decoupling (swap DB without changing API)
  • Always pass the cause when wrapping or the root cause is lost and debugging becomes guesswork
  • Multi-level chains preserve context at every layer: Network to SQL to Repository to Service to Controller
  • Spring automatically chains exceptions: SQLException becomes DataAccessException with the original as the cause

Definition

Exception chaining wraps a low-level exception inside a domain-meaningful one using the cause constructor, preserving the original via getCause() and the Caused by chain in stack traces.
  • Exception chaining wraps a low-level exception (the cause) inside a higher-level, domain-meaningful exception: throw new TradeSettlementException("Settlement failed for TRD-001", sqlException).
  • The caller sees TradeSettlementException (domain language). The original SQLException is preserved as the cause, accessible via getCause().
  • Stack traces show Caused by: java.sql.SQLException... preserving the full chain.
  • Why chain: (1) Abstraction: controller does not depend on SQLException. (2) Context: "Settlement failed for TRD-001" is more meaningful than "Connection refused." (3) Decoupling: switching from SQL to NoSQL does not change the service API. (4) No information lost: getCause() preserves the original.

The database throws "Connection refused: port 5432" which is meaningless to Priya. The service wraps it as "Trade settlement failed for your RELIANCE shares." The controller tells Priya: "Your trade could not be settled, please try later." If Rajesh (the developer) investigates, he calls getCause() and finds the original "Connection refused" error. Information flows up in domain language while preserving technical details for debugging.

Understanding the Concepts

What is exception chaining?

Exception chaining is the practice of wrapping a low-level exception inside a higher-level, domain-specific exception. The original exception is preserved as the "cause" and is accessible via getCause().

JAVA
package com.bank.service;

import java.math.BigDecimal;

public class ExceptionChainingDemo {

    // Custom domain exception with cause constructor
    static class SettlementException extends RuntimeException {
        public SettlementException(String message, Throwable cause) {
            super(message, cause);  // passes cause to Throwable
        }
    }

    // Simulates a low-level SQL failure
    static void updateDatabase(String tradeId) {
        throw new RuntimeException("Connection refused: localhost:5432");
    }

    // Service wraps low-level exception in domain exception
    static void settleTrade(String tradeId) {
        try {
            updateDatabase(tradeId);
        } catch (RuntimeException e) {
            throw new SettlementException(
                "Settlement failed for trade " + tradeId, e);
        }
    }

    public static void main(String[] args) {
        try {
            settleTrade("TRD-001");
        } catch (SettlementException e) {
            System.out.println("Domain message: " + e.getMessage());
            System.out.println("Root cause: " + e.getCause().getMessage());
        }
    }
}
TEXT
Domain message: Settlement failed for trade TRD-001
Root cause: Connection refused: localhost:5432

Why should you wrap exceptions instead of letting them propagate?

Without wrapping, low-level implementation details leak to callers. The controller depends on SQLException, creating tight coupling.

JAVA
package com.bank.demo;

public class WhyWrapDemo {

    // WRONG: low-level exception leaks to caller
    // public void settleTrade(String id) throws SQLException {
    //     jdbc.execute(sql, id);  // SQLException leaks!
    // }
    // Controller now depends on java.sql.
    // If you switch to MongoDB, the method signature CHANGES.
    // All callers break.

    // CORRECT: wrap in domain exception
    static class SettlementException extends RuntimeException {
        SettlementException(String msg, Throwable cause) { super(msg, cause); }
    }

    public static void settleTrade(String id) {
        try {
            throw new RuntimeException("SQL: constraint violation");
        } catch (RuntimeException e) {
            throw new SettlementException("Settlement failed for " + id, e);
        }
    }

    public static void main(String[] args) {
        try {
            settleTrade("TRD-001");
        } catch (SettlementException e) {
            System.out.println("Controller sees: " + e.getMessage());
            System.out.println("Does not know about SQL");
        }
    }
}
TEXT
Controller sees: Settlement failed for TRD-001
Does not know about SQL

What happens when you forget to pass the cause?

If you create a new exception without passing the original as the cause, the root cause is permanently lost. Debugging becomes guesswork.

JAVA
package com.bank.demo;

public class ForgotCauseDemo {

    static class ServiceException extends RuntimeException {
        ServiceException(String msg) { super(msg); }  // no cause constructor used
        ServiceException(String msg, Throwable cause) { super(msg, cause); }
    }

    public static void main(String[] args) {
        RuntimeException original = new RuntimeException("Deadlock on accounts table");

        // WRONG: cause lost
        ServiceException wrong = new ServiceException("Settlement failed");
        System.out.println("Wrong - cause: " + wrong.getCause());

        // CORRECT: cause preserved
        ServiceException correct = new ServiceException("Settlement failed", original);
        System.out.println("Correct - cause: " + correct.getCause().getMessage());
    }
}
TEXT
Wrong - cause: null
Correct - cause: Deadlock on accounts table

How do you traverse a multi-level exception chain?

In production, exceptions often chain through multiple layers. The root cause is at the bottom of the chain.

JAVA
package com.bank.demo;

public class MultiLevelChainDemo {

    public static Throwable getRootCause(Throwable t) {
        Throwable cause = t;
        while (cause.getCause() != null) {
            cause = cause.getCause();
        }
        return cause;
    }

    public static void main(String[] args) {
        // Build a 3-level chain:
        // ConnectException → SQLException → SettlementException
        Exception network = new Exception("Connection refused: 10.0.0.5:5432");
        Exception sql = new Exception("Unable to get connection", network);
        RuntimeException settlement = new RuntimeException(
            "Settlement failed for TRD-001", sql);

        // Traverse the chain
        System.out.println("Top: " + settlement.getMessage());
        System.out.println("Mid: " + settlement.getCause().getMessage());
        System.out.println("Root: " + getRootCause(settlement).getMessage());
    }
}
TEXT
Top: Settlement failed for TRD-001
Mid: Unable to get connection
Root: Connection refused: 10.0.0.5:5432

How does exception chaining appear in stack traces?

Stack traces show the "Caused by:" section for each level of chaining. The root cause is at the very bottom.

JAVA
package com.bank.demo;

public class StackTraceChainDemo {

    static void network() {
        throw new RuntimeException("Connection refused");
    }

    static void database() {
        try { network(); }
        catch (RuntimeException e) {
            throw new RuntimeException("DB query failed", e);
        }
    }

    static void service() {
        try { database(); }
        catch (RuntimeException e) {
            throw new RuntimeException("Settlement failed for TRD-001", e);
        }
    }

    public static void main(String[] args) {
        try {
            service();
        } catch (RuntimeException e) {
            // In production: log.error("Settlement error", e);
            System.out.println("Top: " + e.getMessage());
            System.out.println("Caused by: " + e.getCause().getMessage());
            System.out.println("Root: " + e.getCause().getCause().getMessage());
        }
    }
}
TEXT
Top: Settlement failed for TRD-001
Caused by: DB query failed
Root: Connection refused

What should a senior engineer know?

  • initCause(). Before the cause-accepting constructor was common, you used exception.initCause(cause). This can only be called once per exception. Modern code always uses the constructor.
  • Spring's exception translation. Spring's @Repository automatically translates vendor-specific SQL exceptions into the DataAccessException hierarchy with cause chaining. You get domain exceptions without manual wrapping.
  • getCause() chains can be circular. Technically, a.initCause(b); b.initCause(a) creates a cycle. Java prevents this by throwing IllegalStateException on the second initCause call.

What are common interview follow-up questions?

  • What method retrieves the cause? (getCause() on Throwable.)
  • Can you chain more than two levels? (Yes, each cause can have its own cause.)
  • What is the difference between cause and suppressed? (Cause is for wrapping at abstraction boundaries. Suppressed is for concurrent exceptions from close().)
  • Does Spring use exception chaining? (Yes, DataAccessException chains the original SQLException as the cause.)

Visual Overview

DIAGRAM
  Controller                Service                 Repository
  catches domain ex    ←    wraps SQL in domain  ←  throws SQL

  SettlementException: Settlement failed for TRD-001
    at service.settle()
  Caused by: SQLException: Unable to get connection
    at repo.findAmount()
  Caused by: ConnectException: Connection refused
    at Socket.connect()

  Read BOTTOM-UP for root cause.
  Each level adds CONTEXT.
  getCause() traverses down.

Key Takeaway

Exception chaining wraps a low-level exception inside a domain-meaningful one using the cause constructor: throw new SettlementException("Settlement failed for TRD-001", sqlException). The original exception is preserved as the cause, accessible via getCause(). Stack traces show "Caused by:" with the full chain, preserving both business context and technical details.

Four reasons to chain: abstraction (callers depend on domain exceptions, not implementation), context (domain message is more useful than "Connection refused"), decoupling (switch from SQL to MongoDB without changing the service API), and completeness (getCause preserves the root cause for debugging). Always pass the cause when wrapping. Without it, the "Caused by:" section is missing and debugging becomes guesswork.

In the settlement service at National Bank of India, Rajesh wraps every DataAccessException in a SettlementException with the trade ID in the message. The controller sees domain exceptions. The logs show the full chain: SettlementException, caused by DataAccessException, caused by ConnectException. The ops team reads bottom-up to find the root cause.

Remember one sentence: always pass the original exception as the cause when wrapping, because without it the root cause is permanently lost.

---

Share
Use ← → arrow keys to navigate