Back to writing

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.

request →Middlewareevery requestGuardmay it run?InterceptorbeforePipeshape inputHandlerthe routeInterceptorafter→ responseException filterturns anything thrown along the way into a consistent response
The order a request passes through, simplified from the NestJS docs on the request lifecycle.
The concernUsually fits
Prepare every request: context, CORS, simple loggingMiddleware
May this particular handler run?Guard
Validate or transform the handler’s inputPipe
Wrap the handler: timing, response mapping, cachingInterceptor
Turn a thrown error into a consistent responseException 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