Chained Exceptions
Exception chaining is the practice of associating a caught exception with a new exception being thrown, preserving the full causal history of a failure. When a low-level exception (a database connection failure, a file I/O error) causes a high-level failure (an order cannot be processed), chaining makes both facts visible — what happened at the business level and exactly what technical failure caused it. Java provides built-in support for chaining through Throwable's cause field, accessible via getCause(), and displayed as 'Caused by:' in stack traces. This entry covers the mechanics of chaining, reading chained stack traces, building and traversing cause chains programmatically, the exception translation pattern, common anti-patterns, and why chaining is essential for production debugging.
Chaining Mechanics — cause and initCause()
// ── Creating chained exceptions ──────────────────────────────────────
// Two-arg constructor: message + cause
public class ServiceException extends RuntimeException {
public ServiceException(String message, Throwable cause) {
super(message, cause); // passes both to Throwable
}
}
// ── Chain in practice: wrapping at abstraction boundaries ─────────────
public User findUser(Long id) {
try {
return jdbcTemplate.queryForObject(
"SELECT * FROM users WHERE id = ?",
USER_MAPPER, id);
} catch (EmptyResultDataAccessException e) {
// Chain: UserNotFoundException caused by EmptyResultDataAccessException
throw new UserNotFoundException(
"User not found: " + id, e); // cause preserved
} catch (DataAccessException e) {
// Chain: UserRepositoryException caused by DataAccessException
throw new UserRepositoryException(
"Failed to query user " + id, e); // cause preserved
}
}
// ── getCause() — accessing the cause chain ────────────────────────────
try {
userService.findUser(99L);
} catch (UserNotFoundException e) {
System.out.println(e.getMessage()); // User not found: 99
System.out.println(e.getCause().getClass() // EmptyResultDataAccessException
.getSimpleName());
System.out.println(e.getCause().getMessage()); // Incorrect result size...
}
// ── initCause() — for legacy exception classes without cause constructor
Exception legacyEx = new Exception("legacy error");
legacyEx.initCause(new IOException("root cause"));
// initCause() can only be called once — second call throws IllegalStateException
// ── Traversing the entire cause chain ─────────────────────────────────
public static Throwable getRootCause(Throwable t) {
Throwable cause = t;
while (cause.getCause() != null) {
cause = cause.getCause();
}
return cause;
}
public static List<Throwable> getCauseChain(Throwable t) {
List<Throwable> chain = new ArrayList<>();
Throwable current = t;
while (current != null) {
chain.add(current);
current = current.getCause();
}
return chain;
}
Throwable root = getRootCause(e);
System.out.println("Root cause: " + root.getClass().getSimpleName()
+ ": " + root.getMessage());Reading Chained Stack Traces
// ── Generated chained stack trace ────────────────────────────────────
// This code:
try {
try {
try {
throw new SQLException("Connection timeout after 30s");
} catch (SQLException e) {
throw new RepositoryException("Failed to reserve inventory", e);
}
} catch (RepositoryException e) {
throw new ServiceException("Inventory reservation failed for order 42", e);
}
} catch (ServiceException e) {
throw new ApiException("Failed to create order for user 7", e);
}
// Produces this stack trace:
// com.myapp.ApiException: Failed to create order for user 7
// at com.myapp.OrderController.createOrder(OrderController.java:45)
// at com.myapp.OrderController$$SpringProxy.createOrder(...)
// at java.lang.Thread.run(Thread.java:834)
// Caused by: com.myapp.ServiceException: Inventory reservation failed for order 42
// at com.myapp.InventoryService.reserve(InventoryService.java:78)
// at com.myapp.OrderService.createOrder(OrderService.java:56)
// at com.myapp.OrderController.createOrder(OrderController.java:43)
// ... 2 more ← frames already shown above
// Caused by: com.myapp.RepositoryException: Failed to reserve inventory
// at com.myapp.InventoryRepository.reserve(InventoryRepository.java:92)
// at com.myapp.InventoryService.reserve(InventoryService.java:76)
// ... 4 more
// Caused by: java.sql.SQLException: Connection timeout after 30s ← ROOT CAUSE
// at com.mysql.jdbc.Driver.query(Driver.java:...)
// at com.myapp.InventoryRepository.reserve(InventoryRepository.java:90)
// ... 6 more
// ── How to read it ────────────────────────────────────────────────────
// 1. TOP section: ApiException — this is what the user/caller sees
// 2. Second section: ServiceException — business layer failure
// 3. Third section: RepositoryException — data access layer failure
// 4. BOTTOM section: SQLException — ROOT CAUSE — start here for diagnosis!
//
// Diagnosis: MySQL connection timed out at InventoryRepository.java:90
// This caused the inventory reservation to fail
// Which caused the order creation to fail
// Which caused the API to return an error
// ── Programmatic inspection of chained trace ──────────────────────────
public static void printCauseChain(Throwable t) {
int level = 0;
Throwable current = t;
while (current != null) {
System.out.printf("%sCaused at level %d: %s: %s%n",
" ".repeat(level),
level,
current.getClass().getSimpleName(),
current.getMessage());
current = current.getCause();
level++;
}
}The Exception Translation Pattern
// ── Three-layer exception translation ────────────────────────────────
// ── Layer 1: Repository — translate SQL to Spring DataAccessException ──
// (Spring does this automatically with @Repository + persistence provider)
// ── Layer 2: Service — translate infrastructure to domain ─────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class InventoryService {
private final InventoryRepository inventoryRepo;
public void reserveItems(Long orderId, List<OrderItem> items) {
for (OrderItem item : items) {
try {
inventoryRepo.reserve(item.getProductId(),
item.getQuantity());
} catch (EmptyResultDataAccessException e) {
// Translate: data layer exception → domain exception
throw new ProductNotFoundException(
item.getProductId(), e); // cause preserved
} catch (DataIntegrityViolationException e) {
// Translate: constraint violation → business exception
throw new InsufficientInventoryException(
item.getProductId(), item.getQuantity(), e);
} catch (DataAccessException e) {
// Translate: generic DB failure → domain exception
// Add context: which product, which order, what we were doing
throw new InventoryServiceException(
String.format(
"Failed to reserve %d units of product %d " +
"for order %d",
item.getQuantity(), item.getProductId(), orderId),
e); // ALWAYS include cause
}
}
}
}
// ── Layer 3: Controller — translate domain to HTTP ────────────────────
@RestController
@RequiredArgsConstructor
@Slf4j
public class OrderController {
private final OrderService orderService;
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(
@RequestBody @Valid CreateOrderRequest request) {
try {
OrderResponse response = orderService.createOrder(request);
return ResponseEntity.status(201).body(response);
} catch (ProductNotFoundException e) {
// Domain exception → 404 HTTP response
// Log at WARN — expected business condition
log.warn("Product not found: {}", e.getProductId());
return ResponseEntity.notFound().build();
} catch (InsufficientInventoryException e) {
// Domain exception → 409 HTTP response
log.warn("Insufficient inventory: product={} requested={}",
e.getProductId(), e.getRequestedQuantity());
return ResponseEntity.status(409)
.body(OrderResponse.error(e.getMessage()));
} catch (InventoryServiceException e) {
// Infrastructure failure → 503 HTTP response
// Log at ERROR with full stack trace — needs investigation
log.error("Inventory service failure for order request", e);
return ResponseEntity.status(503).build();
}
}
}Anti-Patterns and Common Mistakes
// ── Anti-pattern 1: Losing the cause ─────────────────────────────────
// WRONG — root cause lost:
catch (SQLException e) {
throw new DataException("Database error");
// getCause() returns null — original SQLException gone
}
// CORRECT — cause preserved:
catch (SQLException e) {
throw new DataException("Database error saving order " + orderId, e);
// getCause() returns the SQLException with full details
}
// ── Anti-pattern 2: Wrapping with no added context ────────────────────
// WRONG — adds a layer but no information:
catch (RepositoryException e) {
throw new ServiceException(e.getMessage(), e);
// Same message, different type — adds stack depth, not insight
}
// CORRECT — adds meaningful context:
catch (RepositoryException e) {
throw new ServiceException(
"Order creation failed for user " + userId +
" with " + items.size() + " items", e);
// Caller now knows WHO was creating WHAT
}
// ── Anti-pattern 3: Log AND rethrow at same level ─────────────────────
// WRONG — creates duplicate log entries:
catch (Exception e) {
log.error("Failed to process", e); // logged HERE
throw new ServiceException("failed", e);
// Exception will be logged AGAIN by the caller's handler
}
// CORRECT: choose one responsibility per layer:
// If this layer handles it:
catch (Exception e) {
log.error("Failed to process, using fallback", e); // log here
return fallbackResult; // handle, don't rethrow
}
// If this layer propagates it:
catch (Exception e) {
// Don't log here — let the handling layer log once
throw new ServiceException("Process failed: " + context, e);
}
// ── Anti-pattern 4: Exception information in message only ─────────────
// WRONG — exception info only in the message string:
catch (Exception e) {
throw new ServiceException(
"Failed: " + e.getMessage()); // cause not set — just string copy
}
// CORRECT — cause set AND message adds context:
catch (Exception e) {
throw new ServiceException(
"Failed processing order " + orderId +
" (see cause for details)", e);
}