Clean Exception Handling Patterns
The 3-Line Engineering Rule
Never catch `Exception` and silently swallow it; translate technical exceptions into rich domain exceptions at integration boundaries.
Swallowing exceptions (`catch (Exception e) {}`) hides corrupted state, makes bugs impossible to locate, and causes silent data loss.
Catch specific exceptions, wrap in custom domain exceptions with context, preserve original cause (`throw new DomainEx('failed', cause)`), and handle globally via `@ControllerAdvice`.
Architectural Blueprint & Sequence
Hover over nodes to inspect state transitionsLow-Level Driver
Throws PSQLException (Deadlock)
Integration Adapter
Catches & translates with cause
Domain Service
Throws LockAcquisitionException
Global @ControllerAdvice
Logs traceId & returns HTTP 409
Key Engineering Takeaways
- Never catch `Throwable` or `Exception` without re-throwing or logging the stack trace.
- Always pass the original cause when wrapping exceptions: `new CustomException(msg, cause)`.
- Distinguish recoverable business exceptions from fatal system defects.
- Handle exceptions at architectural boundaries (API Controllers / Messaging Handlers), not in every 3rd line.
The Problem & Production Reality
Exception handling is about managing failure gracefully without obscuring the root cause. Bad exception handling either swallows the error (leaving developers blind) or litters business logic with dozens of try/catch blocks that obscure the core algorithm.
Anti-Pattern: Swallowing Exceptions and Dropping Root Causes
Empty catch blocks or catching an exception and throwing a new one without passing the original cause.
// ❌ ANTI-PATTERN 1: The Silent Killer
try {
paymentService.charge(user, amount);
} catch (Exception e) {
// Empty block! Payment failed, but program continues as if it succeeded!
}
// ❌ ANTI-PATTERN 2: Lost Stack Trace Cause
try {
db.save(order);
} catch (SQLException e) {
throw new RuntimeException("Database error"); // ❌ Dropped 'e'! Root cause is lost forever!
}Production Pattern: Domain Hierarchy with Preserved Cause & Global Handler
Create a typed hierarchy of domain exceptions that preserve the underlying cause and map cleanly to HTTP status codes.
// ✅ PRODUCTION PATTERN: Domain Exception Hierarchy
public abstract class DomainException extends RuntimeException {
protected DomainException(String message) { super(message); }
protected DomainException(String message, Throwable cause) { super(message, cause); }
}
public class OrderNotFoundException extends DomainException {
public OrderNotFoundException(String orderId) {
super("Order not found with identifier: " + orderId);
}
}
public class PaymentGatewayException extends DomainException {
public PaymentGatewayException(String message, Throwable cause) {
super(message, cause); // Always preserve cause!
}
}
// Clean Domain Service (No try-catch clutter)
@Service
public class OrderService {
public Order getOrder(String id) {
return orderRepo.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}Trade-off Analysis & Staff Recommendations
| Aspect | Pros / Strengths | Cons / Hazards | Staff Recommendation |
|---|---|---|---|
| Checked vs Unchecked Exceptions | Checked exceptions force callers to consider failure cases at compile time. | Checked exceptions pollute method signatures throughout intermediate architectural layers. | Prefer unchecked (`RuntimeException`) domain exceptions; handle them centrally at boundaries. |
| Exceptions for Control Flow | None. | Constructing exceptions is slow because the JVM fills in the entire execution stack trace. | Never use exceptions for normal control flow (e.g. checking if a user exists); return `Optional<T>`. |