Skip to content

ADR-014: Per-Method Interceptor Routing (createMethodFilterInterceptor) ​

Status: Accepted - 2026-02-07; revised - 2026-10-08

Revision (2026-10-08): Keep the implemented MethodFilterMap API. The previously proposed MethodFilterOptions, options overload, and filter-wide skipStreaming are cancelled. Method patterns apply to both unary and streaming calls; each nested interceptor owns its call-kind policy.

Deciders: Tech Lead, Platform Team

Tags: interceptors, per-method, routing, filter, wildcard, moleculer-inspired

Supersedes: Original ADR-014 (Per-Method Action Hooks) -- rejected after Codex Debater review due to introducing a second execution model alongside interceptors.


Context ​

Problem: no per-method customization ​

Connectum interceptors form a flat chain applied to all RPC methods uniformly. The current interceptor chain in createServer() (see ADR-006):

Limitation:

If checkAuth should only apply to UserService/GetUser and rateLimit only to PaymentService/*, the only option is writing a custom interceptor with manual filtering:

typescript
// Current approach: manual filtering inside the interceptor
const authInterceptor: Interceptor = (next) => async (req) => {
  if (req.method.name === "GetUser" && req.service.typeName === "UserService") {
    await checkAuth(req);
  }
  return next(req);
};

This leads to:

  • Boilerplate: every custom interceptor contains if/else filtering logic
  • No standard API: each developer implements filtering differently
  • Composition difficulty: no declarative way to describe per-method behavior

Rejected approach: Action Hooks ​

An approach with hooks (before/after/error) inspired by Moleculer was initially considered. Rejected after critical analysis:

  1. Two execution models: interceptors + hooks create confusion ("where do I add auth check?")
  2. Increased core surface area: hooks added new types (BeforeHook, AfterHook, ErrorHook, HookContext) to @connectum/core
  3. Error handling anti-pattern: error hooks with chain interruption (return true) contradicted standard ConnectRPC error handling via ConnectError

Codex Debater recommendation: keep the single interceptor pattern, add a convenience helper for per-method routing.

Current interceptors (12 total) ​

CategoryInterceptors
Core (builtin)errorHandler, logger, serializer, tracing, validation, redact
AuthaddToken (JWT)
Resilience (opt-in)retry, circuit-breaker, timeout, bulkhead, fallback

Decision ​

Implement createMethodFilterInterceptor in @connectum/interceptors -- a convenience helper for per-method interceptor routing within the single interceptor pattern.

API Design ​

typescript
import { createMethodFilterInterceptor } from "@connectum/interceptors";

const perMethodInterceptor = createMethodFilterInterceptor({
  // Exact match: specific method
  "user.v1.UserService/GetUser": [checkAuth, enrichUser],

  // Service wildcard: all methods of a service
  "payment.v1.PaymentService/*": [validatePayment, auditLog],

  // Global wildcard: all methods
  "*": [logRequest],
});

const server = createServer({
  services: [routes],
  interceptors: [perMethodInterceptor],
});

createMethodFilterInterceptor Signature ​

The MethodFilterOptions interface and the options overload below were part of the original decision; they were cancelled by the 2026-10-08 revision and were never implemented.

typescript
import type { Interceptor } from "@connectrpc/connect";

/**
 * Method pattern -> array of interceptors mapping.
 *
 * Patterns:
 * - "*" -- matches all methods
 * - "package.Service/*" -- matches all methods of a service
 * - "package.Service/Method" -- matches exact method
 *
 * Key format: service.typeName + "/" + method.name (full protobuf path)
 */
type MethodFilterMap = Record<string, Interceptor[]>;

interface MethodFilterOptions {
  /**
   * Per-method interceptor routing map.
   */
  methods: MethodFilterMap;

  /**
   * Skip streaming calls for all interceptors in this filter.
   * @default false
   */
  skipStreaming?: boolean;
}

/**
 * Create a single interceptor that routes to per-method interceptors
 * based on wildcard pattern matching.
 *
 * Resolution order (all matching patterns execute):
 * 1. Global wildcard "*" (executed first)
 * 2. Service wildcard "Service/*" (executed second)
 * 3. Exact match "Service/Method" (executed last)
 *
 * Within each pattern, interceptors execute in array order.
 */
function createMethodFilterInterceptor(
  methods: MethodFilterMap
): Interceptor;

// Overload with options
function createMethodFilterInterceptor(
  options: MethodFilterOptions
): Interceptor;

Streaming policy ​

The filter applies the same matching and composition rules to unary and streaming calls. It does not inspect or consume stream messages itself. A matching interceptor receives the call and decides whether and how to handle streaming.

The timeout, retry, bulkhead, circuit-breaker, and fallback interceptors already have their own skipStreaming policy, enabled by default. Their guards leave other matching middleware, such as authentication and logging, active. Skipping the entire filter would also bypass those other interceptors.

The cancelled options form added a convenience for excluding a whole group of custom middleware from streaming. No separate need for that group option was established in the reviewed framework or examples. A standard custom interceptor can compose a call-kind guard when an application needs it. Retaining the map keeps the routing API focused; it does not imply that external applications can never need such a guard.

See Per-Method Interceptor Routing for the map API and the distinction between method routing and nested skip* policies.

Execution Order ​

All matching patterns execute sequentially (from general to specific):

This follows the ConnectRPC interceptor chain model -- each interceptor calls next(req), forming a nested chain.

Usage Examples ​

typescript
// === Example 1: Auth per service ===
createMethodFilterInterceptor({
  "*": [logRequest],
  "admin.v1.AdminService/*": [requireAdmin],
  "user.v1.UserService/DeleteUser": [requireAdmin, auditLog],
});

// === Example 2: Resilience per method ===
createMethodFilterInterceptor({
  // 5s timeout for fast operations
  "catalog.v1.CatalogService/GetProduct": [
    createTimeoutInterceptor({ duration: 5_000 }),
  ],
  // 30s timeout for heavy operations
  "report.v1.ReportService/*": [
    createTimeoutInterceptor({ duration: 30_000 }),
    createCircuitBreakerInterceptor({ threshold: 3 }),
  ],
});

// === Example 3: Combine with server interceptors ===
createServer({
  services: [routes],
  interceptors: [
    // Global interceptors (all methods)
    createDeadlineInterceptor({ defaultTimeout: 30_000 }),
    // Per-method interceptors
    createMethodFilterInterceptor({
      "payment.v1.PaymentService/*": [
        createCircuitBreakerInterceptor({ threshold: 5 }),
        createRetryInterceptor({ maxRetries: 3 }),
      ],
    }),
  ],
});

Pattern Matching Implementation ​

typescript
// Matching logic (simplified)
function matchesPattern(
  pattern: string,
  serviceName: string,
  methodName: string,
): boolean {
  if (pattern === "*") return true;
  if (pattern.endsWith("/*")) {
    const servicePattern = pattern.slice(0, -2);
    return serviceName === servicePattern;
  }
  return pattern === `${serviceName}/${methodName}`;
}

// Pre-compiled at creation time for performance
// No regex -- simple string comparison and endsWith check

Performance ​

  • Pattern matching: O(n) where n = number of patterns. Pre-compiled map lookup for exact matches, linear scan only for wildcards.
  • Optimization: At createMethodFilterInterceptor() call time, patterns are split into 3 groups (global, service, exact). Exact matches use Map lookup O(1). Service wildcards use Map lookup O(1). Global always executes.
  • Overhead: 1 Map lookup + 1 Map lookup per request. Negligible for real workloads.

Consequences ​

Positive ​

  • Single execution model: Interceptors only. No "interceptor vs hook" confusion. ConnectRPC interceptor pattern is the only pattern for cross-cutting concerns.
  • Per-method routing without boilerplate: Declarative configuration instead of if/else in every interceptor.
  • Composable: createMethodFilterInterceptor returns a regular Interceptor. Multiple instances can be used and combined with other interceptors.
  • Package: @connectum/interceptors (Layer 1): Does not increase @connectum/core surface area. Follows ADR-003.
  • Wildcards: * for global, Service/* for service-level -- covers typical use cases.
  • Zero new concepts: Uses existing ConnectRPC Interceptor types. No BeforeHook, AfterHook, HookContext, or other new types.

Negative ​

  • Interceptor nesting: Each pattern creates a nested interceptor chain. For 10+ patterns the call stack may become deep. Mitigation: >10 patterns is rare in production; performance benchmark in Phase 1.
  • No after/error specific handling: Unlike hooks, an interceptor sees the entire lifecycle (before+after+error in one). Separate after/error handling requires a custom interceptor. Mitigation: this is the standard ConnectRPC pattern, not a limitation.
  • Limited pattern syntax: Only 3 variants (, Service/, Service/Method). No regex, no multi-level wildcards. Mitigation: covers 95% of use cases; for complex routing use a custom interceptor.

Alternatives Considered ​

Alternative 1: Action Hooks (before/after/error) -- REJECTED ​

Rating: 4/10

Description: Hooks API in createServer() options -- a 3-level hook system inspired by Moleculer.

Pros: Declarative, per-method, separate before/after/error. Cons: Second execution model, increases core surface area, error chain interruption anti-pattern. Why rejected: Codex Debater analysis revealed a fundamental issue: two execution models (interceptors + hooks) create confusion. A single interceptor pattern is cleaner and simpler.

Alternative 2: Filter function per interceptor ​

Rating: 5/10

typescript
createTimeoutInterceptor({
  duration: 5000,
  filter: (req) => req.service.typeName === "PaymentService",
});

Pros: Simple implementation, single pattern. Cons: Each interceptor duplicates filter logic, no centralized configuration, no wildcards. Why rejected: Distributed filter logic instead of centralized routing. createMethodFilterInterceptor provides a single configuration point.

Alternative 3: Decorator pattern (@Method) ​

Rating: 3/10

Why rejected: Node.js 25.2.0+ type stripping does not support decorators (ADR-001). ConnectRPC uses function-based handlers, not classes.


Implementation Plan ​

Phase 1: Core Implementation (v0.2.x) ​

Package: @connectum/interceptors

New files:

  • packages/interceptors/src/method-filter.ts -- createMethodFilterInterceptor implementation
  • packages/interceptors/tests/unit/method-filter.test.ts -- unit tests

Modified files:

  • packages/interceptors/src/index.ts -- export createMethodFilterInterceptor
  • packages/interceptors/src/types.ts -- MethodFilterMap, MethodFilterOptions types (MethodFilterOptions cancelled 2026-10-08, never implemented)

Tests:

  • Pattern matching: , Service/, Service/Method
  • Execution order: global -> service -> exact
  • Empty pattern array (no-op)
  • Multiple matching patterns
  • skipStreaming option (cancelled 2026-10-08)
  • Integration with existing interceptors

References ​

  1. ADR-006: Resilience Pattern Implementation -- interceptor chain, resilience patterns
  2. ADR-010: Framework vs Infrastructure (internal planning document) -- optional interceptors, boundary
  3. ADR-003: Package Decomposition -- Layer 1: @connectum/interceptors
  4. ConnectRPC Interceptors -- interceptor model
  5. Moleculer Action Hooks -- inspiration (rejected approach)

Changelog ​

DateAuthorChange
2026-02-07Tech LeadInitial ADR: Per-Method Action Hooks (rejected after review)
2026-02-07Tech LeadRewrite: createMethodFilterInterceptor (single interceptor model). Status: Accepted
2026-10-08Maintainer revisionRetain the map API; cancel MethodFilterOptions, the options overload, and filter-wide skipStreaming. Nested interceptors retain their own streaming policies.