@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
- yarn
- pnpm
npm install @nestjs-cls/transactional
yarn add @nestjs-cls/transactional
pnpm add @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:
- Prisma (see @nestjs-cls/transactional-adapter-prisma)
- Knex (see @nestjs-cls/transactional-adapter-knex)
- Kysely (see @nestjs-cls/transactional-adapter-knex)
- Pg-promise (see @nestjs-cls/transactional-adapter-pg-promise)
- TypeORM (see @nestjs-cls/transactional-adapter-typeorm)
- MongoDB (see @nestjs-cls/transactional-adapter-mongodb)
- MongoDB (see @nestjs-cls/transactional-adapter-mongoose)
- Drizzle ORM (see @nestjs-cls/transactional-adapter-drizzle-orm)
Adapters will not be implemented for the following libraries (unless there is a serious demand):
- Sequelize (since it already includes a built-in CLS-enabled transaction support)
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.
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.
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:
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;
});
}
}
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() },
});
}
}
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:
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.
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() },
});
}
}
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;
});
}
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:
@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;
}
}
@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 takesPropagation
andTransactionOptions
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 thePropagation.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>,
) {}
// ...
}
@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;
}