Creating a Transaction Interceptor Using Nest.js

TL;DR - Nest.js interceptors allow you to create transactions, attach them to each request, and use those in your controllers. See the final implementation here.

Nest.js is one of the fastest growing backend libraries in the Node.js world. Its clear structure, adjacency to Angular, and its similarities to Spring have all led to the framework to bloom in adoption. Additionally, abstracting away repetitive behavior that might have been tedious or verbose in an express app is a breeze with Nest. Between the built-in decorators, pipes, and the ability to extend all of this behavior, serious abstraction is highly encouraged by the framework. If you haven't used Nest.js, we at TeamHive, highly recommend it. Streamlining API development has never been easier than with Nest and it has simplified our development rapidly.

One such repetitive behavior is the process of setting up and appropriately tearing down transactions. If you use a transactional database with your Nest app, one of the greatest benefits is that you can ensure data integrity. If you wrap your requests in a transaction, and the request fails at any point, you can always rollback the transaction and all modifications made up to that point will be reverted. This process, however, is quite tedious. At the start of each request, you must create a transaction, run all of your queries within that transaction, determine if the request succeeded, and then either commit the transaction or it rollback on a failure. While none of these steps are particularly difficult to implement, wrapping every request in appropriate try/catch blocks that can handle the closing of the transaction would result in duplicated code all over the application.

The simplest way to implement this feature would instead be using Nest.js Interceptors. If you come from the express world, you will be familiar with the idea of middleware. A middleware is essentially a function that receives a request and returns a response based on that request. The power of middleware, however, comes from the fact that they can be chained together. In this way, for example, one middleware function can authorize the request, another one can transform it, and a final middleware could fetch some data and respond to the request. The problem with middleware, however, is that they only exist at one point in the middleware chain. There is no simple way to have a function that can both set up some data before the request is handled and also run some logic after the request. An interceptor, on the other hand allows for just this. By using RxJs Observables, Interceptors allow you to inject logic into any point of the request life cycle. For a transaction interceptor, this is perfect because we need to both set up the transaction before the request is handled, and either commit it or roll it back depending on the success of the request.

Implementation

To create an interceptor, all you have to do is create a class that implements the NestInterceptor interface, meaning it has a function called intercept which will receive an ExecutionContext and a CallHandler and will return an Observable. Below is an interceptor in its simplest form.

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class TransactionInterceptor implements NestInterceptor {

    async intercept(
        context: ExecutionContext,
        next: CallHandler
    ): Promise<Observable<any>> {
        return next.handle();
    }
}

The first step to creating the interceptor is going to be creating the transaction. Depending on what database, adaptor, or ORM you use, this part will vary, however, the concept should be the same. In this example, we are going to be using Sequelize as an ORM to create the transaction. With Sequelize, all you need to create a transaction is an established Sequelize instance with a database connection. Nest.js makes this simple as we can simply inject our Sequelize instance into the interceptor. If you are using an ORM that requires a Singleton, creating that as Singleton attached to a provider makes it easy to access throughout the Nest application.

Once that Sequelize instance is available in the intercept function, you can simply create the transaction and attach it to the request using the ExecutionContext.

@Injectable()
export class TransactionInterceptor implements NestInterceptor {

    constructor(
        @Inject(ApplicationTokens.SequelizeToken)
        private readonly sequelizeInstance: Sequelize
    ) { }

    async intercept(
        context: ExecutionContext,
        next: CallHandler
    ): Promise<Observable<any>> {
        const httpContext = context.switchToHttp();
        const req = httpContext.getRequest();

        const transaction: Transaction = await this.sequelizeInstance.transaction();
        req.transaction = transaction;
        return next.handle();
    }
}

At this point the transaction is available on the request, so any controller function could access it using the @Req parameter decorator; however, this is not only verbose, but is not typed by default and leads to the duplicated code of getting the transaction from the request. Instead, using Nest's createParamDecorator function, we can easily create a decorator that will get this transaction for us.

import { createParamDecorator } from '@nestjs/common';

export const TransactionParam: () => ParameterDecorator = () => {
    return createParamDecorator((_data, req) => {
        return req.transaction;
    });
};

With this decorator, any parameter you decorator in a controller function that is being intercepted by the TransactionInterceptor will receive a transaction that will be created per request.


@Get()
@UseInterceptors(TransactionInterceptor)
async handleGetRequest(
    @TransactionParam() transaction: Transaction
) {
    // make queries using your transaction here.
}

Stopping here, though, will not be any help. The transaction is getting created, but there is no logic to either commit the transaction upon success, or roll it back on errors. To do this, we can tap into the Observable returned from the handle() function. In the code below the function passed into tap() will be run when the request is handled successfully. The function passed into catchError will run when an error is thrown by the request handler.

Final Implementation

@Injectable()
export class TransactionInterceptor implements NestInterceptor {

    constructor(
        @Inject(ApplicationTokens.SequelizeToken)
        private readonly sequelizeInstance: Sequelize
    ) { }

    async intercept(
        context: ExecutionContext,
        next: CallHandler
    ): Promise<Observable<any>> {
        const httpContext = context.switchToHttp();
        const req = httpContext.getRequest();

        const transaction: Transaction = await this.sequelizeInstance.transaction();
        req.transaction = transaction;
        return next.handle().pipe(
            tap(() => {
                transaction.commit();
            }),
            catchError(err => {
                transaction.rollback();
                return throwError(err);
            })
        );
    }
}

With this code, any error thrown in the controller will cause the transaction to roll back and data will return to its original state. Additionally, errors are still thrown up the stack so all existing error handling/logging will continue to work.

Why use a transaction interceptor?

To start, the benefits of using transactions should be pretty clear. While we like to pretend our code doesn't fail, it inevitably will in production. If your requests make multiple writes or creates multiple objects that are related, it's important that if this process fails or is interrupted, data can be returned to a valid state. Transactions are the easiest way to ensure that and are one of the largest benefits of relational databases.

As mentioned earlier, the interceptor's main benefit is to make using transactions easier while not duplicating code. Once using the interceptor, all the logic of creating and managing the transaction is abstracted. As a developer, your only responsibility is to run your queries under that existing transaction. In this way, the repetitive and uninteresting work of creating and tearing down the transaction is removed, making developers much more likely to take advantage of the transactions that their database makes available to them.

推荐文章

向iPhone发送全屏、消失的sms

向iPhone发送全屏、消失的sms

推荐文章

如何使用MVCContrib和Razor制作寻呼机?

如何使用MVCContrib和Razor制作寻呼机?

推荐文章

从txt文件编译C代码以与正在运行的wpf应用程序接口

从txt文件编译C代码以与正在运行的wpf应用程序接口

推荐文章

更新文本视图时的计时器问题

更新文本视图时的计时器问题

推荐文章

位图的权限问题。Save()

位图的权限问题。Save()

推荐文章

如果在resultset上使用ojdbc6 getMetaData(),则执行的sql数量会增加,而在ojdbc5上则看不到这一点

如果在resultset上使用ojdbc6 getMetaData(),则执行的sql数量会增加,而在ojdbc5上则看不到这一点

推荐文章

快速处理大量CSV数据的最佳方法

快速处理大量CSV数据的最佳方法

推荐文章

使用WinInet c发布表单数据++

使用WinInet c发布表单数据++

推荐文章

JSON友好数据库?

JSON友好数据库?

推荐文章

使用Twitter身份验证用户

使用Twitter身份验证用户

推荐文章

无效工作组大小的原因

无效工作组大小的原因

推荐文章

带有复选框的Android列表视图:如何捕获选中的项?

带有复选框的Android列表视图:如何捕获选中的项?

推荐文章

facebook xid不工作

facebook xid不工作

推荐文章

Objective-C类别和.NET扩展方法之间有什么区别?

Objective-C类别和.NET扩展方法之间有什么区别?

推荐文章

使用HtmlAgilityPack检查null

使用HtmlAgilityPack检查null

推荐文章

学习最优参数使报酬最大化

学习最优参数使报酬最大化