How I Harnessed Transactions in NestJS with TypeORM Without a Headache

| Mar 19, 2024 min read

Introduction

When I first got involved with a project using NestJS, I encountered a surprising revelation. The team wasn’t leveraging transactions—a decision that puzzled me greatly. Inquiring further, the consensus was that managing transactions in NestJS was overly complex. Indeed, observing the disruption a straightforward transaction could cause to established patterns and practices within the framework left me taken aback. This motivated me to delve into the matter, seeking an alternative as intuitive as SpringBoot’s @Transactional. Despite finding insightful resources here and here on Medium, something about the solutions presented didn’t sit right with me. My engineering instincts leaned towards simplicity and reusability, yet achieving the elegance of Spring’s AOP within NestJS seemed like a daunting, if not impossible, task without significant modifications. A big challenge came up when I looked into using NestJS’s interceptor layer, which only works for the Controller layer. This meant that every time a request was made, a transaction would start. This wasn’t efficient and added too many restrictions. Plus, a lot of the processes I work with don’t even start with an HTTP call. Seeing these issues, I decided not to go with the usual way of doing things. Instead, I chose a more straightforward, declarative approach to make things better. If I can’t use convention-based approach, I will use declarative - but I’ll make it better.

How to create new transaction?

I really dislike dealing with transactions manually. We all know the drill: START TRANSACTION -> DO STUFF -> COMMIT or ROLLBACK. Sounds a lot like try/catch, right?

Here’s what we need:

  • To be able to keep using our custom repositories.
  • To create transactions easily when we need them.
  • To spread transactions across more than just one function.

Before we dive deeper, if you haven’t checked out my recent article on Integration Tests in NestJS with TestContainers, you should definitely give it a read. We’re going to build on some of the concepts and code from there, so it’ll help you follow along here. Trust me, it’s worth it.

Now, back to business. We’ll kick things off by introducing a TransactionRepository, an abstract class that’s going to be at the heart of how we handle transactions. By applying the decorator pattern, this class will give our repositories the power to manage transactions, all neatly wrapped up behind an abstract layer:

import { EntityManager, ObjectType, Repository } from "typeorm";

export abstract class TransactionalRepository<
  T,
  TargetRepo,
> extends Repository<T> {
  protected constructor(
    private readonly entityClass: ObjectType<T>,
    private readonly repository: Repository<T>,
  ) {
    super(repository.target, repository.manager, repository.queryRunner);
  }

  abstract transactional(entityManager: EntityManager): TargetRepo;

  protected fromEntityManager(
    entityManager: EntityManager,
    constr: (repository: Repository<T>) => TargetRepo,
  ): TargetRepo {
    return constr(entityManager.getRepository(this.entityClass));
  }
}

As shown, we need an EntityManager instance to start a transaction. We can use this idea in our repositories:

import {Injectable} from "@nestjs/common";
import {InjectRepository} from "@nestjs/typeorm";
import {EntityManager, Repository} from "typeorm";
import {TeamEntity} from "./team.entity";
import {TransactionalRepository} from "../database/transactional.repository";

@Injectable()
export class TeamRepository extends TransactionalRepository<TeamEntity, TeamRepository> {
    constructor(
        @InjectRepository(TeamEntity)
            repository: Repository<TeamEntity>,
    ) {
        super(TeamEntity, repository);
    }

    transactional(entityManager: EntityManager): TeamRepository {
        return this.fromEntityManager(
            entityManager,
            (repository) => new TeamRepository(repository),
        );
    }
}

and:

import {Injectable} from "@nestjs/common";
import {InjectRepository} from "@nestjs/typeorm";
import {EntityManager, Repository} from "typeorm";
import {TransactionalRepository} from "../database/transactional.repository";
import {UserEntity} from "./user.entity";

@Injectable()
export class UserRepository extends TransactionalRepository<UserEntity, UserRepository> {
    constructor(
        @InjectRepository(UserEntity)
            repository: Repository<UserEntity>,
    ) {
        super(UserEntity, repository);
    }

    transactional(entityManager: EntityManager): UserRepository {
        return this.fromEntityManager(
            entityManager,
            (repository) => new UserRepository(repository),
        );
    }
}

Whenever we call a transactional method on an instance of one of those repositories, we get a new instance that’s scoped to the entity manager’s transaction.

As a rule of thumb, it’s wise to encapsulate your transactional operations within a dedicated method. To illustrate, let’s set up a method in AccountService that’s simple but effective for our demonstration:

@Injectable()
export class AccountService {
    private readonly logger = new Logger(AccountService.name)

    constructor(private readonly entityManager: EntityManager) {}

    async createTeamAccount() {
        await this.entityManager.transaction(async em => {
            // ... operations
        }).catch(err => {
            // ... after-rollback logic
        })
    }
}

Luckily, EntityManager has a handy method named transaction. It accepts a function, allowing us to run our own logic easily. All we need to do is inject an EntityManager instance to use this feature. We can even set a custom ISOLATION_LEVEL with this method:

    async createTeamAccount() {
        await this.entityManager.transaction("READ COMMITTED", async em => {
            // ... operations
        }).catch(err => {
            // ... after-rollback logic
        })
    }

Next, let’s define a simple interface to structure our request data:

export interface INewTeam {
    owner: {
        email: string
        firstName: string
        lastName: string
    },
    team: {
        name: string
    }
}

Now, we’ll implement a fundamental concept for transactions: the bidirectional relation.

import {ConflictException, Injectable, Logger} from "@nestjs/common";
import {TeamRepository} from "./team.repository";
import {UserRepository} from "./user.repository";
import {EntityManager} from "typeorm";
import {INewTeam} from "./vto";

@Injectable()
export class AccountService {
    private readonly logger = new Logger(AccountService.name)

    constructor(private readonly teamRepository: TeamRepository,
                private readonly userRepository: UserRepository,
                private readonly entityManager: EntityManager) {}

    async createTeamAccount(accountInfo: INewTeam) {
        await this.entityManager.transaction(async em => {
            await this.internalCreateTeamAccount(accountInfo, em)
        }).catch(err => {
            this.logger.error(`Transaction failed {err=${err}}`)
            if(err.code == "ER_DUP_ENTRY") {
                throw new ConflictException()
            }
            throw err
        })
    }

    private async internalCreateTeamAccount(accountInfo: INewTeam, em: EntityManager) {
        const {userRepository, teamRepository} =
            this.transactionalRepositories(em);

        const team = await teamRepository.save({
            name: accountInfo.team.name ?? `${accountInfo.owner.firstName} ${accountInfo.owner.lastName} Team`,
        });

        const user = await userRepository.save({
            firstName: accountInfo.owner.firstName,
            lastName: accountInfo.owner.lastName,
            email: accountInfo.owner.email,
            confirmed: false,
            teamId: team.id,
        });

        await teamRepository.update({
                id: team.id,
            }, {
                ownerId: user.id,
            },
        );
    }


    private transactionalRepositories(entityManager: EntityManager) {
        return {
            userRepository: this.userRepository.transactional(entityManager),
            teamRepository:
                this.teamRepository.transactional(entityManager),
        };
    }
}

I’ve created a utility method named transactionalRepositories that fetches repository instances scoped to a specific transaction for use within the class. Following this setup, the operation unfolds in three key steps:

  1. A team is created initially without an assigned owner.
  2. Subsequently, a user is created, linked to the team established in the first step.
  3. Finally, this newly created user is designated as the team’s owner.

Absent a transaction, any interruption during these steps would leave our database in an inconsistent state. Additionally, there’s a clever technique in the catch block hinting at how we’ll approach testing this functionality…

Testing Transactional Integrity

For testing purposes, we’ll incorporate a unique constraint into our database migration (for more details, refer back to the article mentioned earlier):

        await queryRunner.query(`
            CREATE TABLE IF NOT EXISTS users (
                id INT AUTO_INCREMENT PRIMARY KEY,
                email VARCHAR(255) NOT NULL UNIQUE, // here's the change
                firstName VARCHAR(255) NOT NULL,
                lastName VARCHAR(255) NOT NULL,
                confirmed BOOLEAN NOT NULL,
                team_id INT,
                INDEX IDX_team_id (team_id)
            ) ENGINE=InnoDB;
        `);

Next, we’ll implement two test cases: one to verify successful execution and another to demonstrate failure due to the unique constraint violation:

import {AccountModule} from "../../../src/account/account.module";
import {DatabaseModule} from "../../../src/database/database.module";
import {ConflictException, Logger} from "@nestjs/common";
import {Test} from "@nestjs/testing";
import {UserRepository} from "../../../src/account/user.repository";
import {TeamRepository} from "../../../src/account/team.repository";
import {AccountService} from "../../../src/account/account.service";

describe("Should create user with team", () => {
    let userRepository: UserRepository
    let teamRepository: TeamRepository
    let accountService: AccountService

    beforeAll(async () => {
        const moduleRef = await Test.createTestingModule({
            imports: [
                AccountModule,
                DatabaseModule
            ],
        })
            .compile();
        moduleRef.useLogger(new Logger());
        userRepository = moduleRef.get(UserRepository);
        teamRepository = moduleRef.get(TeamRepository);
        accountService = moduleRef.get(AccountService);
    });

    it("should successfully create a team and user account", async () => {
        //given
        const accountInfo = {
            owner: {
                firstName: "John",
                lastName: "Doe",
                email: "[email protected]",
            },
            team: {
                name: "John's Team",
            },
        };

        //when
        await accountService.createTeamAccount(accountInfo);

        //then
        const user = await userRepository.findOne({
            where: {
                email: "[email protected]",
            },
        });

        const team = await teamRepository.findOne({
            where: {
                id: user.teamId,
            },
        });

        expect(user.firstName).toEqual("John");
        expect(team.name).toEqual("John's Team");
        expect(team.ownerId).toEqual(user.id);
    });

    it("should roll back transaction on unique constraint violation", async () => {
        //given
        const accountInfo1 = {
            owner: {
                firstName: "Eve",
                lastName: "Smith",
                email: "[email protected]",
            },
            team: {
                name: "Eve's Team",
            },
        };

        const accountInfo2 = {
            owner: {
                firstName: "Alice",
                lastName: "Jones",
                email: "[email protected]",
            },
            team: {
                name: "Alice's Team",
            },
        };

        //when
        await accountService.createTeamAccount(accountInfo1);

        //and
        await expect(accountService.createTeamAccount(accountInfo2)).rejects.toThrow(ConflictException);

        //then - first account should be created
        const user1 = await userRepository.findOne({
            where: { email: "[email protected]" },
        });
        expect(user1).not.toBeNull();

        //and - second account should not be created
        const team2 = await teamRepository.findOne({
            where: { name: "Alice's Team" },
        });
        expect(team2).toBeNull();
    });
});

A quick: npm run test:it and we can see that indeed a second example fails to create second entry:

    query: START TRANSACTION
    query: INSERT INTO `teams`(`id`, `name`, `owner_id`) VALUES (DEFAULT, ?, DEFAULT) -- PARAMETERS: ["Alice's Team"]
    query: INSERT INTO `users`(`id`, `email`, `firstName`, `lastName`, `confirmed`, `team_id`) VALUES (DEFAULT, ?, ?, ?, ?, ?) -- PARAMETERS: ["[email protected]","Alice","Jones",0,3]
    query failed: INSERT INTO `users`(`id`, `email`, `firstName`, `lastName`, `confirmed`, `team_id`) VALUES (DEFAULT, ?, ?, ?, ?, ?) -- PARAMETERS: ["[email protected]","Alice","Jones",0,3]
    error: Error: Duplicate entry '[email protected]' for key 'users.email'
    query: ROLLBACK
    [Nest] 66455  - 03/19/2024, 11:40:28 PM   ERROR [AccountService] Transaction failed {err=QueryFailedError: Duplicate entry '[email protected]' for key 'users.email'}

Simple as that :)

Conclusion

Mastering transaction management in NestJS requires a thoughtful approach that prioritizes clarity and maintainability. By choosing declarative methods over complex workarounds, we forge a path toward more robust and manageable applications. My heartfelt thanks go to all who have accompanied me on this deep dive. The full codebase is available on GitHub repository as always, if you’re curious to see it all come together.

And as always - thanks for hanging in there till the end.

Cheers,

Jed