Where does this backend concern belong?
When I first worked with NestJS, middleware, guards, pipes, interceptors, and exception filters felt like a list of framework features to remember. They all seemed capable of running code around a request, so it was not always obvious where a new piece of logic should go.
What has helped me is to begin with the concern itself, rather than the decorator. Is this about preparing every request, deciding whether a route may run, shaping input, adding behaviour around a handler, or returning a consistent error? The answer usually narrows the choice quickly.
| The concern | Usually fits |
|---|---|
| Prepare every request: context, CORS, simple logging | Middleware |
| May this particular handler run? | Guard |
| Validate or transform the handler’s input | Pipe |
| Wrap the handler: timing, response mapping, caching | Interceptor |
| Turn a thrown error into a consistent response | Exception filter |
Start with what the code needs to know
Middleware is useful for work that applies broadly before the route is known, such as attaching request context, handling CORS, or simple request logging. It can see the incoming request, but it does not have the same route-level context as a guard.
A guard becomes a better fit when the question is whether this particular handler should run. Authentication and authorisation are common examples, because a guard can inspect the execution context and the metadata attached to the route.
This distinction has kept me from putting every cross-cutting concern into middleware. If the decision depends on the route’s permission, policy, or intent, keeping it close to that route through a guard usually makes the reason easier to understand.
Keep input handling at the boundary
Pipes are a useful place to validate or transform the arguments that are about to reach a controller handler. They can turn a route parameter into the type the handler expects, or reject a request body before business logic starts.
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
// by the time we get here, id is a number, or the request was already rejected
return this.orders.findOne(id);
}I find this especially helpful because it keeps services focused on valid application input. A service can still protect its own business rules, but it does not need to repeatedly parse a string ID or decide whether a malformed request body should become an HTTP error.
That does not mean every validation rule belongs in a pipe. Rules that depend on current business state often belong deeper in the application. The boundary check and the business decision are related, but they are not always the same thing.
Use interceptors for work around the handler
Interceptors are useful when behaviour needs to wrap the route handler. They can run logic before the handler, observe or transform the result after it returns, and apply a reusable policy such as logging, response mapping, caching, or timing.
The word “around” has been a useful mental model for me. If the work needs the handler’s result, an interceptor may be a better fit than middleware. It also avoids making a controller method carry the same supporting logic again and again.
As with any global behaviour, I try to keep interceptors focused. A small response-formatting or timing concern is easy to understand. An interceptor that quietly holds business rules can make the request path harder to trace.
Give failures a consistent way out
Exception filters are where error handling can become more deliberate. They can turn exceptions into a response shape that clients can understand, while preserving enough context for logs and investigation.
For me, the goal is not to catch every error as early as possible. It is to let errors reach the layer that can explain them consistently. A domain or service can throw a meaningful exception, while an exception filter makes sure the HTTP response follows the application’s contract.
These boundaries will not answer every design question automatically. They have given me a simpler way to reason about a NestJS request: prepare it, decide whether it may proceed, validate its input, wrap the handler only when needed, and make failure understandable when it happens.
Working on something similar? Let's talk