DEV Community

Harsh Mathur
Harsh Mathur

Posted on

I replaced 6 @Injectable() gRPC interceptors with composable factories

Our Angular monorepo talks to a gRPC backend. Every service call passes through six interceptors: auth token injection, custom metadata, retry with backoff, deadline enforcement, request logging, and error mapping. That means six files, six @Injectable() decorators, six classes implementing GrpcInterceptor, and a provider array where the order matters but nothing enforces it.

I replaced all of it with composable factory functions. One provider call in app.config.ts. No classes, no decorators, no manual provider registration. The library is nx-grpc-kit.

The problem: class-based interceptor sprawl

Here is what a single interceptor looked like in the old setup:

@Injectable()
export class AuthInterceptor implements GrpcInterceptor {
  constructor(private authService: AuthService) {}

  intercept<T>(
    request: GrpcRequest<T>,
    next: GrpcHandler
  ): Observable<GrpcEvent<T>> {
    const token = this.authService.getToken();
    if (token) {
      request.metadata.set('Authorization', `Bearer ${token}`);
    }
    return next.handle(request);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now multiply that by six. Each interceptor: its own file, its own test file, its own constructor injecting whatever it needs. The provider registration looked like this:

providers: [
  { provide: GRPC_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
  { provide: GRPC_INTERCEPTORS, useClass: MetadataInterceptor, multi: true },
  { provide: GRPC_INTERCEPTORS, useClass: RetryInterceptor, multi: true },
  { provide: GRPC_INTERCEPTORS, useClass: DeadlineInterceptor, multi: true },
  { provide: GRPC_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },
  { provide: GRPC_INTERCEPTORS, useClass: ErrorMappingInterceptor, multi: true },
]
Enter fullscreen mode Exit fullscreen mode

Six lines where order matters silently. Swap two and you get auth tokens on retried requests that should not have them, or logging that misses error-mapped responses. No type safety on the ordering. No way to see the full pipeline at a glance.

The solution: one provider call

Here is the same interceptor stack with nx-grpc-kit:

import {
  provideGrpcInterceptors,
  withAuth,
  withMetadata,
  withRetry,
  withDeadline,
  withLogging,
  withErrorMapping,
  GrpcStatusCode,
} from 'nx-grpc-kit';

export const appConfig: ApplicationConfig = {
  providers: [
    provideGrpcInterceptors(
      withAuth(() => authStore.getToken()),
      withMetadata(() => ({
        'x-org-id': authStore.selectedOrgId(),
        'x-trace-id': crypto.randomUUID(),
      })),
      withRetry({ maxRetries: 3, initialDelayMs: 200 }),
      withDeadline(15_000),
      withLogging({ enabled: !environment.production }),
      withErrorMapping((err) => {
        if (err.statusCode === GrpcStatusCode.UNAUTHENTICATED) {
          return new Error('Session expired — please log in again');
        }
        return err;
      }),
    ),
  ],
};
Enter fullscreen mode Exit fullscreen mode

That is the entire gRPC interceptor setup. Six interceptors, one call, execution order matches reading order. If you have used Angular's provideHttpClient(withInterceptors(...)) pattern, this will feel instantly familiar — that is intentional.

Walking through each interceptor

withAuth(tokenFn)

Injects a bearer token into the Authorization metadata header. The token function can return a string or a Promise<string> for async token refresh:

withAuth(() => localStorage.getItem('access_token'))
// or async:
withAuth(() => authService.refreshAndGetToken())
Enter fullscreen mode Exit fullscreen mode

withMetadata(metadataFn)

Injects arbitrary key-value metadata into every request. Useful for org context, trace IDs, feature flags:

withMetadata(() => ({
  'x-org-id': orgStore.currentId(),
  'x-feature-flags': featureStore.activeFlags().join(','),
}))
Enter fullscreen mode Exit fullscreen mode

withRetry(config)

Exponential backoff with jitter on transient gRPC errors. Configurable retry count, delay bounds, and which status codes to retry:

withRetry({
  maxRetries: 3,
  initialDelayMs: 100,
  maxDelayMs: 5000,
  backoffMultiplier: 2,
  retryableStatuses: [
    GrpcStatusCode.UNAVAILABLE,
    GrpcStatusCode.DEADLINE_EXCEEDED,
    GrpcStatusCode.RESOURCE_EXHAUSTED,
  ],
})
Enter fullscreen mode Exit fullscreen mode

The defaults handle the common case. Most setups only need withRetry() with no arguments.

withDeadline(ms)

Aborts the request after a timeout and surfaces DEADLINE_EXCEEDED. Simple but critical for preventing hung requests from blocking the UI:

withDeadline(10_000) // 10 seconds
Enter fullscreen mode Exit fullscreen mode

withLogging(options)

Logs request method, metadata, response time, and errors. Disabled by default in production:

withLogging({ enabled: !environment.production })
Enter fullscreen mode Exit fullscreen mode

withErrorMapping(mapFn)

Transforms raw gRPC status errors into application-specific errors. This is where you catch UNAUTHENTICATED and redirect to login, or translate NOT_FOUND into a user-friendly message:

withErrorMapping((err) => {
  switch (err.statusCode) {
    case GrpcStatusCode.UNAUTHENTICATED:
      router.navigate(['/login']);
      return new Error('Session expired');
    case GrpcStatusCode.PERMISSION_DENIED:
      return new Error('You do not have access');
    default:
      return err;
  }
})
Enter fullscreen mode Exit fullscreen mode

Utilities

The library also exports a small set of utility functions that come up constantly when working with gRPC in Angular:

import {
  isGrpcError,
  toPlainObject,
  grpcStatusToHttp,
  GrpcStatusCode,
} from 'nx-grpc-kit/utils';
Enter fullscreen mode Exit fullscreen mode

isGrpcError(err) is a type guard that narrows unknown to GrpcStatusEvent — use it in catchError blocks. toPlainObject(msg) is a type-safe wrapper for converting protobuf messages to plain objects. grpcStatusToHttp(code) maps gRPC status codes to HTTP equivalents, useful if your error tracking expects HTTP codes. GrpcStatusCode is an enum with all 17 canonical gRPC status codes so you do not need magic numbers.

Why not class-based interceptors?

Angular moved to functional interceptors for HTTP with provideHttpClient(withInterceptors(...)) in v15. The gRPC ecosystem did not follow. If you use @ngx-grpc/core, interceptors still require @Injectable() classes and manual GRPC_INTERCEPTORS multi-provider registration.

nx-grpc-kit brings the same functional pattern to gRPC. Closures instead of classes. Composition instead of configuration. The interceptor pipeline is visible in one place, and TypeScript enforces the factory signatures.

Install

npm install nx-grpc-kit
Enter fullscreen mode Exit fullscreen mode

Peer deps: Angular 17+, @ngx-grpc/core 3+, @ngx-grpc/common 3+, RxJS 7+.

These interceptors were extracted from a production Angular monorepo using gRPC-web as the transport layer for 20+ services. They have been running in production for months.

npm: nx-grpc-kit
GitHub: harsh04/nx-grpc-kit

Feedback welcome.

Top comments (0)