ConceptsClass Runtime

Interceptors

How NextRush wraps a controller method with code that runs before and after the handler — to time it, reshape its result, or return a cached value.

Some behavior has to run around a handler, not only before it: start a timer, then read it once the response is ready; wrap every result in a consistent envelope; return a cached value without running the handler at all. A guard answers one yes/no question before the handler and then steps aside; middleware wraps the whole request but never sees a controller method's typed return value. Behavior that runs on both sides of one handler — and can reshape what it returned — needs a different tool.

What you'll learn

  • Understand why some cross-cutting behavior has to run both before and after a handler
  • Understand how an interceptor wraps a controller method and can transform its return value
  • Recognize the onion order — class interceptors outside, method interceptors inside
  • Choose an interceptor over a guard or middleware, and know when the reverse is true

The problem

The direct way to add timing and a response envelope is to write both into every handler. It reads fine in one method, then it repeats:

// Timing and enveloping are hand-rolled in every method; the real work is one line.
import { Controller, Get } from 'nextrush/class';

declare function loadUsers(): Promise<unknown[]>;
declare function loadPosts(): Promise<unknown[]>;

@Controller('/api')
class ApiController {
  @Get('/users')
  async users() {
    const start = Date.now();
    const data = await loadUsers(); // the actual work
    return { data, tookMs: Date.now() - start };
  }

  @Get('/posts')
  async posts() {
    const start = Date.now();
    const data = await loadPosts(); // the actual work
    return { data, tookMs: Date.now() - start };
  }
}

Each method repeats the same before-and-after scaffolding. Change the envelope shape and you edit every handler; add a new route and it starts life without the timing until someone remembers to paste it in.

Why this matters

Response shaping, timing, and caching are not features of one route — they are behavior a whole controller surface is expected to share. Copied into each handler, the wrapping is code you read past to find the one line that matters, and a route that forgets it is inconsistent in a way no type checker will catch. The behavior wants to live once, in a declared layer, not scattered across every method that happens to need it.

The solution

NextRush lets you attach an interceptor to a controller or a route with @UseInterceptor. An interceptor is a class whose intercept(ctx, next) method wraps the handler: code before next() runs on the way in, next() invokes the handler (or the next interceptor), and code after next() runs on the way out — with access to whatever the handler returned. Whatever intercept() returns becomes the result, so an interceptor can pass a value through unchanged or reshape it. Interceptors are resolved from the DI container, so one can inject a logger, a metrics client, or a cache exactly as a controller can.

Core idea

Think of an interceptor as a layer wrapped around the handler, not a step before it. The request passes inward through each interceptor to reach the handler, and the result passes outward back through the same interceptors. Each layer gets two moments: one before it calls next(), and one after next() resolves — and on the way out it can transform the value before handing it up. This is "around" behavior, which is exactly what a guard (before-only, yes/no) and middleware (whole request, untyped) cannot express over a single controller method's return value.

Mental model

Loading diagram...

Don't read this as a straight line: each interceptor runs code on the way in (before next()) and again on the way out (after it resolves), and the value returned on the way out is what the layer above — and finally response handling — actually sees.

Quick example

The smallest interceptor times a handler and adds a header, then returns the result untouched. Attach it with @UseInterceptor and every route on the controller is wrapped:

app.ts
import { createApp, listen } from 'nextrush';
import { Controller, Get, Service, UseInterceptor, registerControllers } from 'nextrush/class';
import type { Interceptor } from 'nextrush/class';
import type { Context } from 'nextrush';

// An interceptor wraps the handler: code before next(), then code after it.
@Service()
class TimingInterceptor implements Interceptor {
  async intercept(ctx: Context, next: () => Promise<unknown>): Promise<unknown> {
    const start = Date.now();
    const result = await next();                       // runs the handler
    ctx.set('X-Response-Time', `${Date.now() - start}ms`);
    return result;                                     // pass the result through unchanged
  }
}

@UseInterceptor(TimingInterceptor)
@Controller('/users')
class UserController {
  @Get()
  findAll() {
    return [{ id: 1, name: 'Alice' }]; // no timing code here
  }
}

const app = createApp();
await registerControllers(app, { controllers: [UserController] });
listen(app, 8080);

The handler holds no timing logic. TimingInterceptor captures the start time, calls next() to run findAll(), sets the header once the result is ready, and returns that result unchanged.

How it works

Example — a class interceptor wraps every route with an envelope; a method interceptor wraps one route with an extra transform:

reports.controller.ts
import { Service, UseInterceptor, Controller, Get } from 'nextrush/class';
import type { Interceptor } from 'nextrush/class';
import type { Context } from 'nextrush';

// Outermost: wraps EVERY route's result in an envelope.
@Service()
class EnvelopeInterceptor implements Interceptor {
  async intercept(_ctx: Context, next: () => Promise<unknown>): Promise<unknown> {
    const data = await next();
    return { data, timestamp: Date.now() }; // the returned value becomes the response
  }
}

// Inner: wraps one route only, closest to the handler.
@Service()
class UppercaseInterceptor implements Interceptor {
  async intercept(_ctx: Context, next: () => Promise<unknown>): Promise<unknown> {
    const result = await next();
    return typeof result === 'string' ? result.toUpperCase() : result;
  }
}

@UseInterceptor(EnvelopeInterceptor)
@Controller('/reports')
class ReportController {
  @UseInterceptor(UppercaseInterceptor)
  @Get('/title')
  title() {
    return 'quarterly summary';
  }
}

Observation — a request to /reports/title returns { data: 'QUARTERLY SUMMARY', timestamp: … }. UppercaseInterceptor runs innermost and uppercases the handler's string; EnvelopeInterceptor runs outermost and wraps that already-transformed value. Reverse which layer is which and the envelope would wrap the lowercase string instead.

Explanation — a few rules from the interceptor runner govern that behavior:

  • One contract. An interceptor is a class implementing Interceptor — a single intercept(ctx, next) method. next() returns a Promise of the downstream result (the next interceptor, or at the innermost layer the controller method); return its value to pass through, or return something else to transform it.
  • Resolved from the DI container. @UseInterceptor takes interceptor classes, and the runner resolves each from the same container your controllers and services use — so an interceptor's constructor can declare injected collaborators like a logger or cache.
  • Onion order is fixed. Class interceptors are always outermost and method interceptors always innermost, regardless of decorator order on the method. The runner folds the list from the inside out, so the first (class) interceptor ends up as the outer layer that runs first and returns last.
  • The outermost return value is the response. Whatever the outer interceptor returns is what response handling serializes — the controller method's original return value only survives if every layer passes it through.

The exact fold and per-request resolution live in the @nextrush/class reference — a concept teaches the model, not every signature.

Typical use cases

Interceptors fit behavior that has to see and shape a handler's result:

  • Response shaping — wrap every controller result in a consistent { data, meta } envelope in one declared place.
  • Timing and metrics — record how long a handler took and emit it as a header or to a metrics client, using the before/after split.
  • Result caching — check a cache before next() and return the hit without running the handler, or store next()'s value on the way out.

Each needs the value the handler produced, or both sides of its execution. Access decisions belong in a guard; behavior that must run on every request regardless of controller belongs in middleware.

Configuration

@UseInterceptor takes no options object — only a rest list of interceptor classes, and their placement is the configuration:

composing-interceptors.ts
import { Service, UseInterceptor, Controller, Get } from 'nextrush/class';
import type { Interceptor } from 'nextrush/class';

@Service()
class EnvelopeInterceptor implements Interceptor {
  async intercept(_ctx: unknown, next: () => Promise<unknown>) {
    return { data: await next() };
  }
}

@Service()
class CacheInterceptor implements Interceptor {
  async intercept(_ctx: unknown, next: () => Promise<unknown>) {
    return next();
  }
}

@UseInterceptor(EnvelopeInterceptor) // wraps every route on the controller
@Controller('/posts')
class PostController {
  @Get()
  list() {} // EnvelopeInterceptor only

  @UseInterceptor(CacheInterceptor) // adds a second, inner layer to this route
  @Get('/trending')
  trending() {} // EnvelopeInterceptor (outer) then CacheInterceptor (inner)
}

To parameterize an interceptor — a cache TTL, an envelope key — inject the configuration through the DI container (a useValue binding or a config service) rather than passing it to the decorator; there is no options surface on @UseInterceptor itself.

Performance

  • Complexity — the interceptor list for a route is resolved once, when the route handler is built, and captured in a closure; it is not re-read from metadata per request. A request then runs the layers in sequence, so a route's interceptor cost is the sum of its layers, not the max.
  • Memory — a route with no @UseInterceptor pays nothing: the runner is invoked only when at least one interceptor applies, and otherwise the method is called directly. A singleton interceptor (@Service() default) resolves to one shared instance; a transient one is rebuilt per request.
  • Scaling — because each layer wraps the next, an interceptor that does async work on the way in and out adds that latency to every wrapped request. Keep the hot-path layers cheap, and order a short-circuiting layer (like a cache) close enough to the handler that a hit skips the work below it. Measure with apps/benchmark before tuning.

Security

An interceptor sees and can reshape the handler's return value, so what it exposes and what it swallows both matter.

  • Leaked fields on the way out. Threat: an envelope or serialization interceptor returns the handler's object verbatim, exposing fields the handler never meant to send. Why: the interceptor operates on the raw return value, not a view of it. Safe default: shape an allow-list of fields when wrapping a result. Avoid: spreading an entity straight into the response envelope.
  • Swallowed errors. Threat: an interceptor wraps next() in a broad try/catch and returns a fallback, hiding a failure — including one a guard or the handler raised deliberately. Why: a caught error never reaches the exception filters that wrap the interceptor chain. Safe default: catch only the errors you can meaningfully recover and rethrow the rest. Avoid: a catch-all that turns every failure into a 200.
  • Untrusted request input. Threat: an interceptor reads ctx values (headers, query) to build a cache key or branch, trusting client-supplied data. Why: ctx carries unvalidated input verbatim. Safe default: validate anything from ctx before using it. Avoid: keying a cache on a raw header an attacker controls.

An interceptor that catches every error and returns a fallback silently swallows failures the exception filters were meant to handle — and can mask a guard's rejection. Catch narrowly and rethrow what you cannot recover.

Trade-offs

Why an around-the-handler layer — it isolates behavior that needs both sides of a handler's execution, and access to its typed return value, into a declared, reusable, injectable unit.

  • Benefits — cross-cutting shaping (envelopes, timing, caching) declared once and reused across a controller; the layer sees the handler's actual return value, which middleware operating on raw ctx cannot as directly; the onion order is deterministic; interceptors are DI-resolved and unit-testable in isolation.
  • Costs — interceptors apply only to class-based controller routes, not functional routes; the model is Promise-based, so it wraps a single result rather than a stream; an interceptor that forgets to return replaces the result with undefined; interceptors run inside guards and filters, so they cannot gate access.
  • Alternatives — a guard for a yes/no access decision before the handler; middleware when the behavior must run on every request or on functional routes; an exception filter when the job is turning an error into a response.
  • Why NextRush chose this — an async/await onion mirrors how middleware already composes, so there is no new stream abstraction to learn, and resolving interceptors from the container keeps them consistent with the rest of the class runtime.

Decision guide

Choose an interceptor when:

  • ✓ Behavior must run before and after a controller method and see or transform its return value
  • ✓ You want response shaping, timing, or caching declared once on a controller or route and reused across its methods
  • ✓ The behavior benefits from injected services — a logger, a metrics client, a cache

Reach for something else when:

  • ✗ The decision is yes/no access control — that is a guard, which runs before the interceptor chain even starts
  • ✗ The behavior must run on every request regardless of controller, or on a functional route — register middleware
  • ✗ You are turning an error into an HTTP response — that is an exception filter

Common mistakes

  • Forgetting to return next()'s value. Why it happens: the interceptor awaits next() but doesn't return anything. Correct approach: always return from intercept() — the passed-through value, or your transformed one. If ignored: the handler's real result is replaced with undefined and the response is empty.
  • Expecting interceptors on routes without @UseInterceptor. Why it happens: interceptors feel like global middleware. Correct approach: attach the interceptor to the specific controller or method. If ignored: the behavior silently never runs — there is no global interceptor registration.
  • Using an interceptor for access control. Why it happens: an interceptor runs before the handler too. Correct approach: use a guard for allow/deny; an interceptor can only shape a result that is already going to be computed. If ignored: rejection logic ends up in the wrong layer, running after guards and parameter resolution have already done their work.
  • Assuming decorator stacking controls the onion order. Why it happens: stacked decorators apply bottom-to-top. Correct approach: rely on the fixed rule — class interceptors are always outermost, method interceptors always innermost. If ignored: you reason about the wrong nesting and a transform wraps the wrong value.

Key takeaways

  • An interceptor wraps a controller method: code runs before next() and again after it resolves, around the handler.
  • @UseInterceptor takes interceptor classes implementing Interceptor.intercept(ctx, next), resolved from the DI container so they can inject services.
  • next() runs the downstream layer or the handler; whatever intercept() returns becomes the result — return it unchanged to pass through, or return something else to transform.
  • Class interceptors are always outermost and method interceptors always innermost, regardless of decorator order.
  • A route with no @UseInterceptor pays zero interceptor cost — the method is called directly.
  • Interceptors shape results; they do not gate access (that is a guard) or run app-wide (that is middleware).

Continue learning

Was this helpful?

On this page