Exception Handling (Framework Level)
Throw domain errors where problems are detected and translate them to HTTP in one place. Watch a known domain error become a clean 404 and an unexpected bug become a logged 500 with no leaked internals.
🎯 What you'll learn
- Why an uncaught throw breaks responses
- Global exception filters / error middleware
- Custom domain exception classes
- Global vs route-scoped handling and never leaking internals
📚 Before you start
This topic assumes familiarity with backend/frameworks/06-middleware (the request-handling chain an exception filter hooks into) and connects to backend/api-design/09-error_response_design, which defines the shape an error response should have. This topic is about the framework mechanism that catches a thrown error anywhere in the request-handling chain and converts it into that shape — it does not re-cover the response-shape design itself.
1The Problem: an Uncaught Throw Anywhere Crashes the Response
A request handler is just a function call, several layers deep (middleware -> routing -> controller -> service -> repository). Without a framework-level mechanism, a thrown error inside a service ten calls deep has to be manually caught at every intermediate layer, or it propagates all the way up and either crashes the process or leaves the HTTP response hanging with no status code ever sent:
// Without a global handler: every route must remember to try/catch, by hand
app.get("/orders/:id", async (req, res) => {
try {
const order = await orderService.findById(req.params.id);
res.json(order);
} catch (err) {
res.status(500).json({ error: "something went wrong" }); // easy to forget, copy-pasted everywhere
}
});Repeating this try/catch in every single handler is both tedious and, in practice, inconsistently applied — some handler somewhere inevitably forgets it.
2Global Exception Filters/Middleware
A framework-level exception handler sits at the edge of the request-handling chain and catches whatever an inner layer throws, exactly once, in one place:
// Express: error-handling middleware is recognized by its 4-argument signature
function globalErrorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
if (err instanceof NotFoundError) return res.status(404).json({ error: err.message });
if (err instanceof ValidationError) return res.status(400).json({ error: err.message });
console.error(err); // anything unrecognized: log it, never leak internals to the client
res.status(500).json({ error: "internal server error" });
}
app.use(routes);
app.use(globalErrorHandler); // registered LAST — Express routes any thrown/next(err) error here// NestJS: an @Catch() exception filter serves the same role
@Catch()
class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse();
if (exception instanceof NotFoundException) {
return res.status(404).json({ error: exception.message });
}
res.status(500).json({ error: "internal server error" });
}
}Now every handler can simply throw a meaningful error and trust that something, in exactly one place, converts it into the right HTTP response — no handler needs its own try/catch for the common case.
3Custom Exception Classes for Domain Errors
Throwing a plain Error everywhere gives the global handler nothing to distinguish "this resource doesn't exist" from "this input was invalid" from "something genuinely broke." Custom exception classes carry that distinction as a type the handler can branch on:
class NotFoundError extends Error {
constructor(resource: string, id: string | number) {
super(`${resource} ${id} not found`);
}
}
class ValidationError extends Error {
constructor(message: string, public readonly field?: string) {
super(message);
}
}
class ForbiddenError extends Error {}
// Deep inside a service, far from any HTTP concern:
async function findOrder(id: number) {
const order = await orderRepo.findById(id);
if (!order) throw new NotFoundError("Order", id); // the SERVICE doesn't know or care about HTTP status codes
return order;
}Critically, the service layer throws domain-meaningful errors — NotFoundError, ValidationError — without importing anything HTTP-specific. The mapping from "which error class" to "which HTTP status code" is the global handler's job, and only its job; this keeps the service layer (backend/frameworks/10-services_and_repositories) ignorant of the transport it happens to be running behind, which matters if the same service is ever called from a non-HTTP context (a background job, a CLI script).
4Global vs. Route-Scoped Handling
Most exception handling should be global — one handler, one mapping table from error class to status code, applied uniformly. A route-scoped (or controller-scoped) filter is the exception, reserved for genuinely local behavior a small subset of endpoints need that shouldn't apply everywhere:
@Controller("webhooks")
@UseFilters(WebhookExceptionFilter) // only THIS controller's routes use this filter
class WebhookController { /* ... */ }Reaching for a route-scoped filter as the default (one custom filter per controller) reproduces the exact duplication problem the global handler exists to solve — it should be the deliberate exception, not the norm.
5What the Handler Must Never Do: Leak Internals
The one universal rule for the "unrecognized error" fallback branch (section 2's console.error(err) case) is that the response sent to the client must never include the raw error message, stack trace, or any internal detail — only a generic message. The full error belongs in the server's logs (see devops-cloud/observability/01-logging), not in the HTTP response body, since a stack trace can reveal file paths, library versions, or query structure useful to an attacker.
⚠️ Common Pitfalls
- Wrapping every single handler in its own try/catch "to be safe." Reintroduces the exact duplication a global handler exists to eliminate, and inconsistency creeps in the moment one handler is written without it.
- Throwing plain
Errorinstead of a typed exception class. The global handler has nothing to branch on beyond string-matching a message, which is fragile and easy to get wrong as messages change. - Letting the service layer import HTTP-specific exception types (e.g. a
NotFoundExceptiontied to a specific framework's HTTP module). Couples business logic to a transport it may not always run behind; a plain domain-levelNotFoundErrorclass, mapped to a status code only in the handler, avoids this. - Leaking the raw error or stack trace in the response body for unrecognized errors. Section 5 — always log the full detail server-side, always send a generic message to the client for anything not explicitly recognized.
- Defaulting to route-scoped filters instead of one global handler. Fragments the error-handling logic across every controller instead of keeping one consistent mapping in one place.
📋 Quick Reference
Global exception handler -> ONE place that catches anything thrown, maps it to a response
Custom exception classes -> NotFoundError, ValidationError, etc. — typed, HTTP-agnostic
Service layer -> throws domain errors, never imports HTTP-specific exception types
Handler's job -> map error class -> HTTP status code + safe response body
Unrecognized error -> log full detail server-side, send ONLY a generic message to the client
Route-scoped filter -> the deliberate exception, not the default🧠 Key takeaways
- Unhandled errors must never crash the process or leave requests hanging: use one global handler.
- Throw domain-specific exceptions (
OrderNotFound,InsufficientFunds) from services; map them to HTTP in the filter. - Unknown errors → 500 + log stack + traceId; known errors → precise 4xx.
- Never send stack traces, SQL or internal messages to clients.
🎤 Interview Q&A
Q1How should a backend handle exceptions globally?core▾
A global exception handler/middleware/filter catches unhandled exceptions, logs them with context and trace ID, and maps them to consistent error responses (domain exceptions → 4xx, unexpected → generic 500).
- ASP.NET Core 8 feature?
IExceptionHandlerandAddProblemDetails().
Q2Exceptions vs result types for expected failures?core▾
Exceptions for unexpected failures; result/Either types for expected business outcomes (validation failed, not found, insufficient funds) make error paths explicit and avoid exceptions as control flow. Many teams use domain exceptions mapped centrally as a pragmatic middle ground.
- Performance note? Exceptions are relatively expensive in .NET/Java; avoid them in hot paths.
Q3What should and shouldn't be logged for errors?core▾
Log the exception with stack trace, trace ID, route, user/tenant ID and relevant parameters. Never log passwords, tokens, card numbers or personal data; scrub sensitive fields.
- Which level for 4xx? Usually warning or info; 5xx as error.
Q4How do you handle errors in async/background code?senior▾
Wrap background job bodies with try/catch and logging, use retry policies, ensure unhandled promise rejections/task exceptions are captured (process-level handlers), and send poison messages to DLQs.
- Node.js process-level handlers?
unhandledRejectionanduncaughtException: log and exit/restart, don't continue blindly.
Q5Should you catch and rethrow exceptions?core▾
Only to add context (wrap with a more meaningful exception preserving the inner one) or to handle them. Catch-log-rethrow at every layer creates duplicate logs; catching generic exceptions and ignoring them hides bugs.
- C# rethrow pitfall?
throw ex;resets the stack trace; usethrow;.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.