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. Stratal uses a per-module configuration approach where each module declares which middleware applies to which routes.
The Middleware interface
Section titled “The Middleware interface”A middleware class implements the Middleware interface with a single handle method:
import { Middleware } from 'stratal/middleware'import { RouterContext } from 'stratal/router'import { Transient } from 'stratal/di'
@Transient()export class LoggingMiddleware implements Middleware { async handle(ctx: RouterContext, next: () => Promise<void>): 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.
Configuring middleware on a module
Section titled “Configuring middleware on a module”Modules register middleware by implementing the MiddlewareConfigurable interface. This gives you a configure method with access to the MiddlewareConsumer:
import { Module } from 'stratal/module'import { MiddlewareConfigurable, MiddlewareConsumer } from 'stratal/middleware'import { LoggingMiddleware } from './logging.middleware'import { UsersController } from './users.controller'
@Module({ controllers: [UsersController], providers: [LoggingMiddleware],})export class UsersModule implements MiddlewareConfigurable { configure(consumer: MiddlewareConsumer) { consumer .apply(LoggingMiddleware) .forRoutes('*') }}The configure method is called once during module initialization. It does not run on every request.
The MiddlewareConsumer API
Section titled “The MiddlewareConsumer API”The consumer provides a fluent API for declaring middleware:
apply()
Section titled “apply()”Pass one or more middleware classes to apply():
consumer .apply(CorsMiddleware, LoggingMiddleware) .forRoutes('*')Middlewares execute in the order they are listed. In this example, CorsMiddleware runs first, then LoggingMiddleware.
forRoutes()
Section titled “forRoutes()”forRoutes() defines which routes the middleware applies to. It accepts three types of targets:
Global wildcard applies to all routes:
consumer .apply(LoggingMiddleware) .forRoutes('*')Controller class applies to all routes in that controller:
consumer .apply(AuthMiddleware) .forRoutes(UsersController, OrdersController)Route info applies to a specific path and optionally a specific HTTP method or version:
consumer .apply(RateLimitMiddleware) .forRoutes( { path: '/api/auth/login', method: 'post' }, { path: '/api/auth/register', method: 'post' }, { path: '/api/users', version: '1' }, // matches versioned path /v1/api/users )You can mix target types in a single call:
consumer .apply(AuditMiddleware) .forRoutes(AdminController, { path: '/api/billing', method: 'post' })exclude()
Section titled “exclude()”Use exclude() to skip specific routes. This is useful when you want middleware on most routes but need to carve out exceptions:
consumer .apply(AuthMiddleware) .exclude('/api/health', '/api/auth/login') .forRoutes('*')Exclusions also accept route info objects for method-specific exclusions:
consumer .apply(AuthMiddleware) .exclude( { path: '/api/health', method: 'get' }, { path: '/api/auth/login', method: 'post' }, ) .forRoutes('*')Multiple middleware configurations
Section titled “Multiple middleware configurations”A single module can register multiple middleware chains. Call consumer.apply() multiple times:
@Module({ controllers: [UsersController, HealthController], providers: [LoggingMiddleware, AuthMiddleware, RateLimitMiddleware],})export class AppModule implements MiddlewareConfigurable { configure(consumer: MiddlewareConsumer) { // Global logging on every route except health consumer .apply(LoggingMiddleware) .exclude('/api/health') .forRoutes('*')
// Auth on the users controller consumer .apply(AuthMiddleware) .forRoutes(UsersController)
// Rate limiting on login consumer .apply(RateLimitMiddleware) .forRoutes({ path: '/api/auth/login', method: 'post' }) }}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 { Middleware } from 'stratal/middleware'import { RouterContext } from 'stratal/router'import { Transient, inject } from 'stratal/di'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: () => Promise<void>): 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 without calling next(). This stops the chain and returns the response immediately:
@Transient()export class MaintenanceMiddleware implements Middleware { constructor( @inject(DI_TOKENS.CloudflareEnv) private readonly env: StratalEnv, ) {}
async handle(ctx: RouterContext, next: () => Promise<void>): Promise<void> { if (this.env.MAINTENANCE_MODE === 'true') { return ctx.json({ message: 'Service is under maintenance' }, 503) }
await next() }}Route matching
Section titled “Route matching”Stratal supports several path matching patterns in forRoutes() and exclude():
| Pattern | Matches |
|---|---|
* | All routes |
/api/users | Exact match |
/api/* | Any path starting with /api/ |
/api/users/:id | Path with parameter (e.g., /api/users/123) |
When a method is specified in a route info object, the middleware only runs for that HTTP method. Without a method, the middleware runs for all methods on that path.
Execution order
Section titled “Execution order”The complete request lifecycle shows where middleware fits:
Request -> Request-scoped container setup -> User-defined middleware (from configure()) -> Guards (controller-level, then method-level) -> Schema validation (@Route body, params, query) -> Route handler -> ResponseWithin middleware, execution order is determined by:
- Module order — middleware from modules imported earlier runs before middleware from modules imported later.
- Registration order — within a single
configure()call, middleware registered first runs first. - Chain order — within a single
apply()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: () => Promise<void>): 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.
- Modules for how modules organize middleware and other providers.
- Dependency Injection for more on the request-scoped container.