Interceptors
Interceptors are the most powerful and versatile component in the Karin ecosystem, enabling Aspect-Oriented Programming (AOP). They allow developers to modularize cross-cutting concerns—features that span multiple parts of an application (like logging, caching, performance monitoring) but do not belong to the core business logic.
Unlike middleware, which is generally unaware of the execution context or the method handler that will run, Interceptors have full visibility. They know which controller class and which method handler is about to be executed, and they can manipulate the execution stream both before the handler runs and after it returns.
Concept: The Expediter
An Interceptor acts as the Expediter (or Maître d') in the kitchen.
- Incoming: They timestamp the ticket when it arrives (Logging).
- Outgoing: They inspect the plate before it leaves the kitchen. If the Chef just put a steak on the plate, the Expediter adds the garnish, wipes the rim, and places it on a serving tray (Response Mapping).
The Onion Architecture
It is helpful to visualize Interceptors as layers of an onion wrapping your Request Handler.
- Incoming Request: The request travels through the Interceptor's Pre-Controller logic.
- Handler Execution: The Interceptor allows the request to reach the Controller.
- Outgoing Response: The Controller returns a value. This value travels back out through the Interceptor's Post-Controller logic.
This bidirectional flow allows you to measure duration, modify results, or even catch errors that occurred specifically in that handler.
The NestInterceptor Interface
An interceptor class must implement the intercept() method.
intercept(context: ExecutionContext, next: CallHandler): Promise<any>The next argument is critical. It represents the "Rest of the Application". Calling next.handle() triggers the actual execution of the Route Handler.
- If you don't call
next.handle(), the route logic never runs (useful for Caching). - If you do call it, it returns a
Promise(or Observable) representing the eventual result. You can then attach.then()orawaitit to add logic after the handler finishes.
Use Case 1: Logging (Measuring Duration)
A common requirement is logging the execution time of a request. Middleware can do this, but Middleware doesn't know which controller method processed the request. An Interceptor does.
import { NestInterceptor, ExecutionContext, CallHandler } from "@project-karin/core";
export class LoggingInterceptor implements NestInterceptor {
async intercept(context: ExecutionContext, next: CallHandler): Promise<any> {
const req = context.switchToHttp().getRequest();
const method = req.method;
const url = req.url;
console.log(`Incoming Request: ${method} ${url}`);
const now = Date.now();
// 1. Yield control to the route handler
// We await the result. The controller logic runs entirely during this await.
const result = await next.handle();
// 2. Logic resumes AFTER the controller finishes
const delay = Date.now() - now;
console.log(`Request Handled in ${delay}ms`);
// 3. Return the result to the client
return result;
}
}Use Case 2: Response Mapping
For a consistent frontend experience, API responses should follow a strict contract (e.g., { success: true, data: ... }). Instead of repeating this wrapping logic in every controller method, we can delegate it to an Interceptor.
The Interceptor takes the raw data returned by the controller (User) and transforms it into the API Envelope.
export class TransformInterceptor implements NestInterceptor {
async intercept(context: ExecutionContext, next: CallHandler): Promise<any> {
// Wait for the handler to define the 'data'
const data = await next.handle();
// Transform the output
return {
success: true,
statusCode: 200, // You could extract this from the Response object if needed
data: data,
timestamp: new Date().toISOString()
};
}
}Now, a controller returning generic data [{ id: 1 }] automatically produces:
{
"success": true,
"statusCode": 200,
"data": [{ "id": 1 }],
"timestamp": "2024-01-01T12:00:00Z"
}Use Case 3: Timeout Interceptor
This demonstrates Stream Manipulation. Sometimes, a downstream service hangs. We want to cancel the request if it takes too long. Because next.handle() returns a Promise, we can race it against a timer.
import { RequestTimeoutException } from "@project-karin/core";
export class TimeoutInterceptor implements NestInterceptor {
async intercept(context: ExecutionContext, next: CallHandler): Promise<any> {
// 5 second limit
const timeoutLimit = 5000;
let timer: Timer;
const timeoutPromise = new Promise((_, reject) => {
timer = setTimeout(() => reject(new RequestTimeoutException()), timeoutLimit);
});
try {
// Race the handler against the clock
return await Promise.race([next.handle(), timeoutPromise]);
} finally {
clearTimeout(timer!); // Always clean up to prevent memory leaks
}
}
}Use Case 4: Cache Interceptor
This demonstrates Overriding Execution. If a request is idempotent (like GET) and expensive, we can skip the controller entirely if we have a fresh result stored.
export class CacheInterceptor implements NestInterceptor {
constructor(private cacheManager: CacheService) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<any> {
const request = context.switchToHttp().getRequest();
const key = `CACHE_${request.url}`;
// 1. Check for existing data
const cachedData = await this.cacheManager.get(key);
if (cachedData) {
// Return immediately.
// NOTICE: We do NOT call next.handle(). The controller never runs.
return cachedData;
}
// 2. If valid/missing, run the controller
const result = await next.handle();
// 3. Save the result for next time
await this.cacheManager.set(key, result, { ttl: 60 });
return result;
}
}