Interceptors
An interceptor wraps the handler. It sees the request before the handler
runs, holds the response after, and in between calls next.run(req) to
delegate to whatever sits inside it (another interceptor, eventually the
handler). One call, both sides of the boundary.
Interceptor is a Layer sub-trait shipped by
nest-rs-interceptors.
The HTTP continuation builds on poem’s
endpoint chain; the GraphQL one on
async-graphql’s resolver
context. One impl, three transports — override the method(s) the
interceptor targets, the rest inherit a pass-through default.
#[async_trait]pub trait Interceptor: Layer { /// HTTP entry — required. Per-route, runs once per request. async fn intercept(&self, req: Request, next: Next<'_>) -> Result<Response>;
/// GraphQL per-resolver-call entry. Default just awaits `next`. async fn wrap_graphql<'a>(&self, ctx: &GraphqlContext<'a>, next: GraphqlNext<'a>) -> ServerResult<GraphqlValue> { next.await }
/// WS per-message entry. Default just awaits `next`. async fn wrap_ws<'a>(&self, client: &WsClient, event: &str, data: &Value, next: WsNext<'a>) -> Result<Option<Value>, String> { next.await }}Same vocabulary as a guard, different shape:
- A guard runs before the handler and returns yes/no.
- An interceptor runs around the handler — it can install ambient state for the handler’s duration, add response headers, time the call, retry, transform the body.
It is the seam where the database transaction lives, where response masking lives, where tracing spans wrap a request.
A minimal interceptor
Section titled “A minimal interceptor”use nest_rs_core::{Layer, injectable};use nest_rs_interceptors::{Interceptor, Next};use poem::{async_trait, Request, Response, Result};
#[injectable]#[derive(Default)]pub struct Tracer;
impl Layer for Tracer {}
#[async_trait]impl Interceptor for Tracer { async fn intercept(&self, req: Request, next: Next<'_>) -> Result<Response> { let mut resp = next.run(req).await?; resp.headers_mut() .insert("x-trace", "hit".parse().unwrap()); Ok(resp) }}A plain #[injectable] provider that implements Interceptor. Listed
in a module’s providers = [...], then bound globally, per controller,
or per handler.
The three scopes
Section titled “The three scopes”| Scope | Binding | Resolved by |
|---|---|---|
| Global | App::builder().use_interceptors_global([interceptor::<Tracer>()]) in main | A transport-level HttpInterceptorMeta wrap + the per-route shaper |
| Controller / Resolver / Gateway | #[use_interceptors(Tracer)] on the struct | The container, at mount |
| Per-handler | #[use_interceptors(Tracer)] beside a verb / #[query] / #[subscribe_message] | The container, at mount |
#[controller(path = "/api")]#[use_interceptors(Tracer)] // controller scopepub struct ApiController { #[inject] svc: Arc<ApiService>,}
#[routes]impl ApiController { #[get("/cache")] #[use_interceptors(CacheControl)] // handler scope async fn cached(&self) -> Json<Snapshot> { /* ... */ }}Multiple interceptors in one attribute run outermost-first: the first listed sees the request before the second, and its response wraps the second’s.
TypeId dedup — redeclaration is free
Section titled “TypeId dedup — redeclaration is free”Like every other Layer, an Interceptor declared
globally and redeclared on the controller or method runs exactly
once per request — the transport-level HttpInterceptorMeta wrap
carries the single execution, the inner wraps are skipped at mount
time. The full rationale and the cross-transport wiring details live
on the guards page; the same rules apply here.
See Guards / Declare on the provider for the worked example and the defense-in-depth argument.
Auto-discovered global interceptors
Section titled “Auto-discovered global interceptors”For infrastructure that must wrap everything, write #[interceptor]
instead of #[injectable] + impl Interceptor. The framework discovers
it, builds it, and folds it around the assembled route tree in
registration order — no #[use_interceptors] needed anywhere.
#[interceptor]pub(crate) struct DbContext { #[inject] db: Arc<DatabaseConnection>,}
#[async_trait]impl Interceptor for DbContext { async fn intercept(&self, req: Request, next: Next<'_>) -> Result<Response> { if is_safe(req.method()) { return with_request_executor(Executor::Pool(self.db.clone()), next.run(req)).await; } let txn = Arc::new(self.db.begin().await?); let result = with_request_executor(Executor::Txn(txn.clone()), next.run(req)).await; commit_or_rollback(txn, &result).await?; result }}That’s the entire transactional boundary. Import DatabaseModule, the
interceptor is auto-mounted, and every handler runs inside a transaction
on a mutating verb, on the pool on a safe verb. No #[use_interceptors]
on any controller.
The request-layer ordering
Section titled “The request-layer ordering”Interceptors participate at two levels. The full picture — per-route
nesting, global priority bands, RouteShaper, and where
#[use_filters] vs #[use_exception_filters] sit — is on
Guards / Ordering inside a route.
Global interceptors (including auto-mounted DbContext) wrap
outside global guards. That’s how the executor is visible when guards
run:
DbContextruns — opens a transaction for mutating verbs, installs the executor in atask_local!, then callsnext.run(req).- Guards run inside it —
AuthGuardattachesClaims,AuthzGuardbuildsAbility. Both see the same executor. - Handler runs —
Repo::scoped(...)reads both ambients. - Response returns —
DbContextcommits on 2xx/3xx, rolls back otherwise.
Per-route interceptors (#[use_interceptors] on the controller or
beside a verb) nest inside the RouteShaper guard chain — closer to
the handler than the guards. A guard denial returns before those
interceptors’ pre-handler work runs.
Per-route, inner → outer (from the #[routes] macro):
handler→ ability shaper→ per-route interceptors→ per-route filters (error path only)→ RouteShaper (guards + pipes)→ `#[meta]` / `#[public]` (route data)Common things an interceptor does
Section titled “Common things an interceptor does”- Install ambient state for the handler’s duration (transactions, tracing spans, request id, authorization ability).
- Transform the response — add headers (
Server-Timing,X-Request-Id), compress, paginate. - Time the call — measure latency by route, emit a metric.
- Mask the body —
nest-rs-authz’sAuthorizeshaper is technically a “shaper” (inside the interceptors), but the pattern is the same: see the response, decide what to send.
The pattern: capture what you need from req, await next.run(req) to
get the response, decide what to do with it.
Going further
Section titled “Going further”- Guards — the sibling layer, runs after interceptors install their state.
- Database / transactions —
DbContextend to end. - Security / response masking — the
Authorizeshaper composes with this seam. - HTTP / controllers — per-route bindings.