Skip to content

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.

  1. Incoming Request: The request travels through the Interceptor's Pre-Controller logic.
  2. Handler Execution: The Interceptor allows the request to reach the Controller.
  3. 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.

typescript
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() or await it 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.

typescript
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.

typescript
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:

json
{
  "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.

typescript
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.

typescript
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;
  }
}

Released under the MIT License.