Exception Handling
What exceptions are, checked vs unchecked, designing domain exceptions, central handlers in Laravel and Spring Boot, database rollbacks, error boundaries in React, retries and dead-letter topics in Kafka, and what to log.
Running examples: insufficient balance, payment gateway timeout, out-of-stock at checkout
1. What an exception is and how to classify it
An exception is an object that represents a failure and interrupts normal flow. It travels up the call stack until code that knows how to handle it catches it. If nothing catches it, the framework's global handler turns it into an error response.
Classify every failure before deciding how to handle it. Business rule violations (insufficient balance, coupon expired) are expected: return a clear 4xx message, do not wake anyone up. Transient technical failures (gateway timeout, deadlock) may succeed if retried. Bugs and infrastructure failures (null pointer, database down) need logs, alerts and a generic 5xx message.
| Example | Type | Response | Log/alert | Retry? |
|---|---|---|---|---|
| Balance Rs 500, transfer Rs 2,000 | Business | 422 INSUFFICIENT_BALANCE | Info | No |
| Product went out of stock during checkout | Business / concurrency | 409 OUT_OF_STOCK | Info | No |
| Razorpay timed out | Transient | 503 or queue a retry | Warning | Yes, with backoff and idempotency key |
| MySQL deadlock 1213 | Transient | Retry the transaction | Warning | Yes, 2–3 times |
| Undefined index in code | Bug | 500 generic | Error + alert | No |
2. Laravel: domain exceptions, transactions and the handler
Transfer with rollback on any failure
class TransferService
{
public function transfer(Account $from, Account $to, int $amountPaise): Transfer
{
return DB::transaction(function () use ($from, $to, $amountPaise) {
// lock both rows in a fixed order to avoid deadlocks
[$a, $b] = $from->id < $to->id ? [$from, $to] : [$to, $from];
Account::whereIn('id', [$a->id, $b->id])->lockForUpdate()->get();
$from->refresh();
if ($from->balance_paise < $amountPaise) {
throw new InsufficientBalanceException($from, $amountPaise); // transaction rolls back
}
$from->decrement('balance_paise', $amountPaise);
$to->increment('balance_paise', $amountPaise);
return Transfer::create(['from_id' => $from->id, 'to_id' => $to->id, 'amount_paise' => $amountPaise]);
}, attempts: 3); // retries deadlocks
}
}
Domain exception that renders itself
class InsufficientBalanceException extends DomainException
{
public function __construct(public Account $account, public int $requested)
{
parent::__construct('Insufficient balance.');
}
public function render(Request $request): JsonResponse
{
return response()->json(['success' => false, 'code' => 'INSUFFICIENT_BALANCE', 'message' => $this->getMessage()], 422);
}
public function report(): bool { return false; } // expected; do not send to Sentry
}
// app/Exceptions/Handler.php (Laravel 9/10)
public function register(): void
{
$this->reportable(fn (GatewayTimeoutException $e) => Log::channel('payments')->warning('gateway timeout', ['order' => $e->orderId]));
$this->renderable(fn (ModelNotFoundException $e, $request) => $request->expectsJson()
? response()->json(['success' => false, 'code' => 'NOT_FOUND', 'message' => 'Record not found.'], 404) : null);
}
Catch only where you can recover or add meaning. Wrapping every controller in try { } catch (Exception $e) { return "error"; } hides bugs and loses stack traces. Converting a vendor exception into your own (GatewayTimeoutException with previous: $e) is good: the rest of the app no longer depends on Guzzle or a payment SDK.
3. Spring Boot: checked vs unchecked and @RestControllerAdvice
Java has checked exceptions (must be declared or caught, such as IOException) and unchecked ones (RuntimeException and subclasses). Business exceptions are usually unchecked. @Transactional rolls back on unchecked exceptions by default but NOT on checked ones unless you add rollbackFor, a classic interview trap.
Global handler
public class InsufficientBalanceException extends RuntimeException {
public InsufficientBalanceException(String accountNo) { super("Insufficient balance in " + accountNo); }
}
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(InsufficientBalanceException.class)
ResponseEntity<ApiError> balance(InsufficientBalanceException e) {
return ResponseEntity.unprocessableEntity().body(new ApiError("INSUFFICIENT_BALANCE", e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiError> validation(MethodArgumentNotValidException e) {
return ResponseEntity.badRequest().body(ApiError.fromFieldErrors(e.getBindingResult()));
}
@ExceptionHandler(Exception.class)
ResponseEntity<ApiError> unexpected(Exception e) {
log.error("Unhandled", e); // full stack trace in logs only
return ResponseEntity.internalServerError().body(new ApiError("SERVER_ERROR", "Something went wrong"));
}
}
@Transactional(rollbackFor = Exception.class) // roll back on checked exceptions too
public void transfer(...) { ... }
4. React error boundaries and Kafka retries
React: show a fallback instead of a white screen
class ErrorBoundary extends React.Component {
state = { failed: false };
static getDerivedStateFromError() { return { failed: true }; }
componentDidCatch(error, info) { reportToSentry(error, info); }
render() { return this.state.failed ? <p>Could not load your orders. <a href="">Retry</a></p> : this.props.children; }
}
// API errors are not render errors: handle them where you call fetch
const res = await api('/orders', { method: 'POST', body });
if (res.status === 422) setErrors((await res.json()).errors);
else if (res.status === 409) toast('Item went out of stock');
Kafka consumers cannot return an error to a caller, so failures are handled with retries and a dead-letter topic (DLT). A payment-events consumer retries a message a few times with backoff; if it still fails (for example the message is malformed) it is published to payments.DLT, alerting the team, so the partition is not blocked forever.
Because a consumer may process the same message twice after a retry or rebalance, every handler must be idempotent: check the event id in a processed_events table before applying it.
Spring Kafka retry + dead-letter topic
@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<String, Object> template) {
var recoverer = new DeadLetterPublishingRecoverer(template); // sends to <topic>.DLT
var handler = new DefaultErrorHandler(recoverer, new ExponentialBackOff(1000, 2.0));
handler.addNotRetryableExceptions(InvalidPaymentEventException.class); // bad data: no point retrying
return handler;
}
5. Interview questions
What should a 500 response contain in production?
A generic message and a reference id. Stack traces, SQL and file paths go to logs only (APP_DEBUG=false).
Does @Transactional roll back on IOException?
No, checked exceptions do not trigger rollback by default; use rollbackFor.
How do you avoid double charging when retrying a gateway call?
Send an idempotency key the gateway honours, and check payment status before retrying.
report() vs render() in Laravel?
report decides logging/alerting; render decides the HTTP response.
