Skip to main content

Middleware Pipeline

The Ever Works API processes every HTTP request through a multi-stage pipeline. Understanding the execution order of middleware, guards, interceptors, pipes, handlers, and exception filters is essential for debugging and extending the platform.

Request Lifecycle

Client Request
|
+-----v------+
| Express | body-parser (json/urlencoded, 10MB limit)
| Middleware | helmet (security headers)
+-----+------+ CORS
|
+-----v------+
| Guards | 1. ThrottlerGuard (rate limiting)
| | 2. JwtAuthGuard (authentication)
+-----+------+
|
+-----v------+
| Interceptors| (before handler - request phase)
| | 1. LoggingInterceptor (log incoming request)
| | 2. SentryInterceptor (set user context)
| | 3. PostHogInterceptor (start timer)
+-----+------+
|
+-----v------+
| Pipes | ValidationPipe (whitelist, transform, validate)
+-----+------+
|
+-----v------+
| Handler | Controller method executes
+-----+------+
|
+-----v------+
| Interceptors| (after handler - response phase)
| | 3. PostHogInterceptor (track request)
| | 2. SentryInterceptor (capture errors)
| | 1. LoggingInterceptor (log response)
+-----+------+
|
+-----v------+
| Exception | Built-in NestJS exception filter
| Filters | (catches unhandled errors)
+-----+------+
|
Client Response

Express Middleware Layer

These run before NestJS takes over. Configured in main.ts:

// Body parsing with size limits
app.use(json({ limit: '10mb' }));
app.use(urlencoded({ limit: '10mb', extended: true }));

// Security headers (conditional CSP)
app.use((req, res, next) => {
if (req.path.startsWith('/api/docs')) {
return helmet({
contentSecurityPolicy: {
/* relaxed */
}
})(req, res, next);
}
return helmet()(req, res, next);
});

// CORS
app.enableCors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
credentials: true
});

Guards

Guards determine whether a request should proceed. They run in registration order:

1. ThrottlerGuard

Checks rate limits across all three tiers (short, medium, long):

{
provide: APP_GUARD,
useClass: ThrottlerGuard, // Runs first
},

If the rate limit is exceeded, the guard returns a 429 Too Many Requests response. The request never reaches the auth guard.

2. JwtAuthGuard

Validates the JWT token (unless the route is @Public()):

{
provide: APP_GUARD,
useClass: JwtAuthGuard, // Runs second
},
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass()
]);
if (isPublic) return true;
return super.canActivate(context);
}
}

If authentication fails, a 401 Unauthorized response is returned.

Interceptors (Before Handler)

Interceptors wrap the handler execution. The "before" phase runs in registration order:

1. LoggingInterceptor

Logs the incoming request (only when HTTP_DEBUG=true):

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
if (!config.debug()) {
return next.handle(); // Skip entirely in production
}

const now = Date.now();
this.logger.log(`Incoming Request: ${method} ${originalUrl}`);

return next.handle().pipe(
tap(() => {
const delay = Date.now() - now;
this.logger.log(`Outgoing Response: ${method} ${originalUrl} ${statusCode} - ${delay}ms`);
})
);
}
}

2. SentryInterceptor

Sets Sentry user context and request metadata:

if (request.user) {
Sentry.setUser({ id: request.user.id, email: request.user.email });
}
Sentry.setContext('request', { method, url, headers: sanitized, body: sanitized });
Sentry.setTag('transaction', `${method} ${originalUrl}`);

3. PostHogInterceptor

Records the start time for duration tracking:

const startTime = Date.now();
// ... handler executes ...
// After handler:
trackEvent(user?.id || 'anonymous', 'api_request', {
method,
endpoint,
statusCode,
duration: Date.now() - startTime
});

Pipes

The global ValidationPipe runs after guards and interceptors but before the handler:

app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip unknown properties
transform: true, // Type coercion
forbidNonWhitelisted: true // Reject unknown properties with 400
})
);

Pipes can also be applied per-parameter:

@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}

Handler Execution

The controller method runs with validated, transformed parameters and the authenticated user available via @CurrentUser():

@Post()
async createWork(
@CurrentUser() user: JwtPayload,
@Body() createDto: CreateWorkDto,
) {
return this.service.create(user.sub, createDto);
}

Interceptors (After Handler)

After the handler returns, interceptors run in reverse order through RxJS operators:

  1. PostHogInterceptor (tap) -- Tracks the request with duration and status code
  2. SentryInterceptor (catchError) -- Captures any unhandled exception
  3. LoggingInterceptor (tap / catchError) -- Logs the response or error

Exception Filters

If an error escapes all interceptors, NestJS built-in exception filters convert it to an HTTP response:

Exception TypeStatus CodeResponse Body
BadRequestException400{ statusCode: 400, message: [...] }
UnauthorizedException401{ statusCode: 401, message: "..." }
ConflictException409{ statusCode: 409, message: "..." }
ThrottlerException429{ statusCode: 429, message: "..." }
Unhandled Error500{ statusCode: 500, message: "..." }

Execution Order Summary

1. Express Middleware: body-parser -> helmet -> CORS
2. Guards (in order): ThrottlerGuard -> JwtAuthGuard
3. Interceptors (before): LoggingInterceptor -> SentryInterceptor -> PostHogInterceptor
4. Pipes: ValidationPipe
5. Handler: Controller method
6. Interceptors (after): PostHogInterceptor -> SentryInterceptor -> LoggingInterceptor
7. Exception Filters: Built-in NestJS filters

Adding Custom Middleware

Per-Module Middleware

@Module({
/* ... */
})
export class WorksModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestLoggerMiddleware).forRoutes('works');
}
}

Per-Route Guards

@Controller('admin')
@UseGuards(AdminRoleGuard)
export class AdminController {
// AdminRoleGuard runs after the global guards
}

Per-Route Interceptors

@UseInterceptors(CacheInterceptor)
@Get('popular')
async getPopularWorks() {
// Response will be cached
}

Best Practices

  1. Understand the order -- Guards run before interceptors, which run before pipes. Errors in guards skip the entire pipeline.

  2. Use @SkipThrottle() intentionally -- Skipping rate limiting should be explicit and documented.

  3. Keep interceptors lean -- The three global interceptors run on every request. Heavy logic should be in per-route interceptors.

  4. Validate at the pipe level -- Do not validate input inside the handler. Let the ValidationPipe handle it.

  5. Use exception filters for consistency -- Custom exception filters ensure all errors follow the same response format.

Troubleshooting

Request body is empty

Check that Content-Type: application/json header is set. The body parser only processes application/json and application/x-www-form-urlencoded.

Guard order issues

APP_GUARD providers run in the order they are registered in the providers array. If JwtAuthGuard should run before ThrottlerGuard, swap their order.

Interceptor not firing

Ensure the interceptor is registered as APP_INTERCEPTOR or applied with @UseInterceptors(). Global interceptors do not apply to middleware.