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:
// 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:
- Two execution models: interceptors + hooks create confusion ("where do I add auth check?")
- Increased core surface area: hooks added new types (BeforeHook, AfterHook, ErrorHook, HookContext) to
@connectum/core - Error handling anti-pattern: error hooks with chain interruption (
return true) contradicted standard ConnectRPC error handling viaConnectError
Codex Debater recommendation: keep the single interceptor pattern, add a convenience helper for per-method routing.
Current interceptors (12 total)
| Category | Interceptors |
|---|---|
| Core (builtin) | errorHandler, logger, serializer, tracing, validation, redact |
| Auth | addToken (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
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.
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
// === 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
// 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 checkPerformance
- 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/coresurface 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
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 implementationpackages/interceptors/tests/unit/method-filter.test.ts-- unit tests
Modified files:
packages/interceptors/src/index.ts-- export createMethodFilterInterceptorpackages/interceptors/src/types.ts-- MethodFilterMap, MethodFilterOptions types (MethodFilterOptionscancelled 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
- ADR-006: Resilience Pattern Implementation -- interceptor chain, resilience patterns
- ADR-010: Framework vs Infrastructure (internal planning document) -- optional interceptors, boundary
- ADR-003: Package Decomposition -- Layer 1: @connectum/interceptors
- ConnectRPC Interceptors -- interceptor model
- Moleculer Action Hooks -- inspiration (rejected approach)
Changelog
| Date | Author | Change |
|---|---|---|
| 2026-02-07 | Tech Lead | Initial ADR: Per-Method Action Hooks (rejected after review) |
| 2026-02-07 | Tech Lead | Rewrite: createMethodFilterInterceptor (single interceptor model). Status: Accepted |
| 2026-10-08 | Maintainer revision | Retain the map API; cancel MethodFilterOptions, the options overload, and filter-wide skipStreaming. Nested interceptors retain their own streaming policies. |