Definition
- 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 originalSQLExceptionis preserved as the cause, accessible viagetCause(). - 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().
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());
}
}
}
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.
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");
}
}
}
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.
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());
}
}
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.
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());
}
}
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.
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());
}
}
}
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
@Repositoryautomatically translates vendor-specific SQL exceptions into theDataAccessExceptionhierarchy 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
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.
---