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.
Where interceptors run in the chain
Section titled “Where interceptors run in the chain”Interceptors are pooled by TypeId and run once per request: a global interceptor folds at the transport edge (band 90, before auth — it sees 404s and denials); a scoped one wraps the handler inside the guards. Infra #[interceptor]s (DbContext, tracing) auto-mount off the pool at a fixed band. The full ordering across all five families is on the request lifecycle.
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.
posts/http/interceptor.rsin the demo — a structured audit interceptor on the posts controller.
Built by YV17labs