Back in 2020, I wrote an article about how to set up a database transaction that lasts the entire request (using TypeORM). However, the approach I took had two major drawbacks:
- If your
UnitOfWork
depends on anything, those dependencies are implicitly a Request-ScopedInjectable
which means they need to be really lightweight to avoid performance issues. - You are jumping ship from the dependency injection (DI) system the moment you have to pass the
context
around to propagate the database transaction.
The primary reason I went this route back in 2020 was because there was no stable way to store context about about the currently executing JavaScript task (aka thread-local storage in other languages) and therefore we had to manually pass that context around.
Today, NodeJS v20+ provides the stable AsyncLocalStorage API. This allows for attaching data to the current executing context (even across event loop interrupts) which will allow for us to attach anything we want to a specific request, i.e. the database transaction. Moving some request-specific context into AsyncLocalStorage will allow for us to remove the Scope.REQUEST
from the Injectables and allow for us to take advantage of NestJS’s DI system for any extra context we might need during a transaction execution.
An example project
I am going to use the proverbial Todo web app as an example to show how all of this fits together. In fact, it’s going to be a very useless Todo app because it won’t even have Todo list items! You can follow along with a running example here: https://github.com/frenchtoast747/per-request-database-transactions-with-nestjs
Setup
To make our lives easier, instead of implementing our own request context local storage manager and injectable, I will be using a library called nestjs-cls and its @nestjs-cls/transactional plugin along with the @nestjs-cls/transactional-adapter-typeorm. Check out the other adapters depending on your database needs.
You can install these libraries with:
yarn add nestjs-cls yarn add @nestjs-cls/transactional yarn add @nestjs-cls/transactional-adapter-typeorm
Note: if you’re using TypeORM and love it, there is a dedicated library called typeorm-transactional. This library has been around for a while, but it has been forked due to lack of maintenance; the fork seems to be getting some work. The typeorm-transactional API for is, in my opinion, simpler to use, but it locks you in to only TypeORM. As I’m looking to ditch TypeORM for another ORM (MikroORM to be specific) and because nestjs-cls supports more than just TypeORM that’s why I chose to take a potential hit in the API here. To be clear, this only affects your Repositories (see example below) and nothing else, so it’s really not all that bad.
I would highly suggest reading through the nestjs-cls documentation in case I miss something here that you might need to get going.
To get started, you will need to initialize the ClsModule (provided by nestjs-cls) in your root AppModule.
import { ClsModule } from 'nestjs-cls'; import { ClsPluginTransactional } from '@nestjs-cls/transactional'; import { TypeOrmModule } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { TodoModule } from './todo/todo.module'; import { TransactionalAdapterTypeOrm } from '@nestjs-cls/transactional-adapter-typeorm'; @Module({ imports: [ // our main domain module. TodoModule, ClsModule.forRoot({ global: true, // sets up a middleware that automatically wraps each request // in an AsyncLocalStorage.run() call, mapping a data store to the request. middleware: { mount: true }, plugins: [ // instantiate the plugin informing it to use the TypeORM adapter new ClsPluginTransactional({ // the adapter needs access to the DataSource as defined below, // so we need to import the TypeOrmModule to get access to it. imports: [TypeOrmModule], adapter: new TransactionalAdapterTypeOrm({ dataSourceToken: DataSource, }), }), ], }), // setup your DB connection here or possibly via some // config NestJS module. TypeOrmModule.forRoot(...); ] }) export class AppModule {}
A couple of things to note, as we set up the ClsModule, we’re informing it to mount a middleware that wraps each request with an invocation to AsyncLocalStorage.run()
which activates the storage and ties it to the request.
Next, the ClsModule is using the ClsPluginTransactional()
plugin which provides support for wrapping any function call with a database transaction creating a new transaction if one is not started or re-using an existing one if available. This is very similar to the @Transactional()
annotation in the Java Spring framework.
Using the transactional context
As I mentioned in the Note in the Setup section above, the nestjs-cls API is not quite as friendly as typeorm-transactional and that’s because it’s taking the stance that it does not want to monkey-patch TypeORM. What this means is that to get a transaction context, we have to go through the EntityManager to propagate the transaction information to TypeORM repositories. This is very similar to what we needed to do in my previous post where we’d declare the unit of work and then pass the entity manager “session” around, using that session to call getCustomRepository()
.
Instead of creating a custom TypeORM repository, we will be creating a standard NestJS provider that we will call a Repository. So it’s like a custom repository, but instead of using inheritance, we’re using composition calling into the standard TypeORM repositories to interact with our data stores. Let’s look at the code.
Assume we’ve already created a new NestJS module called todo
which has a structure like:
- todo/
| - commands/
| | - create-todo.ts
| - todo.entity.ts
| - todo.module.ts
| - todo.repository.ts
| - todo.service.ts
| - todo.controller.ts
TodoRepository
To access our data through TypeORM in a transactional context, we need to set up a custom NestJS provider that uses the TypeORM transactional adapter. We’ll call this provider TodoRepository
and its main job will be to interact with the data store using the TypeORM repository or query builder APIs.
import { TransactionHost } from '@nestjs-cls/transactional'; import { TransactionalAdapterTypeOrm } from '@nestjs-cls/transactional-adapter-typeorm'; @Injectable() export class TodoRepository { constructor( private readonly txHost: TransactionHost<TransactionalAdapterTypeOrm>, ) {} private get repository(): Repository<TodoEntity> { return this.txHost.tx.getRepository(TodoEntity); } async findTodoByTitle(title: string): Promise<Todo | null> { const result = await this.repository.findOne({ where: { title } }); if (!result) { return null; } return { id: result.id, title: result.title }; } async createTodo(todoDto: CreateTodoDto): Promise<Todo> { const todo = this.repository.create({ ...todoDto }); const result = await this.repository.save(todo); return { id: result.id, title: result.title }; } async findTodos(): Promise<Todo[]> { return (await this.repository.find()).map((todo) => ({ id: todo.id, title: todo.title, })); } }
The TodoRepository
depends on the TransactionHost
which is provided by the @nestjs-cls/transactional
plugin. The TransactionHost is how we get a hold of the “current” transaction context for the request. The host is stored as a private variable named txHost
on the TodoRepository
class. A TransactionHost
contains a .tx
attribute containing the currently open database session containing the open transaction if one has been created or otherwise defaults to a non-transaction query. This allows for each method in the repository to be used whether inside of a transaction or not. In the case of TypeORM, the .tx
attribute contains an EntityManager
instance. Through the EntityManager
instance, we then have access to the TypeORM querying APIs.
Our TodoRepository
provides developer friendly methods that interact with the Todo data that’s stored. In this example, these helpful methods are findTodoByTitle()
, createTodo()
, and findTodos()
. Each of these methods need access to the TypeORM query methods in a transactional context, so a private helper get method named repository
consolidates the chain of calls so that only this.repository
is needed to use TypeORM’s TodoEntity repository.
TodoService
Now, to separate our business logic layer away from the data store layer (thus making it easier to jump ship from TypeORM to another ORM), we will create a TodoService:
@Injectable() export class TodoService { constructor(private readonly todoRepository: TodoRepository) {} async getTodoByTitle(title: string): Promise<Todo | null> { return this.todoRepository.findTodoByTitle(title); } create(title: string) { return this.todoRepository.createTodo({ title }); } getTodos(): Promise<Todo[]> { return this.todoRepository.findTodos(); } }
Note that this is just a standard NestJS provider injecting the TodoRepository
dependency. The TodoService
method names are varied slightly from the TodoRepository
to attempt to illustrate why having a service calling a repository is necessary for keeping business logic separate from data access APIs. This will allow you to be able to completely swap out the TodoRepository
methods with another data store access provider without breaking any of the callers of the TodoService
. At this point, the TodoService has no knowledge of transactions and we’re not having to propagate an EntityManager
instance all over.
CreateTodo command
I’m a big fan of the command pattern which encapsulates business logic, potentially across multiple services, in a single location called a command. In line with Data-Driven Design‘s ubiquitous language requirement, a command is the name of a workflow that a user performs about your application.
While a command is probably overly complicated for such a simple application, hopefully it illustrates the Command < Service < Repository
dependency chain and how the Command and the Repository have knowledge of the current request’s transaction, while the middle Service dependency doesn’t need to care.
We need a way to be able to create todos, so inside of todo/commands
, we’ll create the CreateTodo
command:
import { Transactional } from '@nestjs-cls/transactional'; import { TodoService } from '../todo.service'; export type CreateTodo = { title: string; }; // should be declared in another file shared by all command handlers export type CommandHandler<T, R> = { execute(command: T): Promise<R>; }; @Injectable() export class CreateTodoHandler implements CommandHandler<CreateTodo, Todo> { constructor(private readonly todoService: TodoService) {} @Transactional() async execute(command: CreateTodo): Promise<Todo> { const existing = await this.todoService.getTodoByTitle(command.title); if (existing) { throw new InvariantViolated('Todo already exists'); } return this.todoService.create(command.title); } }
Again, this is declaring another standard NestJS provider named CreateTodoHandler
. This provider needs access to Todo’s, so it injects the TodoService
as a dependency. Note how nothing up to this point knows or cares about database transactions, databases, etc. We’re not propagating the EntityManager
context here.
Now, the execute()
method takes in the CreateTodo
command object which contains the information needed to create a Todo
. This first uses the .todoService
to fetch an existing Todo
by the title passed in and if one exists, it throws a custom error named InvariantViolated
. You can use whatever error you want here. If there’s no existing Todo with the same title, then the todoService
calls its create method with the necessary arguments.
The entire execute()
method is decorated with the @Transactional()
decorator indicating that when this function is called, a new transaction should be created if one hasn’t already been created for this request or it should re-use an existing one and that everything inside of the function should be executed within that transaction. The key difference between this approach and my previous post’s unitOfWork()
function is that to roll back the transaction, you need to raise an exception whereas the unitOfWork()
function expected you to return a Result object with success: false
.
TodoController
To expose the Todo creation to the world, we need to add it to our API via a controller:
type CreateTodoInput = { title: string } type CreateTodoResult = { success: boolean, message: string, todo: Todo, } @Controller('todos') export class TodoController { constructor(private readonly createTodoHandler: CreateTodoHandler) {} @Post() async create(@Body() input: CreateTodoInput): Promise<CreateTodoResult> { try { return { success: true, message: 'Todo created', todo: await this.createTodoHandler.execute(input), }; } catch (e) { let message = 'An error occurred'; if (e instanceof InvariantViolated) { message = e.message; } else { console.error(e); } return Promise.resolve({ success: false, message }); } } }
This simply injects the CreateTodoHandler
provider into the TodoController
and we call it like a normal method here. Again, note how our controller doesn’t care about transactions. Also note the use of the custom InvariantViolated
error which is used to provide a custom error message as a part of the endpoint’s Result
response.
And that’s it! Making a request to POST /todos
will:
- create a new request context due to the request nestjs-cls middleware,
- the request is routed to our
TodoController
and itscreate()
method is called. - the
CreateTodoHandler.execute()
method is called. Since this method is decorated with the@Transactional()
decorator a new transaction will be initiated and then persisted into the request context store; everything inside of that call is wrapped in a database transaction - the
.execute()
method uses theTodoService.create()
method TodoService.create()
uses ourTodoRepository
to check for an existing Todo and then create a new TodoEntity if it’s unique.- The
TodoRepository
wraps each of its methods with the context-awareTransactionHost
provided bynestjs-cls
and@nestjs-cls/transactional
. So whenTodoRepository.findTodoByTitle()
andTodoRepository.createTodo()
are called, they’re both called within the context of the database transaction.
You can verify that this is working by setting the TypeORM logging: true
config in the TypeOrmModule initialization and you should be able to see BEGIN; ... COMMIT;
echoed appropriately during the request with the findTodoByTitle()
and createTodo()
SQL statements in the middle. Something like this:
query: START TRANSACTION query: SELECT "TodoEntity"."id" AS "TodoEntity_id", "TodoEntity"."title" AS "TodoEntity_title" FROM "todo_entity" "TodoEntity" WHERE (("TodoEntity"."title" = $1)) LIMIT 1 -- PARAMETERS: ["My Todo"] query: INSERT INTO "todo_entity"("title") VALUES ($1) RETURNING "id" -- PARAMETERS: ["My Todo"] query: COMMIT
If you create a Todo and then attempt to create another with the same title, you should then see BEGIN; ... ROLLBACK;
since the InvariantViolated
error will signal the need to rollback the transaction. This will look something like:
query: START TRANSACTION query: SELECT "TodoEntity"."id" AS "TodoEntity_id", "TodoEntity"."title" AS "TodoEntity_title" FROM "todo_entity" "TodoEntity" WHERE (("TodoEntity"."title" = $1)) LIMIT 1 -- PARAMETERS: ["My Todo"] query: ROLLBACK
Conclusion
This post attempted to show an example of how you can use NestJS and TypeORM along with per-request database transactions in an easier-to-use and more manageable way utilizing the @Transactional()
decorator to indicate pieces of code that need to be executed within the same unit of work. Unlike my previous post, this was all made possible thanks to the stable AsyncLocalStorage NodeJS API.
P.S.
I’ll leave it as an exercise for the reader, but you should be able to easily swap out the TypeORM specific configuration, adapters, and API calls in the TodoRepository
to use something else, e.g. MikroORM. And since we’re using a TodoService
for business logic, the only thing that would need to be updated is the TodoService
since it is the only thing that uses TodoRepository
. Our CreateTodoHandler
will not need to be updated since it uses TodoService
.
Thanks for sharing, this post and the solution are amazing, I’ll share with every developer that I know that had hard times with transactions in NodeJS
Thanks Ismael, I appreciate it! If you have any open-source projects that use some sort of strategy like this one, feel free to share it here for others to learn from.