Back to Blog

Backend Middleware Pipeline Internals: Composition Patterns, Onion Model, Error Propagation & Framework Mechanics

March 24, 20267 min read0 views

Backend Middleware Pipeline Internals: Composition Patterns, Onion Model, Error Propagation & Framework Mechanics

What Is a Middleware Pipeline?

Every HTTP request that enters a backend server passes through a chain of functions before reaching the route handler. Each function in this chain — called middleware — can inspect, modify, short-circuit, or augment the request and response. This is the middleware pipeline.

HTTP Request Flow Through Middleware:

Request →  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐
            │  Logger  │→ │   Auth   │→ │   CORS   │→ │  Router  │
            │middleware│  │middleware│  │middleware│  │ handler  │
Response ← └──────────┘← └──────────┘← └──────────┘← └──────────┘

Each middleware can:
1. Read/modify the request (add user info, parse body)
2. Read/modify the response (add headers, compress)
3. Short-circuit the pipeline (return 401 before reaching handler)
4. Measure timing (log latency around downstream execution)
5. Handle errors from downstream middleware

Two Models: Linear (Express) vs Onion (Koa)

Express Linear Model:
────────────────────────────────────────────────
Request  → mw1(req, res, next) → mw2(req, res, next) → handler(req, res)
Response ← (each mw must explicitly send response or call next)

- Response can be sent at ANY point
- No guaranteed "after handler" execution
- next() moves to next middleware
- Forgetting next() silently hangs the request


Koa Onion Model:
────────────────────────────────────────────────
Request flows IN through each layer:
           ┌───────────────────────────────┐
           │  Middleware 1 (before)         │
           │  ┌───────────────────────────┐ │
           │  │  Middleware 2 (before)     │ │
           │  │  ┌───────────────────────┐ │ │
           │  │  │  Middleware 3 (before) │ │ │
           │  │  │       HANDLER         │ │ │
           │  │  │  Middleware 3 (after)  │ │ │
           │  │  └───────────────────────┘ │ │
           │  │  Middleware 2 (after)      │ │
           │  └───────────────────────────┘ │
           │  Middleware 1 (after)          │
           └───────────────────────────────┘

- await next() pauses current middleware
- Downstream executes
- Control returns after next() resolves
- GUARANTEED before/after execution
- Natural try/catch error handling

Building the Express-Style Pipeline from Scratch

Core Implementation

type NextFunction = (error?: any) => void;
type Middleware = (req: IncomingRequest, res: OutgoingResponse, next: NextFunction) => void;
type ErrorMiddleware = (error: any, req: IncomingRequest, res: OutgoingResponse, next: NextFunction) => void;

interface IncomingRequest {
  method: string;
  url: string;
  headers: Record<string, string>;
  body?: any;
  params: Record<string, string>;
  query: Record<string, string>;
  path: string;
  // Extended by middleware
  [key: string]: any;
}

interface OutgoingResponse {
  statusCode: number;
  headers: Record<string, string>;
  body: any;
  headersSent: boolean;
  
  status(code: number): OutgoingResponse;
  setHeader(name: string, value: string): OutgoingResponse;
  json(data: any): void;
  send(data: string | Buffer): void;
  end(): void;
}

// Layer: wraps a middleware with path matching
class Layer {
  readonly path: string;
  readonly middleware: Middleware | ErrorMiddleware;
  readonly isErrorHandler: boolean;
  readonly method?: string;
  
  constructor(
    path: string, 
    middleware: Middleware | ErrorMiddleware,
    method?: string
  ) {
    this.path = path;
    this.middleware = middleware;
    // Express uses arity to detect error middleware (4 args)
    this.isErrorHandler = middleware.length === 4;
    this.method = method?.toUpperCase();
  }
  
  match(path: string, method?: string): boolean {
    // Root middleware matches everything
    if (this.path === '/') return true;
    
    // Path prefix matching (Express behavior)
    if (path === this.path || path.startsWith(this.path + '/')) {
      if (this.method && method) {
        return this.method === method.toUpperCase();
      }
      return true;
    }
    
    return false;
  }
}

class ExpressStylePipeline {
  private layers: Layer[] = [];
  
  // Add middleware for all routes
  use(pathOrMiddleware: string | Middleware, middleware?: Middleware): void {
    if (typeof pathOrMiddleware === 'function') {
      this.layers.push(new Layer('/', pathOrMiddleware));
    } else {
      this.layers.push(new Layer(pathOrMiddleware, middleware!));
    }
  }
  
  // Add route-specific handler
  route(method: string, path: string, ...handlers: Middleware[]): void {
    for (const handler of handlers) {
      this.layers.push(new Layer(path, handler, method));
    }
  }
  
  get(path: string, ...handlers: Middleware[]): void {
    this.route('GET', path, ...handlers);
  }
  
  post(path: string, ...handlers: Middleware[]): void {
    this.route('POST', path, ...handlers);
  }
  
  // Core dispatch: iterate through layers
  handle(req: IncomingRequest, res: OutgoingResponse): void {
    let index = 0;
    let currentError: any = null;
    
    const next: NextFunction = (error?: any) => {
      if (error) {
        currentError = error;
      }
      
      // Find next matching layer
      while (index < this.layers.length) {
        const layer = this.layers[index++];
        
        if (!layer.match(req.path, req.method)) {
          continue;
        }
        
        try {
          if (currentError) {
            // Skip non-error handlers when we have an error
            if (!layer.isErrorHandler) continue;
            
            const errorHandler = layer.middleware as ErrorMiddleware;
            const err = currentError;
            currentError = null; // Clear error if handler deals with it
            errorHandler(err, req, res, next);
            return;
          } else {
            // Skip error handlers when there's no error
            if (layer.isErrorHandler) continue;
            
            const handler = layer.middleware as Middleware;
            handler(req, res, next);
            return;
          }
        } catch (thrown) {
          // Synchronous errors become the current error
          currentError = thrown;
          // Continue to next iteration to find error handler
        }
      }
      
      // No more layers — send default response
      if (currentError) {
        this.defaultErrorHandler(currentError, req, res);
      } else if (!res.headersSent) {
        res.status(404).json({ error: 'Not Found' });
      }
    };
    
    // Start the chain
    next();
  }
  
  private defaultErrorHandler(
    error: any, 
    req: IncomingRequest, 
    res: OutgoingResponse
  ): void {
    const statusCode = error.statusCode || error.status || 500;
    const message = process.env.NODE_ENV === 'production' 
      ? 'Internal Server Error' 
      : error.message || 'Internal Server Error';
    
    console.error(`[${req.method}] ${req.path} - Error:`, error);
    
    if (!res.headersSent) {
      res.status(statusCode).json({ error: message });
    }
  }
}

How Express Actually Dispatches (Simplified)

Express Internal Dispatch:

app.handle(req, res)
  │
  ▼
router.handle(req, res, finalHandler)
  │
  ▼
next()  ─────────────────────────────────────┐
  │                                          │
  ▼                                          │
layer[0].match(path)? ──No──→ next() ────────┤
  │ Yes                                      │
  ▼                                          │
layer[0].handle(req, res, next) ──error──→   │
  │                                          │
  │ next() called                            │
  ▼                                          │
layer[1].match(path)? ──No──→ next() ────────┤
  │ Yes                                      │
  ▼                                          │
layer[1].handle(req, res, next)              │
  │                                          │
  │ ... continues until                      │
  │   - res.send() called (response sent)    │
  │   - No more layers (404)                 │
  │   - Error propagated to error handler    │
  └──────────────────────────────────────────┘

Key insight: Express's `next()` is a CLOSURE that captures
the current index in the layer stack. Each call increments
the index and tries the next matching layer.

Building the Koa-Style Onion Pipeline from Scratch

Core Implementation with async/await

type KoaMiddleware = (ctx: Context, next: () => Promise<void>) => Promise<void>;

interface Context {
  request: {
    method: string;
    url: string;
    path: string;
    headers: Record<string, string>;
    query: Record<string, string>;
    body?: any;
  };
  response: {
    status: number;
    headers: Record<string, string>;
    body: any;
  };
  state: Record<string, any>;  // Shared state between middleware
  throw(status: number, message?: string): never;
  
  // Convenience shortcuts
  status: number;
  body: any;
  set(header: string, value: string): void;
  get(header: string): string | undefined;
}

class KoaStylePipeline {
  private middleware: KoaMiddleware[] = [];
  
  use(mw: KoaMiddleware): void {
    this.middleware.push(mw);
  }
  
  // Compose middleware into a single function
  // This is the heart of the onion model
  private compose(middleware: KoaMiddleware[]): (ctx: Context) => Promise<void> {
    return function(ctx: Context): Promise<void> {
      let index = -1;
      
      function dispatch(i: number): Promise<void> {
        // Prevent calling next() multiple times
        if (i <= index) {
          return Promise.reject(new Error('next() called multiple times'));
        }
        index = i;
        
        const fn = middleware[i];
        if (!fn) {
          return Promise.resolve(); // End of chain
        }
        
        try {
          // Pass dispatch(i+1) as next()
          // When middleware awaits next(), it suspends
          // and downstream middleware executes
          return Promise.resolve(fn(ctx, () => dispatch(i + 1)));
        } catch (err) {
          return Promise.reject(err);
        }
      }
      
      return dispatch(0);
    };
  }
  
  async handle(ctx: Context): Promise<void> {
    const composed = this.compose(this.middleware);
    
    try {
      await composed(ctx);
    } catch (error: any) {
      // Top-level error handling
      ctx.response.status = error.statusCode || error.status || 500;
      ctx.response.body = {
        error: error.message || 'Internal Server Error'
      };
    }
    
    // Default 404 if no body set
    if (ctx.response.body === undefined) {
      ctx.response.status = 404;
      ctx.response.body = { error: 'Not Found' };
    }
  }
}

// Usage demonstration:
function createApp(): KoaStylePipeline {
  const app = new KoaStylePipeline();
  
  // 1. Error handling (outermost layer)
  app.use(async (ctx, next) => {
    try {
      await next();
    } catch (err: any) {
      ctx.status = err.statusCode || 500;
      ctx.body = { error: err.message };
      // Error is caught here — no propagation to top
    }
  });
  
  // 2. Request timing
  app.use(async (ctx, next) => {
    const start = performance.now();
    await next();  // Wait for all downstream middleware + handler
    const duration = performance.now() - start;
    ctx.set('X-Response-Time', `${duration.toFixed(2)}ms`);
    console.log(`${ctx.request.method} ${ctx.request.path} - ${duration.toFixed(0)}ms`);
  });
  
  // 3. Authentication
  app.use(async (ctx, next) => {
    const token = ctx.get('Authorization')?.replace('Bearer ', '');
    if (!token) {
      ctx.throw(401, 'Authentication required');
    }
    ctx.state.user = { id: 'user-123', role: 'admin' }; // decoded token
    await next();
  });
  
  // 4. Route handler
  app.use(async (ctx, next) => {
    if (ctx.request.path === '/api/users' && ctx.request.method === 'GET') {
      ctx.status = 200;
      ctx.body = { users: [], requestedBy: ctx.state.user.id };
      return; // Don't call next() — we handled this route
    }
    await next();
  });
  
  return app;
}

Execution Trace: Onion Model in Action

Request: GET /api/users (with valid auth token)

→ ErrorHandler.before
  → Timer.before (start = 1000.00ms)
    → Auth.before (token valid, set ctx.state.user)
      → RouteHandler (set body, return without next())
    → Auth.after (no-op, no code after next())
  → Timer.after (duration = 5.2ms, set header)
→ ErrorHandler.after (no error caught)

Stack trace of execution:

errorHandler(ctx, next) {
  try {
    await next() ──────────────────────────────┐
    // Returns here after timer completes       │
  } catch {}                                    │
}                                               │
                                                │
timer(ctx, next) {                 ◄────────────┘
  const start = now();
  await next() ────────────────────────────────┐
  // Returns here after auth+route complete     │
  set X-Response-Time header                    │
}                                               │
                                                │
auth(ctx, next) {                  ◄────────────┘
  verify token, set user
  await next() ────────────────────────────────┐
  // Returns here after route handler           │
}                                               │
                                                │
routeHandler(ctx, next) {          ◄────────────┘
  set body = { users: [] }
  return  // Does NOT call next()
}

Router Implementation: Path Matching & Parameter Extraction

interface RouteDefinition {
  method: string;
  path: string;       // /users/:id/posts/:postId
  pattern: RegExp;
  paramNames: string[];
  handlers: KoaMiddleware[];
}

class Router {
  private routes: RouteDefinition[] = [];
  private prefix: string = '';
  
  constructor(prefix: string = '') {
    this.prefix = prefix;
  }

  // Convert path pattern to regex with named capture groups
  private compilePath(path: string): { pattern: RegExp; paramNames: string[] } {
    const paramNames: string[] = [];
    
    // Replace :param segments with capture groups
    const regexStr = path
      .replace(/\/:([^\/]+)/g, (_, name) => {
        paramNames.push(name);
        return '/([^/]+)';
      })
      .replace(/\//g, '\\/');
    
    return {
      pattern: new RegExp(`^${regexStr}$`),
      paramNames
    };
  }

  private addRoute(method: string, path: string, ...handlers: KoaMiddleware[]): void {
    const fullPath = this.prefix + path;
    const { pattern, paramNames } = this.compilePath(fullPath);
    
    this.routes.push({
      method: method.toUpperCase(),
      path: fullPath,
      pattern,
      paramNames,
      handlers
    });
  }

  get(path: string, ...handlers: KoaMiddleware[]): void {
    this.addRoute('GET', path, ...handlers);
  }

  post(path: string, ...handlers: KoaMiddleware[]): void {
    this.addRoute('POST', path, ...handlers);
  }

  put(path: string, ...handlers: KoaMiddleware[]): void {
    this.addRoute('PUT', path, ...handlers);
  }

  delete(path: string, ...handlers: KoaMiddleware[]): void {
    this.addRoute('DELETE', path, ...handlers);
  }

  // Returns middleware that dispatches to matched route
  routes(): KoaMiddleware {
    const routeTable = this.routes;
    
    return async (ctx: Context, next: () => Promise<void>) => {
      const { method, path } = ctx.request;
      
      for (const route of routeTable) {
        if (route.method !== method && route.method !== 'ALL') continue;
        
        const match = path.match(route.pattern);
        if (!match) continue;
        
        // Extract path parameters
        const params: Record<string, string> = {};
        route.paramNames.forEach((name, i) => {
          params[name] = decodeURIComponent(match[i + 1]);
        });
        ctx.state.params = params;
        
        // Compose route handlers as mini-pipeline
        const composed = this.composeHandlers(route.handlers);
        await composed(ctx, next);
        return;
      }
      
      // No route matched — pass to next middleware
      await next();
    };
  }

  private composeHandlers(handlers: KoaMiddleware[]): KoaMiddleware {
    return async (ctx: Context, next: () => Promise<void>) => {
      let index = -1;
      
      async function dispatch(i: number): Promise<void> {
        if (i <= index) throw new Error('next() called multiple times');
        index = i;
        
        if (i < handlers.length) {
          await handlers[i](ctx, () => dispatch(i + 1));
        } else {
          await next(); // After all route handlers, continue pipeline
        }
      }
      
      await dispatch(0);
    };
  }
}

Middleware Composition Patterns

Conditional Middleware

// Only apply middleware under certain conditions
function unless(
  condition: (ctx: Context) => boolean,
  middleware: KoaMiddleware
): KoaMiddleware {
  return async (ctx, next) => {
    if (condition(ctx)) {
      await next(); // Skip middleware
    } else {
      await middleware(ctx, next);
    }
  };
}

// Usage: skip auth for public paths
app.use(unless(
  (ctx) => ctx.request.path.startsWith('/public'),
  authMiddleware
));

// Method-specific middleware
function forMethods(
  methods: string[], 
  middleware: KoaMiddleware
): KoaMiddleware {
  const methodSet = new Set(methods.map(m => m.toUpperCase()));
  
  return async (ctx, next) => {
    if (methodSet.has(ctx.request.method)) {
      await middleware(ctx, next);
    } else {
      await next();
    }
  };
}

Branching Middleware

// Route to different middleware based on condition
function branch(
  condition: (ctx: Context) => boolean,
  ifTrue: KoaMiddleware,
  ifFalse: KoaMiddleware
): KoaMiddleware {
  return async (ctx, next) => {
    if (condition(ctx)) {
      await ifTrue(ctx, next);
    } else {
      await ifFalse(ctx, next);
    }
  };
}

// Fan-out: run multiple middleware in parallel
function parallel(...middlewares: KoaMiddleware[]): KoaMiddleware {
  return async (ctx, next) => {
    // Run all in parallel, each gets same ctx
    // WARNING: ctx mutations may race
    await Promise.all(
      middlewares.map(mw => mw(ctx, async () => {}))
    );
    await next();
  };
}

Middleware Factory Pattern

// Rate limiter factory
function rateLimit(options: {
  windowMs: number;
  maxRequests: number;
  keyExtractor?: (ctx: Context) => string;
}): KoaMiddleware {
  const { windowMs, maxRequests, keyExtractor = (ctx) => ctx.get('x-forwarded-for') || 'anonymous' } = options;
  
  const windows: Map<string, { count: number; resetAt: number }> = new Map();
  
  return async (ctx, next) => {
    const key = keyExtractor(ctx);
    const now = Date.now();
    
    let window = windows.get(key);
    if (!window || now > window.resetAt) {
      window = { count: 0, resetAt: now + windowMs };
      windows.set(key, window);
    }
    
    window.count++;
    
    // Set rate limit headers
    ctx.set('X-RateLimit-Limit', String(maxRequests));
    ctx.set('X-RateLimit-Remaining', String(Math.max(0, maxRequests - window.count)));
    ctx.set('X-RateLimit-Reset', String(Math.ceil(window.resetAt / 1000)));
    
    if (window.count > maxRequests) {
      ctx.status = 429;
      ctx.body = { error: 'Too Many Requests', retryAfter: Math.ceil((window.resetAt - now) / 1000) };
      return; // Short-circuit
    }
    
    await next();
  };
}

// CORS middleware factory
function cors(options: {
  origin: string | string[] | ((origin: string) => boolean);
  methods?: string[];
  allowedHeaders?: string[];
  credentials?: boolean;
  maxAge?: number;
}): KoaMiddleware {
  return async (ctx, next) => {
    const requestOrigin = ctx.get('Origin');
    
    // Determine if origin is allowed
    let allowed = false;
    if (typeof options.origin === 'string') {
      allowed = options.origin === '*' || options.origin === requestOrigin;
    } else if (Array.isArray(options.origin)) {
      allowed = options.origin.includes(requestOrigin || '');
    } else if (typeof options.origin === 'function') {
      allowed = options.origin(requestOrigin || '');
    }
    
    if (!allowed) {
      await next();
      return;
    }
    
    // Set CORS headers
    ctx.set('Access-Control-Allow-Origin', requestOrigin || '*');
    if (options.credentials) {
      ctx.set('Access-Control-Allow-Credentials', 'true');
    }
    
    // Handle preflight
    if (ctx.request.method === 'OPTIONS') {
      ctx.set('Access-Control-Allow-Methods', 
        (options.methods || ['GET', 'POST', 'PUT', 'DELETE']).join(', '));
      ctx.set('Access-Control-Allow-Headers',
        (options.allowedHeaders || ['Content-Type', 'Authorization']).join(', '));
      if (options.maxAge) {
        ctx.set('Access-Control-Max-Age', String(options.maxAge));
      }
      ctx.status = 204;
      ctx.body = '';
      return; // Short-circuit — don't call next()
    }
    
    await next();
  };
}

// Body parser middleware factory
function bodyParser(options: {
  jsonLimit?: number;
  textLimit?: number;
  enableTypes?: string[];
} = {}): KoaMiddleware {
  const { jsonLimit = 1_048_576, enableTypes = ['json'] } = options;
  
  return async (ctx, next) => {
    if (['GET', 'HEAD', 'DELETE'].includes(ctx.request.method)) {
      await next();
      return;
    }
    
    const contentType = ctx.get('Content-Type') || '';
    
    if (enableTypes.includes('json') && contentType.includes('application/json')) {
      const rawBody = await readRequestBody(ctx);
      
      if (rawBody.length > jsonLimit) {
        ctx.throw(413, `Body exceeds limit of ${jsonLimit} bytes`);
      }
      
      try {
        ctx.request.body = JSON.parse(rawBody);
      } catch {
        ctx.throw(400, 'Invalid JSON');
      }
    }
    
    await next();
  };
}

async function readRequestBody(ctx: Context): Promise<string> {
  // In real implementation, reads from Node.js IncomingMessage stream
  return JSON.stringify(ctx.request.body || '');
}

Error Propagation Strategies

Express Error Flow:

mw1 → mw2 → mw3(throws) → SKIP mw4 → SKIP mw5 → errorHandler
                  │                                      ↑
                  └──────── error propagates ────────────┘

- Errors propagate via next(error)
- Regular middleware is SKIPPED
- First error middleware (4 args) catches it
- If error middleware calls next(error), continues to next error handler


Koa Error Flow:

mw1 {                          ← catch error HERE
  try {
    mw2 {
      mw3 {  ← throws Error
      }
    }
  } catch (err) {
    // Handle error
  }
}

- Errors bubble up via Promise rejection
- Each middleware's try/catch is a natural boundary
- Outermost try/catch is the global error handler
- No special error middleware signature needed

Structured Error Handling

// Application error hierarchy
class AppError extends Error {
  constructor(
    message: string,
    public statusCode: number = 500,
    public code: string = 'INTERNAL_ERROR',
    public details?: any,
    public isOperational: boolean = true
  ) {
    super(message);
    this.name = 'AppError';
  }
  
  toJSON(): object {
    return {
      error: {
        code: this.code,
        message: this.message,
        ...(this.details ? { details: this.details } : {})
      }
    };
  }
}

class NotFoundError extends AppError {
  constructor(resource: string, id?: string) {
    super(
      id ? `${resource} '${id}' not found` : `${resource} not found`,
      404,
      'NOT_FOUND'
    );
  }
}

class ValidationError extends AppError {
  constructor(errors: Array<{ field: string; message: string }>) {
    super('Validation failed', 400, 'VALIDATION_ERROR', errors);
  }
}

class UnauthorizedError extends AppError {
  constructor(message: string = 'Authentication required') {
    super(message, 401, 'UNAUTHORIZED');
  }
}

class ForbiddenError extends AppError {
  constructor(message: string = 'Insufficient permissions') {
    super(message, 403, 'FORBIDDEN');
  }
}

// Global error handler middleware
function errorHandler(): KoaMiddleware {
  return async (ctx, next) => {
    try {
      await next();
    } catch (error: any) {
      // Operational errors — expected, handled gracefully
      if (error instanceof AppError && error.isOperational) {
        ctx.status = error.statusCode;
        ctx.body = error.toJSON();
        
        // Log at appropriate level
        if (error.statusCode >= 500) {
          console.error('[ERROR]', error.message, error.stack);
        } else {
          console.warn('[WARN]', error.message);
        }
        return;
      }
      
      // Programming errors — unexpected, log full stack
      console.error('[FATAL]', error);
      
      ctx.status = 500;
      ctx.body = {
        error: {
          code: 'INTERNAL_ERROR',
          message: process.env.NODE_ENV === 'production' 
            ? 'Internal Server Error'
            : error.message
        }
      };
      
      // In production, non-operational errors should trigger alerts
      // and potentially restart the process
    }
  };
}

Request Context & Dependency Injection

AsyncLocalStorage-Based Context

import { AsyncLocalStorage } from 'async_hooks';

interface RequestContext {
  requestId: string;
  traceId: string;
  userId?: string;
  tenantId?: string;
  startTime: number;
  logger: ContextLogger;
}

const requestStore = new AsyncLocalStorage<RequestContext>();

// Middleware to establish request context
function requestContext(): KoaMiddleware {
  return async (ctx, next) => {
    const requestId = ctx.get('X-Request-ID') || generateId();
    const traceId = ctx.get('X-Trace-ID') || generateId();
    
    const context: RequestContext = {
      requestId,
      traceId,
      startTime: Date.now(),
      logger: new ContextLogger(requestId, traceId)
    };
    
    // Set response headers
    ctx.set('X-Request-ID', requestId);
    ctx.set('X-Trace-ID', traceId);
    
    // Run all downstream middleware within this context
    await requestStore.run(context, async () => {
      await next();
    });
  };
}

// Access context from anywhere in the call stack
function getRequestContext(): RequestContext {
  const ctx = requestStore.getStore();
  if (!ctx) throw new Error('No request context available');
  return ctx;
}

class ContextLogger {
  constructor(
    private requestId: string, 
    private traceId: string
  ) {}
  
  info(message: string, data?: any): void {
    console.log(JSON.stringify({
      level: 'info',
      requestId: this.requestId,
      traceId: this.traceId,
      message,
      ...data,
      timestamp: new Date().toISOString()
    }));
  }
  
  error(message: string, error?: Error, data?: any): void {
    console.error(JSON.stringify({
      level: 'error',
      requestId: this.requestId,
      traceId: this.traceId,
      message,
      error: error ? { message: error.message, stack: error.stack } : undefined,
      ...data,
      timestamp: new Date().toISOString()
    }));
  }
}

function generateId(): string {
  return Math.random().toString(36).substring(2) + Date.now().toString(36);
}

Dependency Injection Container

type Factory<T> = (container: DIContainer) => T;

class DIContainer {
  private singletons: Map<string, any> = new Map();
  private factories: Map<string, Factory<any>> = new Map();
  private scoped: Map<string, any> = new Map();
  
  registerSingleton<T>(name: string, factory: Factory<T>): void {
    this.factories.set(`singleton:${name}`, factory);
  }
  
  registerTransient<T>(name: string, factory: Factory<T>): void {
    this.factories.set(`transient:${name}`, factory);
  }
  
  registerScoped<T>(name: string, factory: Factory<T>): void {
    this.factories.set(`scoped:${name}`, factory);
  }
  
  resolve<T>(name: string): T {
    // Check singleton cache
    if (this.singletons.has(name)) {
      return this.singletons.get(name);
    }
    
    // Check scoped cache
    if (this.scoped.has(name)) {
      return this.scoped.get(name);
    }
    
    // Try singleton factory
    const singletonFactory = this.factories.get(`singleton:${name}`);
    if (singletonFactory) {
      const instance = singletonFactory(this);
      this.singletons.set(name, instance);
      return instance;
    }
    
    // Try scoped factory
    const scopedFactory = this.factories.get(`scoped:${name}`);
    if (scopedFactory) {
      const instance = scopedFactory(this);
      this.scoped.set(name, instance);
      return instance;
    }
    
    // Try transient factory
    const transientFactory = this.factories.get(`transient:${name}`);
    if (transientFactory) {
      return transientFactory(this);  // New instance every time
    }
    
    throw new Error(`No registration found for '${name}'`);
  }
  
  createScope(): DIContainer {
    const scope = new DIContainer();
    scope.factories = this.factories;
    scope.singletons = this.singletons; // Share singletons
    return scope;
  }
}

// DI middleware
function dependencyInjection(container: DIContainer): KoaMiddleware {
  return async (ctx, next) => {
    // Create per-request scope
    const scope = container.createScope();
    ctx.state.container = scope;
    
    await next();
  };
}

Middleware Pipeline Optimization

Compiled Pipeline

// Instead of iterating a linked list, compile middleware into
// a single nested function for zero-overhead dispatch

class CompiledPipeline {
  private middleware: KoaMiddleware[] = [];
  private compiled: ((ctx: Context) => Promise<void>) | null = null;
  
  use(mw: KoaMiddleware): void {
    this.middleware.push(mw);
    this.compiled = null; // Invalidate cache
  }
  
  // Compile into nested function calls
  compile(): (ctx: Context) => Promise<void> {
    if (this.compiled) return this.compiled;
    
    const mws = [...this.middleware];
    
    // Build from inside out
    let fn: (ctx: Context) => Promise<void> = async () => {};
    
    for (let i = mws.length - 1; i >= 0; i--) {
      const current = mws[i];
      const downstream = fn;
      
      fn = async (ctx: Context) => {
        await current(ctx, () => downstream(ctx));
      };
    }
    
    this.compiled = fn;
    return fn;
  }
  
  async handle(ctx: Context): Promise<void> {
    const handler = this.compile();
    await handler(ctx);
  }
}

Lazy Middleware Loading

// Load middleware only when first needed
function lazy(
  loader: () => Promise<KoaMiddleware>
): KoaMiddleware {
  let cached: KoaMiddleware | null = null;
  
  return async (ctx, next) => {
    if (!cached) {
      cached = await loader();
    }
    await cached(ctx, next);
  };
}

// Usage: heavy middleware loaded on demand
app.use(lazy(async () => {
  const { createPrometheusMiddleware } = await import('./metrics');
  return createPrometheusMiddleware();
}));

Framework Comparison: Middleware Internals

┌──────────────┬────────────────┬────────────────┬────────────────┬────────────────┐
│ Feature      │ Express        │ Koa            │ Fastify        │ Hono           │
├──────────────┼────────────────┼────────────────┼────────────────┼────────────────┤
│ Model        │ Linear         │ Onion          │ Hooks/Encaps.  │ Onion          │
│ Async        │ Callback       │ async/await    │ async/await    │ async/await    │
│ Error Flow   │ next(err)      │ try/catch      │ Reply error    │ try/catch      │
│ Context      │ req + res      │ ctx            │ request+reply  │ c (Context)    │
│ Composition  │ app.use()      │ app.use()      │ register()     │ app.use()      │
│ Encapsulation│ None           │ None           │ Plugin scope   │ Route groups   │
│ Performance  │ ~15K req/s     │ ~20K req/s     │ ~75K req/s     │ ~100K req/s    │
│ Validation   │ External       │ External       │ JSON Schema    │ Zod/Valibot    │
│ Serialization│ res.json()     │ ctx.body =     │ Schema-based   │ c.json()       │
│              │                │                │ (10x faster)   │                │
└──────────────┴────────────────┴────────────────┴────────────────┴────────────────┘

Fastify's Encapsulated Plugins:

┌─── Root Scope ──────────────────────────────────────────┐
│  Global middleware (auth, logging)                       │
│                                                         │
│  ┌─── Plugin A Scope ───────────────────────────────┐   │
│  │  Plugin-local middleware (rate limit)             │   │
│  │  Routes: /api/users/*                            │   │
│  │  Decorators visible only to this plugin           │   │
│  └───────────────────────────────────────────────────┘   │
│                                                         │
│  ┌─── Plugin B Scope ───────────────────────────────┐   │
│  │  Different middleware (caching)                   │   │
│  │  Routes: /api/products/*                         │   │
│  │  Cannot access Plugin A's decorators             │   │
│  └───────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘

Fastify uses DAG (directed acyclic graph) for plugin loading.
Each plugin scope inherits parent decorators but not sibling.

Real-World Middleware Stack

Production Middleware Order (matters!):

1. Request ID / Correlation ────── Assigns unique ID first
   │
2. Request Logging (before) ────── Log incoming request
   │
3. Error Handler (outermost) ───── Catches everything below
   │
4. Security Headers ────────────── Helmet: CSP, HSTS, etc.
   │
5. CORS ────────────────────────── Must come before routes
   │
6. Rate Limiting ───────────────── Reject before parsing body
   │
7. Body Parsing ────────────────── Parse JSON/form/multipart
   │
8. Authentication ──────────────── Verify JWT/session
   │
9. Authorization ───────────────── Check permissions
   │
10. Validation ─────────────────── Validate request body/params
    │
11. Route Handler ──────────────── Business logic
    │
12. Response Serialization ─────── Consistent response format
    │
13. Response Logging (after) ───── Log response status + timing
    │
14. 404 Handler ────────────────── No route matched

Why order matters:
- Rate limiting before body parsing → saves CPU on rejected requests
- Auth before validation → don't validate unauthorized requests
- CORS before routes → preflight OPTIONS must respond without auth
- Error handler outermost → catches errors from all layers
- Request ID first → all logs include correlation ID

Interview Questions

Q1: Explain the difference between Express's linear middleware model and Koa's onion model. What problem does the onion model solve?

Express uses callbacks: (req, res, next). When middleware calls next(), control moves to the next middleware. There is no built-in mechanism to execute code AFTER downstream middleware completes. To log response time, you'd need to hook into the res.on('finish') event, which is outside the middleware chain. Error handling requires a separate signature (err, req, res, next) and errors must be explicitly passed via next(err).

Koa uses async functions: async (ctx, next). When middleware calls await next(), it suspends execution, all downstream middleware runs, and then control returns to the line after await next(). This creates a natural before/after execution pattern — the "onion" — where each middleware wraps the next. Error handling uses standard try/catch. The onion model solves three problems: (1) guaranteed post-processing code after downstream execution, (2) natural error propagation via Promise rejection, and (3) timing/wrapping logic is trivial to implement.

Q2: How does Express determine whether a middleware is an error handler vs a regular middleware?

Express checks the function's length property (arity — number of declared parameters). A function with 4 parameters (err, req, res, next) is treated as an error handler. A function with 3 or fewer parameters (req, res, next) is a regular middleware. When an error is propagated via next(err), Express's router skips all regular middleware and only calls error middleware. This is why arrow functions with default parameters or rest parameters can break error handling — they may report a different arity. It's a fragile design based on JavaScript metaprogramming rather than explicit registration, which is one reason Koa replaced it with try/catch.

Q3: In what order should middleware be registered, and why does it matter?

Order determines execution sequence and has major performance and security implications. The recommended order is: (1) Request ID/correlation first, so all downstream logs include it. (2) Error handler as outermost wrapper to catch all errors. (3) Security headers (Helmet) early to ensure every response gets them. (4) CORS before authentication, because preflight OPTIONS requests must succeed without auth. (5) Rate limiting before body parsing — rejecting rate-limited requests before parsing saves CPU. (6) Body parsing before validation. (7) Authentication before authorization. (8) Route handlers last. Incorrect ordering causes subtle bugs: putting auth before CORS breaks preflight; putting body parsing before rate limiting wastes CPU on rejected requests; putting logging after error handling misses error responses.

Q4: How would you implement a middleware that works across both Express and Koa?

Create a framework-agnostic middleware factory that detects the runtime and adapts. The core logic operates on a normalized context object. The factory returns a function with the appropriate signature. For Express: (req, res, next) => { ... } with next(err) for errors. For Koa: async (ctx, next) => { ... } with await next() and try/catch. In practice, most middleware libraries (like cors, helmet) do this by exporting two entry points or using a wrapper layer. Alternatively, use a universal middleware format like the one in universal-middleware packages that compile to both Express and Koa signatures, normalizing request/response access into a shared interface.

Q5: What is Fastify's encapsulated plugin system, and how does it differ from Express middleware?

Fastify uses a DAG (directed acyclic graph) of plugins instead of a flat middleware array. Each plugin call to fastify.register() creates an isolated scope. Middleware (called "hooks" in Fastify) registered inside a plugin only applies to routes within that plugin. Child plugins inherit parent decorators and hooks, but sibling plugins are completely isolated. This prevents a common Express problem where one team's middleware accidentally affects another team's routes. Additionally, Fastify plugins support async loading — a plugin can asynchronously configure itself (connect to a database, load config) and Fastify guarantees all parent plugins are fully loaded before child plugins start. This creates a deterministic boot sequence, unlike Express where middleware registration order is manual and error-prone across large codebases.


Real-World Problems & How to Solve Them

Problem 1: Requests hang forever on certain routes

Symptom: Some requests never finish and eventually time out at the load balancer.

Root cause: In a linear pipeline, a middleware path neither sends a response nor calls next(). This is a common branch bug in Express-style flow.

Fix — Enforce exactly-once next() behavior with a guard wrapper:

import type { Request, Response, NextFunction } from "express";

type Middleware = (req: Request, res: Response, next: NextFunction) => void | Promise<void>;

function safeMiddleware(mw: Middleware): Middleware {
  return async (req, res, next) => {
    let nextCalled = false;
    const guardedNext: NextFunction = (err?: unknown) => {
      if (nextCalled) return;
      nextCalled = true;
      next(err as any);
    };

    await Promise.resolve(mw(req, res, guardedNext));

    if (!nextCalled && !res.headersSent) {
      guardedNext(new Error("Middleware finished without response or next()"));
    }
  };
}

Problem 2: ERR_HTTP_HEADERS_SENT in production

Symptom: Logs show header-sent errors and clients receive partial or broken responses.

Root cause: Multiple middleware layers attempt to write the response (short-circuit + downstream writer), especially when next() is called after res.json().

Fix — Centralize response writes with a send-once helper:

import type { Response } from "express";

function sendJsonOnce(res: Response, status: number, body: unknown): void {
  if (res.headersSent || res.writableEnded) return;
  res.status(status).json(body);
}

function authMiddleware(req: any, res: Response, next: any): void {
  if (!req.user) {
    sendJsonOnce(res, 401, { error: "Unauthorized" });
    return;
  }
  next();
}

Problem 3: Async route errors bypass error middleware

Symptom: Unhandled promise rejections appear, but custom error middleware is not triggered.

Root cause: Express 4 does not automatically forward async throw/rejection to next(err).

Fix — Wrap async handlers so rejections propagate through the error pipeline:

import type { Request, Response, NextFunction, RequestHandler } from "express";

function asyncHandler(
  fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>,
): RequestHandler {
  return (req, res, next) => {
    void fn(req, res, next).catch(next);
  };
}

app.get(
  "/orders/:id",
  asyncHandler(async (req, res) => {
    const order = await orderService.getById(req.params.id);
    if (!order) throw new Error("Order not found");
    res.json(order);
  }),
);

Problem 4: Trace IDs disappear between middleware and services

Symptom: Logs from the same request show different or missing correlation IDs.

Root cause: Request context is stored on mutable globals instead of async-local context, so concurrent requests overwrite each other.

Fix — Use AsyncLocalStorage for per-request context propagation:

import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";

type RequestContext = { traceId: string; userId?: string };
const contextStore = new AsyncLocalStorage<RequestContext>();

export function contextMiddleware(req: any, _res: any, next: any): void {
  const traceId = req.headers["x-trace-id"] ?? randomUUID();
  contextStore.run({ traceId, userId: req.user?.id }, next);
}

export function getContext(): RequestContext {
  const ctx = contextStore.getStore();
  if (!ctx) throw new Error("Request context unavailable");
  return ctx;
}

Problem 5: Middleware order causes security and performance regressions

Symptom: CPU spikes on large unauthenticated payloads and rate limits trigger too late.

Root cause: Heavy parsers run before cheap rejection middleware (auth/rate limit), and error handling is not wrapped at the edge.

Fix — Register middleware in a strict, tested order:

import express from "express";

const app = express();

app.use(requestIdMiddleware);
app.use(accessLogMiddleware);
app.use(corsMiddleware);
app.use(rateLimitMiddleware); // reject early
app.use(authMiddleware);      // reject before expensive parsing
app.use(express.json({ limit: "1mb" }));
app.use("/api", apiRouter);
app.use(notFoundMiddleware);
app.use(globalErrorMiddleware);

Problem 6: Shared middleware fails when moving from Express to Koa

Symptom: A middleware package works in Express but silently breaks in Koa/Hono services.

Root cause: Express uses callback-style next(), while onion frameworks require await next() and allow post-processing on unwind.

Fix — Build an adapter that normalizes middleware contracts:

type ExpressMw = (req: any, res: any, next: (err?: unknown) => void) => void;

function expressToKoa(expressMw: ExpressMw) {
  return async (ctx: any, next: () => Promise<void>): Promise<void> => {
    await new Promise<void>((resolve, reject) => {
      expressMw(ctx.req, ctx.res, (err?: unknown) => {
        if (err) reject(err);
        else resolve();
      });
    });

    if (!ctx.res.writableEnded) {
      await next();
    }
  };
}

Key Takeaways

  1. Middleware is the backbone of backend request processing: Every HTTP framework uses a middleware pipeline. Understanding the composition model (linear vs onion) determines how you structure error handling, logging, and auth.

  2. The onion model (Koa/Hono) is superior for before/after logic: await next() guarantees post-processing. Express requires event listeners or response patching to achieve the same effect.

  3. Express detects error handlers by function arity (4 params): This fragile metaprogramming pattern breaks with arrow functions and rest parameters. Koa's try/catch is more robust.

  4. Middleware order is a security and performance concern: CORS before auth, rate limiting before body parsing, and error handling as the outermost wrapper are non-negotiable patterns.

  5. Composition patterns unlock reusable middleware: unless(), branch(), factories with options, and lazy loading let you build complex pipelines from simple, testable pieces.

  6. AsyncLocalStorage provides implicit request context: Instead of passing req through every function, store context in CLS (continuation-local storage) for automatic propagation across async boundaries.

  7. Compiled pipelines eliminate dispatch overhead: Building a nested function from the middleware array at startup avoids the per-request iteration overhead of dynamic dispatch.

  8. Fastify's encapsulation model prevents cross-contamination: Plugin scoping isolates middleware, decorators, and hooks between different parts of the application — critical for large codebases.

  9. Error hierarchies (AppError subclasses) enable structured error handling: Operational errors return appropriate HTTP status codes. Programming errors return 500 and trigger alerts. The error middleware distinguishes between them.

  10. Body parsing, validation, and serialization should be schema-driven: Fastify proves this with JSON Schema — schema-based serialization is 10x faster than JSON.stringify() because the shape is known at compile time.

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.