# Exception Handling (Framework Level)

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.

## 1. The 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:

```ts
// 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.

## 2. Global 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:

```ts
// 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
```

```ts
// 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.

![Errors thrown from any layer reach one global exception filter that maps known error types to status codes and returns a safe body with a request ID](diagrams/01-global-error-mapping.svg)

## 3. Custom 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:

```ts
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).

## 4. Global 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:

```ts
@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.

## 5. What 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

1. **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.
2. **Throwing plain `Error` instead 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.
3. **Letting the service layer import HTTP-specific exception types (e.g. a `NotFoundException` tied to a specific framework's HTTP module).** Couples business logic to a transport it may not always run behind; a plain domain-level `NotFoundError` class, mapped to a status code only in the handler, avoids this.
4. **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.
5. **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
```
