Documentation/titan/DI Middleware
In this section

DI Middleware

DI middleware wraps container resolution. It runs at construction time, not at call time. Use it to add cross-cutting behaviour to how providers are constructed, not to how their methods are called.

import {
  type Middleware,
  type MiddlewareFunction,
  type MiddlewareNext,
  type MiddlewareResult,
  createMiddleware,
  composeMiddleware,
  MiddlewarePipeline,
  // Built-ins
  RetryMiddleware,
  LoggingMiddleware,
  CachingMiddleware,
  RateLimitMiddleware,
  ValidationMiddleware,
  TransactionMiddleware,
  CircuitBreakerMiddleware,
} from '@omnitron-dev/titan/nexus';

DI middleware ≠ Netron middleware. This page covers wrapping the resolution of providers. For wrapping per-call RPC dispatch, see Netron Middleware.

The Middleware interface

interface Middleware<T = unknown> {
  name:     string;
  execute:  (context: MiddlewareContext<T>, next: () => T | Promise<T>) => T | Promise<T>;
  priority?: number;
  condition?: (context: MiddlewareContext<T>) => boolean;
  onError?:   (error: Error, context: MiddlewareContext<T>) => void;
}

context carries:

  • token — the token being resolved.

  • scope — current resolution scope.

  • parent — the resolution that triggered this one (depth + cycle tracking).

next() resolves the provider; call exactly once and return the value.

Built-in middleware

flowchart LR
  Resolve[container.resolve]
  Resolve --> M1[Logging]
  M1 --> M2[Retry]
  M2 --> M3[Caching]
  M3 --> M4[CircuitBreaker]
  M4 --> M5[Provider executes]
MiddlewarePurpose
RetryMiddlewareRetry construction if it throws
LoggingMiddlewareLog every resolution with timing
CachingMiddlewareCache resolved instances by token + context
RateLimitMiddlewareReject resolutions exceeding a threshold
ValidationMiddlewareValidate resolved instance against a schema
TransactionMiddlewareWrap resolution in a transaction (DB-aware contexts)
CircuitBreakerMiddlewareOpen a circuit after consecutive resolution failures

These come in two flavours. LoggingMiddleware, CachingMiddleware, RetryMiddleware, ValidationMiddleware, and TransactionMiddleware are pre-built middleware objects (export const … = createMiddleware(…)) — add them directly. CircuitBreakerMiddleware and RateLimitMiddleware are classes you instantiate with new (e.g. new RateLimitMiddleware(100, 60000)).

Applying middleware

Middleware is added one at a time via container.addMiddleware(), which returns the container for chaining:

const container = createContainer();

container.addMiddleware(LoggingMiddleware);
container.addMiddleware(CachingMiddleware);
container.addMiddleware(new CircuitBreakerMiddleware({ threshold: 5 }));
container.addMiddleware(new RateLimitMiddleware(100, 60000)); // 100/min

Execution order is governed by each middleware's priority field (higher priority runs first); the container sorts on insertion. There is no array-position ordering — set priority explicitly when order matters.

Custom middleware via createMiddleware

import { createMiddleware, getTokenName } from '@omnitron-dev/titan/nexus';

const InstrumentationMiddleware = createMiddleware({
  name:    'instrumentation',
  execute: async (ctx, next) => {
    const t0 = performance.now();
    try {
      const instance = await next();
      // `ctx.token` is a ServiceIdentifier: a class, a string, a symbol or a
      // Token. Only two of those have `.name`, so use the exported helper.
      metrics.histogram('di.resolve.ms', { token: getTokenName(ctx.token) })
        .observe(performance.now() - t0);
      return instance;
    } catch (e) {
      metrics.counter('di.resolve.errors', { token: getTokenName(ctx.token) }).inc();
      throw e;
    }
  },
});

container.addMiddleware(InstrumentationMiddleware);

Composing middleware

composeMiddleware(...middlewares) takes middleware as rest arguments and produces a single middleware that chains them. Useful for grouping related middleware as a single unit, then adding the composite with one addMiddleware() call.

MiddlewarePipeline is the class that drives execution; the container creates one internally, but you can construct one yourself if you need to test middleware in isolation.

Conditional execution

Middleware can opt out per resolution via condition:

const ExpensiveMiddleware: Middleware = {
  name: 'expensive',
  condition: (ctx) => ctx.token === MY_TOKEN,
  execute: async (ctx, next) => { /* … */ return next(); },
};

When condition(ctx) returns false the middleware is skipped for that resolution and next() runs directly.

Per-token middleware

Some middleware suites support per-token application. Check the specific built-in for whether it accepts a tokens filter, and the provider definition for a middleware: field. The exact surface varies and is documented in the source for each middleware.

When to write DI middleware

Custom DI middleware is rare. The built-ins cover most cases. Write your own when you need:

  • Project-specific telemetry — emit to a metrics backend the built-ins don't know about.

  • Cross-cutting validation — assert that every resolved provider matches a schema.

  • Tracing instrumentation — attach a span to every resolution.

  • Test-only middleware — capture the resolution graph for a test assertion.

Most application code does not need DI middleware at all.

Anti-patterns

  • Putting business logic in DI middleware. It runs at construction time, not per call. Method-call concerns belong in Netron middleware.

  • Side effects in middleware. Middleware should observe and decorate, not mutate the application. A middleware that registers more providers is a smell.

  • Async work that should be in onStart. Heavy setup belongs in lifecycle hooks where the framework can sequence it.