@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)
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.
Plugin registration
To add register the transactional plugin with nestjs-cls
, we need to pass it to the forRoot
method of the ClsModule
:
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 adapter docs for more info
prismaInjectionToken: PrismaClient,
}),
}),
],
}),
],
providers: [UserService, AccountService],
})
export class AppModule {}
This registers a TransactionHost
provider in the global context which can be used to start a new transaction 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
to start a new transaction and retrieve the current transaction reference.
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
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.
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. 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 silimarly to the TransactionHost
type argument, and ensures that the transaction instance is typed correctly.
import { InjectTransaction, Transaction, Transactional } 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 starting the transaction, which should be the case in most cases, however, for that reason, 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,
}),
Passing transaction options
The both the withTransaction
method and the Transactional
decorator accepts an optional TransactionOptions
object as the first argument. 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;
});
}
Transaction propagation
Similar to how the @Transactional
decorator work in Spring and other similar frameworks. The @Transactional
decorator and the withTransaction
method accept an optional propagation
option as the first parameter which can be used to configure how the transaction should be propagated.
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. -
NotSupported
: Run without a transaction even if one exists. -
Mandatory
: Reuse an existing transaction, throw an exception otherwise -
Never
: Throw an exception if an existing transaction exists, otherwise create a new one
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() },
});
}
}
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): 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). -
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;
}