Skip to main content

@nestjs-cls/transactional

The Transactional plugin for nestjs-cls provides a generic interface that can be used to wrap any function call in a CLS-enabled transaction by storing the transaction reference in the CLS context.

The transaction reference can be then retrieved in any other service and refer to the same transaction without having to pass it around.

The plugin is designed to be database-agnostic and can be used with any database library that supports transactions (via adapters). At the expense of using a minimal wrapper, it deliberately does not require any monkey-patching of the underlying library.

Installation

npm install @nestjs-cls/transactional

The plugin works in conjunction with various adapters that provide the actual transactional logic and types for the underlying database library, so you'll need to install one of those as well.

Adapters for the following libraries are available:

Adapters will not be implemented for the following libraries (unless there is a serious demand):

Example

For this example, we'll use the prisma library and the @nestjs-cls/transactional-adapter-prisma adapter. Later, you'll learn how to create your own adapter.

Suppose we already have a PrismaModule which provides a PrismaClient instance and two other services UserService and AccountService which we'd like to make transactional.

note

The prisma adapter is only given as an example simply because it was the first one that was implemented. It does not mean it is the best or the most compatible one.

Plugin registration

To register the transactional plugin with nestjs-cls, we need to pass it to the plugins array of the ClsModule.forRoot method.

app.module.ts
import { ClsModule } from 'nestjs-cls';
import { ClsPluginTransactional } from '@nestjs-cls/transactional';
import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma';
// ... other imports

@Module({
imports: [
PrismaModule,
ClsModule.forRoot({
plugins: [
new ClsPluginTransactional({
// if PrismaModule is not global, we need to make it available to the plugin
imports: [PrismaModule],
adapter: new TransactionalAdapterPrisma({
// each adapter has its own options, see the specific adapter docs for details
prismaInjectionToken: PrismaClient,
}),
}),
],
}),
],
providers: [UserService, AccountService],
})
export class AppModule {}

This registers a TransactionHost provider in the global context which can be used to start new transactions and retrieve the current transaction reference.

Using the TransactionHost

Now that we have the plugin registered, we can use the TransactionHost to start a new transaction and retrieve the current transaction reference.

Suppose that any time we create an User, we want to create an Account for them as well and both operations must either succeed or fail. We can use the TransactionHost for this.

If the callback function passed to the withTransaction completes successfully, the transaction will be committed. If it throws an error, the transaction will be rolled back.

The type argument on the TransactionHost<Adapter> makes sure that the tx property is typed correctly and the withTransaction method returns the correct type. This is ensured by the implementation of the adapter:

user.service.ts
import { TransactionHost } from '@nestjs-cls/transactional';
import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma';
// ... other imports

@Injectable()
class UserService {
constructor(
private readonly txHost: TransactionHost<TransactionalAdapterPrisma>,
private readonly accountService: AccountService,
) {}

async createUser(name: string): Promise<User> {
return this.txHost.withTransaction(async () => {
const user = await this.txHost.tx.user.create({ data: { name } });
await this.accountService.createAccountForUser(user.id);
return user;
});
}
}
account.service.ts
import { TransactionHost } from '@nestjs-cls/transactional';
import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma';
// ... other imports

@Injectable()
class AccountService {
constructor(
private readonly txHost: TransactionHost<TransactionalAdapterPrisma>,
) {}

async createAccountForUser(id: number): Promise<Account> {
return this.txHost.tx.user.create({
data: { userId: id, number: Math.random() },
});
}
}
note

Notice that we never used either raw PrismaClient or the prisma.$transaction method directly. This is because the adapter takes care of that for us, otherwise the transaction would not be propagated in the CLS context.

Using the @Transactional decorator

The @Transactional decorator can be used to wrap a method call in the withTransaction call implicitly. This saves a lot of boilerplate code and makes the code more readable, all the while de-cluttering the application logic.

Using the decorator, we can change the createUser method like so without changing the behavior:

user.service.ts
import { TransactionHost, Transactional } from '@nestjs-cls/transactional';
import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma';
// ... other imports

@Injectable()
class UserService {
constructor(
private readonly txHost: TransactionHost<TransactionalAdapterPrisma>,
private readonly accountService: AccountService,
) {}

@Transactional()
async createUser(name: string): Promise<User> {
const user = await this.txHost.tx.user.create({ data: { name } });
await this.accountService.createAccountForUser(user.id);
return user;
}
}

Using the @InjectTransaction decorator

since v2.2.0

The @InjectTransaction decorator can be used to inject a Proxy Provider of the Transaction instance (the tx property of the TransactionHost) directly as a dependency.

This is useful when you don't want to inject the entire TransactionHost and only need the transaction instance itself, or for example, when you're migrating an existing codebase and don't want to change all database calls to use txHost.tx.

The type argument of Transaction<Adapter> behaves similarly to the TransactionHost<Adapter> type argument, and ensures that the transaction instance is typed correctly.

account.service.ts
import { InjectTransaction, Transaction } from '@nestjs-cls/transactional';
import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma';
// ... other imports

@Injectable()
class AccountService {
constructor(
@InjectTransaction()
private readonly tx: Transaction<TransactionalAdapterPrisma>,
) {}

async createAccountForUser(id: number): Promise<Account> {
return this.tx.create({
data: { userId: id, number: Math.random() },
});
}
}
important

When a transaction is not active, the Transaction instance refers to the default non-transactional instance. However, if the CLS context is not active, the Transaction instance will be undefined instead, which could cause runtime errors.

Therefore, this feature works reliably only when the CLS context is active prior to accessing the transaction.

Additionally, some adapters do not support this feature due to the nature of how transactions work in the library they implement (notably MongoDB and Mongoose).

For these reasons, this is an opt-in feature that must be explicitly enabled with the enableTransactionProxy: true option of the ClsPluginTransactional constructor.

new ClsPluginTransactional({
imports: [PrismaModule],
adapter: new TransactionalAdapterPrisma({
prismaInjectionToken: PrismaClient,
}),
enableTransactionProxy: true,
});

Transaction options

The both the withTransaction method and the Transactional decorator accepts an optional TransactionOptions object. This object can be used to configure the transaction, for example to set the isolation level or the timeout.

The type of the object is provided by the adapter, so to enforce the correct type, you need to pass the adapter type argument to the TransactionHost or to the Transactional decorator.

@Transactional<TransactionalAdapterPrisma>({ isolationLevel: 'Serializable' })
async createUser(name: string): Promise<User> {
const user = await this.txHost.tx.user.create({ data: { name } });
await this.accountService.createAccountForUser(user.id);
return user;
}
async createUser(name: string): Promise<User> {
return this.txHost.withTransaction({ isolationLevel: 'Serializable' }, async () => {
const user = await this.txHost.tx.user.create({ data: { name } });
await this.accountService.createAccountForUser(user.id);
return user;
});
}
note

You might have noticed that using the @Transactional decorator on service methods does leak implementation details to the application code when used with the adapter-specific options. This is a deliberate choice, because the alternative would be building even more abstraction and ensuring compatibility with all other adapters. That is not the path that I want this relatively simple plugin to take.

Transaction propagation

The @Transactional decorator and the withTransaction method accept an optional propagation option as the first parameter which can be used to configure how multiple nested transactional calls are encountered.

This option is directly inspired by a similar feature of the Spring framework for Java and Kotlin.

The propagation option is controlled by the Propagation enum, which has the following values:

  • Required: (default) Reuse the existing transaction or create a new one if none exists.

  • RequiresNew: Create a new transaction even if one already exists. The new transaction is committed independently of the existing one.

  • NotSupported: Run without a transaction even if one exists. The existing transaction is resumed once the callback completes.

  • Mandatory: Reuse an existing transaction, throw an exception otherwise.

  • Never: Run without a transaction, throw an exception if one already exists.

This parameter comes before the TransactionOptions object, if one is provided. The default behavior when a nested transaction decorator is encountered if no propagation option is provided, is to reuse the existing transaction or create a new one if none exists, which is the same as the Required propagation option.

Example:

user.service.ts
@Injectable()
class UserService {
constructor(
private readonly txHost: TransactionHost<TransactionalAdapterPrisma>,
private readonly accountService: AccountService,
) {}

@Transactional(
// Propagation.RequiresNew will always create a new transaction
// even if one already exists.
Propagation.RequiresNew,
)
async createUser(name: string): Promise<User> {
const user = await this.txHost.tx.user.create({ data: { name } });
await this.accountService.createAccountForUser(user.id);
return user;
}
}
account.service.ts
@Injectable()
class AccountService {
constructor(
private readonly txHost: TransactionHost<TransactionalAdapterPrisma>,
) {}

@Transactional<TransactionalAdapterPrisma>(
// Propagation.Mandatory enforces that an existing transaction is reused,
// otherwise an exception is thrown.
Propagation.Mandatory,
// When a propagation option is provided,
// the transaction options are passed as the second parameter.
{
isolationLevel: 'Serializable',
},
)
async createAccountForUser(id: number): Promise<Account> {
return this.txHost.tx.user.create({
data: { userId: id, number: Math.random() },
});
}
}

Plugin API

ClsPluginTransactional Interface

The ClsPluginTransactional constructor takes an options object with the following properties:

  • imports: any[]
    An array of NestJS modules that should be imported for the plugin to work. If the dependencies are available in the global context, this is not necessary.

  • adapter: TransactionalAdapter
    An instance of the adapter that should be used for the plugin.

  • enableTransactionProxy: boolean (default: false)
    Whether to enable injecting the Transaction instance directly using @InjectTransaction()

TransactionHost Interface

The TransactionHost interface is the main working interface of the plugin. It provides the following API:

  • tx: Transaction
    Reference to the currently active transaction. Depending on the adapter implementation for the underlying database library, this can be either a transaction client instance, a transaction object or a transaction ID. If no transaction is active, refers to the default non-transactional client instance (or undefined transaction ID).

  • withTransaction(callback): Promise
    withTransaction(options, callback): Promise
    withTransaction(propagation, callback): Promise
    withTransaction(propagation, options, callback): Promise
    Runs the callback in a transaction. Optionally takes Propagation and TransactionOptions as the first one or two parameters.

  • withoutTransaction(callback): Promise
    Runs the callback without a transaction (even if one is active in the parent scope). This is analogous to using the Propagation.NotSupported mode.

  • isTransactionActive(): boolean
    Returns whether a CLS-managed transaction is active in the current scope.

@Transactional decorator interface

The @Transactional decorator can be used to wrap a method call in the withTransaction call implicitly. It has the following call signatures:

  • @Transactional()
  • @Transactional(propagation)
  • @Transactional(options)
  • @Transactional(propagation, options)

Or when using named connections:

  • @Transactional(connectionName, propagation?, options?)

Multiple databases

Similar to other @nestjs/<orm> libraries, the @nestjs-cls/transactional plugin can be used to manage transactions for multiple database connections, or even multiple database libraries altogether.

Registration

To use multiple connections, register multiple instances of the ClsPluginTransactional, each with an unique connectionName:

ClsModule.forRoot({
plugins: [
new ClsPluginTransactional({
connectionName: 'prisma-connection',
imports: [PrismaModule],
adapter: new TransactionalAdapterPrisma({
prismaInjectionToken: PrismaClient,
}),
}),
new ClsPluginTransactional({
connectionName: 'knex-connection',
imports: [KnexModule],
adapter: new TransactionalAdapterKnex({
knexInstanceToken: KNEX,
}),
}),
],
}),

This works for any number of connections and any number of database libraries.

Usage

To use the TransactionHost for a specific connection, you need to use @InjectTransactionHost('connectionName') decorator to inject the TransactionHost. Otherwise Nest will try to inject the default unnamed instance which will result in an injection error.

Similarly, the @InjectTransaction decorator accepts the connection name as the first argument.

@Injectable()
class UserService {
constructor(
@InjectTransactionHost('prisma-connection')
private readonly txHost: TransactionHost<TransactionalAdapterPrisma>,
@InjectTransaction('prisma-connection')
private readonly tx: Transaction<TransactionalAdapterPrisma>,
) {}

// ...
}
note

@InjectTransactionHost('connectionName') is a short for @Inject(getTransactionHostToken('connectionName')). The getTransactionHostToken function is useful for when you need to mock the TransactionHost in unit tests.

Similarly, @InjectTransaction('connectionName') is a short for @Inject(getTransactionToken('connectionName')).

In a similar fashion, using the @Transactional decorator requires the connectionName to be passed as the first argument.

@Transactional('prisma-connection')
async createUser(name: string): Promise<User> {
await this.accountService.createAccountForUser(user.id);
return user;
}