Middleware
Middleware runs before guards and route handlers. It can inspect or modify the request, short-circuit the response, or perform side effects like logging and timing. A module registers middleware by implementing RouteConfigurable and configuring the Router it receives.
The Middleware interface
Section titled “The Middleware interface”A middleware class implements the Middleware interface with a single handle method:
import { Transient } from 'stratal/di'import type { Middleware, Next, RouterContext } from 'stratal/router'
@Transient()export class LoggingMiddleware implements Middleware { async handle(ctx: RouterContext, next: Next): Promise<void> { const start = Date.now() console.log(`--> ${ctx.c.req.method} ${ctx.c.req.path}`)
await next()
const duration = Date.now() - start console.log(`<-- ${ctx.c.req.method} ${ctx.c.req.path} ${duration}ms`) }}Call next() to pass control to the next middleware in the chain (or the route handler if there are no more middlewares). Code before next() runs on the way in, and code after runs on the way out.
The Middleware, Next, and RouterContext types are all imported from stratal/router.
Configuring middleware on a module
Section titled “Configuring middleware on a module”Modules register middleware by implementing the RouteConfigurable interface. This gives you a configureRoutes method that receives a Router:
import { Module } from 'stratal/module'import type { RouteConfigurable } from 'stratal/router'import { Router } from 'stratal/router'import { LoggingMiddleware } from './logging.middleware'import { UsersController } from './users.controller'
@Module({ controllers: [UsersController], providers: [LoggingMiddleware],})export class UsersModule implements RouteConfigurable { configureRoutes(router: Router): void { router.middleware(LoggingMiddleware) }}configureRoutes is called once during module initialization. It does not run on every request.
The Router API
Section titled “The Router API”The Router is a fluent builder. Every configuration method returns the same router, so calls chain together.
middleware()
Section titled “middleware()”middleware() registers one or more middleware classes scoped to the current module’s controllers. Pass them in the order you want them to run:
configureRoutes(router: Router): void { router.middleware(CorsMiddleware, LoggingMiddleware)}In this example, CorsMiddleware runs first, then LoggingMiddleware, for every route declared by this module’s controllers.
use() registers global middleware that applies to every route in the entire application, not just the current module’s controllers:
configureRoutes(router: Router): void { router.use(SecurityHeadersMiddleware, CorsMiddleware)}group()
Section titled “group()”group() scopes middleware and other route configuration to a specific set of controllers. Pass the controllers and a callback that configures them:
configureRoutes(router: Router): void { router.group([AdminController], (admin) => { admin.middleware(AdminAuthMiddleware) })}Controllers listed in a group() are removed from the module’s default scope, so middleware registered directly on router does not apply to them. The callback receives a router without use(), since global middleware belongs on the root.
Because every method returns the router, you can combine middleware with prefixes, domains, versions, and names inside a group:
configureRoutes(router: Router): void { router.group([TenantController, BillingController], (r) => { r.prefix('/tenant') .domain('{tenant}.myapp.com') .middleware(TenantMiddleware) })}The scoped configuration methods available on the router and inside a group are:
| Method | Applies to scope |
|---|---|
middleware(...middlewares) | Middleware classes for controllers in this scope |
prefix(path, params?) | A path prefix, with an optional Zod schema for its params |
domain(pattern) | A domain pattern for controllers in this scope |
name(prefix) | A name prefix for routes in this scope |
version(version) | An API version (string or string[]) for this scope |
throttle(name) | A named rate limiter for controllers in this scope |
hideFromDocs(hide?) | Hide or show this scope’s routes in the OpenAPI document |
use() is the only method that is not scoped: it always registers global middleware on the whole application.
Targeting routes
Section titled “Targeting routes”Targeting is expressed through scope rather than path strings:
-
To run middleware on every route in the app, call
router.use(...). -
To run middleware on every route in this module, call
router.middleware(...)on the module’s router. -
To run middleware on specific controllers, list them in
router.group([...], ...)and callmiddleware(...)inside the callback.
To target a versioned path, scope the group with .version(). For a prefixed path, scope it with .prefix(). See API Versioning for how versions map to URL segments.
Multiple groups and scopes
Section titled “Multiple groups and scopes”A single module can mix global middleware, module-wide middleware, and several groups:
@Module({ controllers: [UsersController, AdminController, HealthController], providers: [LoggingMiddleware, AuthMiddleware, AdminAuthMiddleware],})export class AppModule implements RouteConfigurable { configureRoutes(router: Router): void { // Global logging on every route in the app router.use(LoggingMiddleware)
// Auth on this module's remaining controllers (Users, Health) router.middleware(AuthMiddleware)
// Stricter auth scoped to the admin controller only router.group([AdminController], (admin) => { admin.middleware(AdminAuthMiddleware) }) }}Because AdminController is listed in the group, the module-wide AuthMiddleware does not apply to it. The group’s AdminAuthMiddleware applies instead.
Dependency injection in middleware
Section titled “Dependency injection in middleware”Middleware classes are resolved from the request-scoped DI container, so you can inject services:
import { Transient, inject } from 'stratal/di'import type { Middleware, Next, RouterContext } from 'stratal/router'import { LOGGER_TOKENS, type LoggerService } from 'stratal/logger'
@Transient()export class RequestLoggerMiddleware implements Middleware { constructor( @inject(LOGGER_TOKENS.LoggerService) private readonly logger: LoggerService, ) {}
async handle(ctx: RouterContext, next: Next): Promise<void> { this.logger.info('Request received', { method: ctx.c.req.method, path: ctx.c.req.path, })
await next() }}Short-circuiting a response
Section titled “Short-circuiting a response”A middleware can respond directly by returning a Response instead of calling next(). This stops the chain, and the route handler never runs:
@Transient()export class MaintenanceMiddleware implements Middleware { constructor( @inject(DI_TOKENS.CloudflareEnv) private readonly env: StratalEnv, ) {}
async handle(ctx: RouterContext, next: Next): Promise<Response | void> { if (this.env.MAINTENANCE_MODE === 'true') { return ctx.json({ message: 'Service is under maintenance' }, 503) }
await next() }}Rate limiting
Section titled “Rate limiting”For request throttling, prefer the framework’s built-in named rate limiters over hand-rolled middleware. Scope a named limiter with throttle():
configureRoutes(router: Router): void { router.group([UploadsController], (uploads) => { uploads.prefix('/uploads').throttle('uploads') })}See Rate Limiting for registering named limiters.
Execution order
Section titled “Execution order”The complete request lifecycle shows where middleware fits:
Request -> Request-scoped container setup -> Global middleware (router.use) -> Scoped middleware (router.middleware / group middleware) -> Guards (controller-level, then method-level) -> Schema validation (@Route body, params, query) -> Route handler -> ResponseWithin middleware, execution order is determined by:
- Global before scoped: middleware from
use()runs before scoped middleware. - Registration order: within a single
configureRoutes()call, middleware registered first runs first. - Chain order: within a single
middleware()oruse()call, middleware listed first runs first.
Accessing the request container
Section titled “Accessing the request container”Every middleware has access to the request-scoped DI container through RouterContext:
@Transient()export class CorrelationMiddleware implements Middleware { async handle(ctx: RouterContext, next: Next): Promise<void> { const requestId = ctx.header('X-Request-ID') ?? crypto.randomUUID()
// Register the request ID in the request container const container = ctx.getContainer() container.registerValue(REQUEST_ID_TOKEN, requestId)
await next() }}Services resolved later in the request (guards, controllers, other middleware) can then inject REQUEST_ID_TOKEN to access the correlation ID.
Next steps
Section titled “Next steps”- Guards to learn about access control that runs after middleware.
- Domain Routing for routing requests based on subdomain patterns.
- Signed URLs for generating tamper-proof, expiring links.
- Modules for how modules organize middleware and other providers.
- Dependency Injection for more on the request-scoped container.