Skip to main content
Every HTTP request in nestrs passes through a layered pipeline before it reaches your handler. The pipeline runs cross-cutting concerns — authorization, logging, validation, error mapping — without cluttering handler code. Understanding the order of these layers is essential for reasoning about what happens when a guard fails, an interceptor logs a request, or a filter rewrites an error response.

Pipeline order

For a single matched route, nestrs composes Axum middleware in this sequence (outermost to innermost on the incoming path):
Client
  → [global middleware stack]
  → [exception filters (outer → inner)]
  → [controller guard, if any]
  → [route guards: G1, G2, …]
  → [interceptors (outer → inner)]
  → Handler + Axum extractors
  → Response
On the response path, the layers unwind in reverse: the handler produces a response, interceptors wrap it, filters catch any HttpException values, and the global stack applies final transformations (CORS headers, compression, production error sanitization).
NestJS teaches guards → interceptors → pipes → handler. nestrs respects a similar model but the exact nesting differs — exception filters are outermost. Refer to this page as the authoritative contract for nestrs, not a line-for-line NestJS clone.

Guards — CanActivate

Guards run before the handler and decide whether the request is allowed to proceed. Implement the CanActivate trait:
use nestrs::prelude::*;
use axum::http::request::Parts;

#[derive(Default)]
pub struct AuthGuard;

#[async_trait]
impl CanActivate for AuthGuard {
    async fn can_activate(&self, parts: &Parts) -> Result<(), GuardError> {
        let token = parts
            .headers
            .get("authorization")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("");

        if token.starts_with("Bearer valid-") {
            Ok(())
        } else {
            Err(GuardError::unauthorized("Invalid or missing bearer token"))
        }
    }
}
GuardError produces a JSON error response:
  • GuardError::unauthorized(message) → 401 Unauthorized
  • GuardError::forbidden(message) → 403 Forbidden

Applying guards

Declare guards per route inside impl_routes! with the with (G1, G2) syntax. Guards are evaluated left-to-right; the first failure short-circuits.
impl_routes! {
    state = AppService;
    GET "/protected" with (AuthGuard) => AppController::protected,
    GET "/public"    with ()          => AppController::public,
}

Pipes — PipeTransform

Pipes transform or validate a single value before it reaches the handler. nestrs includes two built-in pipes:
  • ParseIntPipe — parses a decimal string into i64
  • ValidationPipe — runs validator::Validate on a struct
Implement PipeTransform for custom pipes:
use nestrs::prelude::*;

#[derive(Default)]
pub struct TrimPipe;

#[async_trait]
impl PipeTransform<String> for TrimPipe {
    type Output = String;
    type Error = HttpException;

    async fn transform(&self, value: String) -> Result<Self::Output, Self::Error> {
        Ok(value.trim().to_owned())
    }
}

Applying pipes with #[use_pipes]

#[use_pipes(ValidationPipe)] switches #[param::body], #[param::query], and #[param::param] wiring to ValidatedBody, ValidatedQuery, and ValidatedPath extractors, which run validation at extraction time:
#[routes(state = AppService)]
impl AppController {
    #[post("/users")]
    #[use_pipes(ValidationPipe)]
    pub async fn create_user(
        State(service): State<Arc<AppService>>,
        ValidatedBody(dto): ValidatedBody<CreateUserDto>,
    ) -> Result<Json<UserResponse>, HttpException> {
        Ok(Json(service.create_user(dto)))
    }
}
You can also call PipeTransform::transform directly in handler code without the macro:
let id = ParseIntPipe.transform(raw_id).await?;

Interceptors — Interceptor

Interceptors provide around-advice: they run logic before calling next.run(req) (pre-handler) and can inspect or modify the response after (post-handler). The built-in LoggingInterceptor shows the pattern:
use nestrs::prelude::*;
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;

#[derive(Default)]
pub struct LoggingInterceptor;

#[async_trait]
impl Interceptor for LoggingInterceptor {
    async fn intercept(&self, req: Request, next: Next) -> Response {
        let method = req.method().clone();
        let path = req.uri().path().to_owned();
        let start = std::time::Instant::now();
        let response = next.run(req).await;
        tracing::debug!(
            target: "nestrs::interceptor",
            method = %method,
            path = %path,
            status = %response.status(),
            elapsed_ms = start.elapsed().as_millis(),
            "request"
        );
        response
    }
}

Applying interceptors

#[get("/items")]
#[use_interceptors(LoggingInterceptor)]
pub async fn list_items(State(svc): State<Arc<ItemService>>) -> Json<Vec<Item>> {
    Json(svc.list())
}
The first interceptor listed in #[use_interceptors(I1, I2, …)] is the outermost Tower layer — it sees the request first and wraps next. On the response path, I1 runs last (outermost on the way back out).

Exception filters — ExceptionFilter

Exception filters intercept HttpException responses before they reach the client. Implement ExceptionFilter to rewrite error responses — for example, to translate error codes, add correlation IDs, or format errors for a specific API contract:
use nestrs::prelude::*;
use axum::response::Response;

pub struct AppExceptionFilter;

#[async_trait]
impl ExceptionFilter for AppExceptionFilter {
    async fn catch(&self, ex: HttpException) -> Response {
        // Re-map or enrich the response before it reaches the client.
        let body = serde_json::json!({
            "error": ex.status.as_u16(),
            "message": ex.message,
        });
        (ex.status, Json(body)).into_response()
    }
}

Applying exception filters

#[get("/guarded")]
#[use_filters(AppExceptionFilter)]
pub async fn guarded(State(svc): State<Arc<AppService>>) -> Result<String, HttpException> {
    svc.do_work().map_err(InternalServerErrorException::new)
}
The first filter in #[use_filters(F1, F2, …)] is the outermost Tower layer. When an HttpException response bubbles up, the innermost filter (closest to the handler) catches it first, then the next filter outward.

Global middleware stack

Beyond per-route cross-cutting concerns, nestrs assembles a global middleware stack in NestApplication::build_router. The layers (from inner to outer on the incoming request) include:
  • Optional global exception filter
  • CORS
  • Security headers
  • Rate limiting
  • Timeouts
  • Request ID injection
  • Compression and request decompression
  • CSRF (when enabled)
  • Cookie and session layers
  • Your custom use_global_layer callbacks (outermost)
NestFactory::create::<AppModule>()
    .enable_cors(CorsOptions::permissive())
    .use_security_headers(SecurityHeaders::default())
    .use_rate_limit(RateLimitOptions::builder().max_requests(200).build())
    .use_global_layer(|router| router.layer(interceptor_layer!(LoggingInterceptor)))
    .listen_graceful(3000)
    .await;
Each .layer(...) call in Axum wraps outside the existing stack, so later use_global_layer calls produce layers that are more outer on the incoming request. Do not rely on undocumented ordering between unrelated third-party layers; write integration tests if you need a guaranteed sequence.