Skip to main content
  1. Posts/

Refactoring with routing-controllers: 2. Interceptor

·773 words·4 mins
Table of Contents
The code in this post is available in the express-sequelize-ts repository.

Why I separated response logging #

In part one, I moved the Passport authentication flows into class-based middleware. The next shared concern I wanted to reorganize was response logging.

The project already used morgan middleware to record the method, URL, status, and response time. That was enough to follow the HTTP request, but it did not give me the value returned by a controller action.

Capturing a response body in Express middleware usually means wrapping methods such as res.send() or res.json(). A routing-controllers interceptor receives the action’s returned content directly. I kept request metadata in middleware and moved returned-content logging into an interceptor.

Where middleware and interceptors run #

The two extension points see different parts of a request.

  • Middleware receives Express’s request, response, and next around the request flow.
  • An interceptor runs after a controller action and receives both its Action context and returned content.

Middleware remained the natural place for authentication that must finish before an action runs. An interceptor was a more direct fit for observing or transforming the action result.

The previous response logging #

The old handler.middleware.ts selected a morgan handler based on the response status.

1
2
3
4
5
6
7
8
9
const successHandler = morgan(successResponseFormat, {
  skip: (req, res) => res.statusCode >= 400,
  stream: { write: (message) => logger.info(message.trim()) }
});

const errorHandler = morgan(errorResponseFormat, {
  skip: (req, res) => res.statusCode < 400,
  stream: { write: (message) => logger.error(message.trim()) }
});

Both handlers were registered directly in initMiddleware().

1
2
3
4
if (this._env !== 'test') {
  this.app.use(loggerHandler.success);
  this.app.use(loggerHandler.error);
}

I later moved that morgan setup into a class middleware named LoggingHandler. Its responsibility stayed narrow: recording request metadata such as the method, URL, status, and response time.

Building ResponseInterceptor #

I added a separate ResponseInterceptor for the value returned by a controller.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@Interceptor({ priority: 0 })
@Service()
export class ResponseInterceptor implements InterceptorInterface {
  intercept(action: Action, content: any) {
    const { response, request }: { response: Response; request: Request } = action;

    if (content instanceof HttpError) {
      logger.debug(
        `{ api: ${request.url}, status: ${content.httpCode}, data: ${JSON.stringify(content)} }`
      );
      return response.status(content.httpCode).json(content);
    } else {
      logger.debug(
        `{ api: ${request.url}, status: ${response.statusCode}, data: ${JSON.stringify(content)} }`
      );
      return content;
    }
  }
}

The action provides the current request and response. The content argument is the action result. For a normal response, the interceptor logs the current status and returned value, then returns the original content unchanged.

This implementation also included a content instanceof HttpError branch. That does not mean every exception thrown by a controller passes through this branch. A separately registered error middleware still owns the final conversion of exceptions into HTTP responses.

Registering it globally #

Instead of repeating an interceptor decorator on every controller, I registered the interceptor path in the options passed to useExpressServer().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const routingControllerOptions = {
  routePrefix: '/api',
  controllers: [path.join(__dirname, '/*.controller.ts')],
  middlewares: [HttpErrorHandler, LoggingHandler],
  interceptors: [path.join(__dirname, '/interceptors/*.interceptor.ts')],
  defaultErrorHandler: false,
  classTransformer: true,
  authorizationChecker
};

useExpressServer(this.app, routingControllerOptions);

Every controller action could now be observed from the same place. Controllers did not need an extra logging call for each returned value.

Keeping error middleware separate #

I did not remove HttpErrorHandler as part of this change. The interceptor and error middleware had different responsibilities.

  • LoggingHandler records the method, URL, status, and response time.
  • ResponseInterceptor records content returned by a controller action.
  • HttpErrorHandler converts errors that are thrown or passed through next(error) into HTTP responses.

All three sit near the response path, but they do not solve the same problem. Separating them by execution point made each shared concern easier to locate outside the controllers.

Looking back #

The 2023 implementation logged the entire response body with JSON.stringify(content). It was convenient during development, but in production it could retain sensitive values or create very large log entries.

If I implemented this today, I would use structured fields such as method, URL, status, duration, and request ID while excluding the response body by default. When body-level debugging is necessary, I would allowlist fields or redact sensitive values and enable it only in a limited debug environment.

Conclusion #

I did not need to choose middleware or interceptors for every shared concern. Request metadata and error flow stayed in middleware, while the controller result moved to an interceptor.

The useful part of routing-controllers here was having distinct extension points around an action. It let each concern live where its required data was already available.

References #