Exception Handling
Good exception handling makes your code robust without cluttering it. In machine coding rounds, custom exceptions signal domain-aware design. Throwing RuntimeException("something went wrong") everywhere signals the opposite.
Why this matters: Interviewers notice when you throw meaningful, domain-specific exceptions (InsufficientBalanceException, SlotUnavailableException) vs. when you let NPEs crash your demo or swallow errors silently.
Prerequisites
- SOLID Principles โ Exceptions support SRP (separate error handling from business logic)
- Class Design Approach โ when to define exceptions in your design
The Exception Hierarchy Strategy
classDiagram
class BaseException {
-message: String
-errorCode: String
}
class BusinessException {
+Recoverable domain errors
}
class SystemException {
+Infrastructure failures
}
class InsufficientBalanceException {
-available: Money
-required: Money
}
class ProductNotFoundException {
-productId: String
}
class DatabaseUnavailableException {
-retryAfter: Duration
}
BaseException <|-- BusinessException
BaseException <|-- SystemException
BusinessException <|-- InsufficientBalanceException
BusinessException <|-- ProductNotFoundException
SystemException <|-- DatabaseUnavailableException
Two categories:
- Business exceptions: Invalid domain operations (insufficient funds, slot taken, invalid state transition). The caller can potentially recover.
- System exceptions: Infrastructure failures (DB down, timeout, disk full). Usually unrecoverable at the application level.
Custom Exceptions Done Right
// Base exception for the domain
public abstract class WalletException extends RuntimeException {
private final String errorCode;
protected WalletException(String message, String errorCode) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() { return errorCode; }
}
// Specific: carries context about what went wrong
public class InsufficientBalanceException extends WalletException {
private final Money available;
private final Money required;
public InsufficientBalanceException(Money available, Money required) {
super(String.format("Insufficient balance: have %s, need %s", available, required),
"WALLET_001");
this.available = available;
this.required = required;
}
public Money getAvailable() { return available; }
public Money getRequired() { return required; }
public Money getDeficit() { return required.subtract(available); }
}
public class DuplicateTransactionException extends WalletException {
private final String transactionId;
public DuplicateTransactionException(String transactionId) {
super("Transaction already processed: " + transactionId, "WALLET_002");
this.transactionId = transactionId;
}
public String getTransactionId() { return transactionId; }
}
public class WalletFrozenException extends WalletException {
private final String reason;
private final Instant frozenSince;
public WalletFrozenException(String walletId, String reason, Instant frozenSince) {
super("Wallet " + walletId + " is frozen: " + reason, "WALLET_003");
this.reason = reason;
this.frozenSince = frozenSince;
}
}
// Usage: meaningful, catchable, debuggable
public class WalletService {
public void debit(String walletId, Money amount, String idempotencyKey) {
Wallet wallet = repository.findById(walletId)
.orElseThrow(() -> new WalletNotFoundException(walletId));
if (wallet.isFrozen()) {
throw new WalletFrozenException(walletId, wallet.getFreezeReason(), wallet.getFrozenAt());
}
if (transactionLog.exists(idempotencyKey)) {
throw new DuplicateTransactionException(idempotencyKey);
}
if (wallet.getBalance().isLessThan(amount)) {
throw new InsufficientBalanceException(wallet.getBalance(), amount);
}
wallet.debit(amount);
transactionLog.record(idempotencyKey, amount);
}
}
class WalletError(Exception):
"""Base exception for wallet domain."""
def __init__(self, message: str, error_code: str):
super().__init__(message)
self.error_code = error_code
class InsufficientBalanceError(WalletError):
def __init__(self, available: Decimal, required: Decimal):
self.available = available
self.required = required
self.deficit = required - available
super().__init__(
f"Insufficient balance: have {available}, need {required}",
"WALLET_001"
)
class DuplicateTransactionError(WalletError):
def __init__(self, transaction_id: str):
self.transaction_id = transaction_id
super().__init__(
f"Transaction already processed: {transaction_id}",
"WALLET_002"
)
class WalletFrozenError(WalletError):
def __init__(self, wallet_id: str, reason: str):
self.wallet_id = wallet_id
self.reason = reason
super().__init__(f"Wallet {wallet_id} frozen: {reason}", "WALLET_003")
class WalletNotFoundError(WalletError):
def __init__(self, wallet_id: str):
self.wallet_id = wallet_id
super().__init__(f"Wallet not found: {wallet_id}", "WALLET_004")
# Usage
class WalletService:
def debit(self, wallet_id: str, amount: Decimal, idempotency_key: str):
wallet = self._repo.find_by_id(wallet_id)
if wallet is None:
raise WalletNotFoundError(wallet_id)
if wallet.is_frozen:
raise WalletFrozenError(wallet_id, wallet.freeze_reason)
if self._tx_log.exists(idempotency_key):
raise DuplicateTransactionError(idempotency_key)
if wallet.balance < amount:
raise InsufficientBalanceError(wallet.balance, amount)
wallet.debit(amount)
self._tx_log.record(idempotency_key, amount)
#include <stdexcept>
#include <string>
class WalletException : public std::runtime_error {
string errorCode_;
public:
WalletException(const string& message, const string& code)
: runtime_error(message), errorCode_(code) {}
const string& errorCode() const { return errorCode_; }
};
class InsufficientBalanceException : public WalletException {
double available_, required_;
public:
InsufficientBalanceException(double available, double required)
: WalletException(
"Insufficient balance: have " + to_string(available) +
", need " + to_string(required), "WALLET_001"),
available_(available), required_(required) {}
double available() const { return available_; }
double required() const { return required_; }
double deficit() const { return required_ - available_; }
};
class WalletFrozenException : public WalletException {
string walletId_, reason_;
public:
WalletFrozenException(const string& walletId, const string& reason)
: WalletException("Wallet " + walletId + " frozen: " + reason, "WALLET_003"),
walletId_(walletId), reason_(reason) {}
};
// Usage
void WalletService::debit(const string& walletId, double amount, const string& key) {
auto wallet = repo_->findById(walletId);
if (!wallet) throw WalletNotFoundException(walletId);
if (wallet->isFrozen()) throw WalletFrozenException(walletId, wallet->freezeReason());
if (wallet->balance() < amount)
throw InsufficientBalanceException(wallet->balance(), amount);
wallet->debit(amount);
}
Error Handling Anti-Patterns
| Anti-Pattern | Why Itโs Bad | Fix |
|---|---|---|
catch (Exception e) {} |
Silently swallows errors | Catch specific exceptions, log or rethrow |
throw new RuntimeException("error") |
No context, uncatchable selectively | Custom exception with relevant data |
| Returning null on failure | Caller forgets to check, NPE later | Throw exception or return Optional |
| Wrapping every line in try/catch | Clutters code, hides flow | Let exceptions propagate, catch at boundaries |
| Using exceptions for flow control | Expensive, hard to follow | Use conditionals for expected cases |
Checked vs Unchecked (Java-Specific)
| Checked (extends Exception) | Unchecked (extends RuntimeException) |
|---|---|
| Caller forced to handle or declare | Caller not forced to handle |
| Use for recoverable conditions | Use for programming errors and unrecoverable failures |
| Makes API contract explicit | Keeps code clean |
| Example: IOException, SQLException | Example: NullPointerException, IllegalArgumentException |
In LLD interviews: Use unchecked (RuntimeException) for domain exceptions. Checked exceptions add noise in a 90-minute session.
When to Use vs When to Avoid Exceptions
| Use Exceptions For | Use Return Values For |
|---|---|
| Unexpected, exceptional situations | Expected alternative outcomes |
| Errors that require caller attention | Optional results (empty list, null/None) |
| Contract violations | Boolean success/failure for simple ops |
| Cross-layer error propagation | Performance-critical hot paths |
Interview Questions
-
โHow many custom exceptions should you create?โ โ One per distinct error condition that callers need to handle differently. In a wallet system: insufficient funds, wallet frozen, duplicate transaction. Donโt create one for every possible failure mode.
-
โShould you include context data in exceptions?โ โ Yes.
InsufficientBalanceExceptionshould carryavailableandrequiredamounts so the caller can provide useful error messages or make decisions. -
โWhere should you catch exceptions?โ โ At domain boundaries: API layer catches domain exceptions and converts to HTTP status codes. Service layer catches infrastructure exceptions and wraps them in domain exceptions. Donโt catch in the middle of business logic unless you can meaningfully handle it.
-
โException vs return code?โ โ Exceptions for unexpected failures that interrupt normal flow. Return codes (or Result types) for expected alternative outcomes. Donโt use exceptions for expected โnot foundโ cases that happen frequently.
See It in Action
Exception design is part of every LLD problem. Payment Wallet and Vending Machine demonstrate custom exception hierarchies.