Managing Transactions with a Generic BaseService and TransactionService in a Nest.js App with TypeORM (Part 2)

Ahmad Alhourani
4 min readSep 28, 2023

--

In Part 1 of this series, we discussed the importance of using a generic base service for handling database queries in a Nest.js application with TypeORM. Now, in Part 2, we’ll explore how to manage transactions using TransactionService alongside the BaseService. We'll also see how to integrate these services to ensure data integrity in your application.

Transaction Management with TransactionService

The TransactionService you provided handles transaction management using TypeORM's QueryRunner. It offers methods for starting, committing, rolling back, and releasing transactions.

// transaction.service.ts

import { Injectable } from '@nestjs/common';
import { QueryRunner, EntityManager } from 'typeorm';
import { Observable, from, map } from 'rxjs';

@Injectable()
export class TransactionService {
private queryRunner: QueryRunner;

constructor(private manager: EntityManager) {}

// ... Transaction methods (start, commit, rollback, release)
startTransactionByQueryRunner(): Observable<QueryRunner> {
const queryRunner = this.manager.connection.createQueryRunner();
queryRunner.connect();
return defer(() => queryRunner.startTransaction()).pipe(
map(() => {
return queryRunner;
}),
);
}

commitTransactionByQueryRunner(queryRunner: QueryRunner): Observable<any> {
return from(queryRunner.commitTransaction()).pipe(map(() => true));
}

rollbackTransactionByQueryRunner(queryRunner: QueryRunner): Observable<any> {
return from(queryRunner.rollbackTransaction()).pipe(map(() => true));
}

releaseByQueryRunner(queryRunner: QueryRunner): Observable<any> {
return from(queryRunner.release()).pipe(map(() => true));
}
}

Now, let’s integrate them TransactionService with the BaseService to ensure that database operations are wrapped in transactions.

Integrating Transaction Management with BaseService

Incorporating transaction management into the BaseService is straightforward. We'll modify a few methods to accept a QueryRunner instance when necessary, ensuring that operations occur within the same transaction.

// BaseService.ts

import { DeepPartial, FindManyOptions, FindOneOptions, Repository, QueryRunner, UpdateResult, SelectQueryBuilder } from 'typeorm';
import { Observable, defer, from, map, switchMap } from 'rxjs';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';

export abstract class BaseService<T> {
constructor(protected readonly repository: Repository<T>) {}

// ...

/**
* Save an entity within a transaction.
* @param entity
* @param queryRunner
* @returns
*/
saveInTransaction(entity: DeepPartial<T>, queryRunner: QueryRunner): Observable<T> {
return defer(() => queryRunner.manager.getRepository(this.repository.target).save(entity));
}

/**
* Update an entity within a transaction.
* @param id
* @param entity
* @param queryRunner
* @returns
*/
updateInTransaction(id: any, entity: QueryDeepPartialEntity<T>, queryRunner: QueryRunner): Observable<UpdateResult> {
return defer(() => queryRunner.manager.getRepository(this.repository.target).update(id, entity));
}

// ...

/**
* Remove an entity within a transaction.
* @param entity
* @param queryRunner
* @returns
*/
removeInTransaction(entity: T, queryRunner: QueryRunner): Observable<any> {
return defer(() => queryRunner.manager.getRepository(this.repository.target).remove(entity));
}
}

Using Transaction Management in TestService

With the modifications in place, we can now use the TransactionService transaction-aware methods the TestService for more complex database operations:

// TestService.ts

import { Injectable } from '@nestjs/common';
import { Test } from './entities/test.entity';
import { BaseService } from '../../services/base.service';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TransactionService } from '../../services/transaction.service'; // Import TransactionService

@Injectable()
export class TestService extends BaseService<Test> {
constructor(
@InjectRepository(Test)
protected readonly repository: Repository<Test>,
private readonly transactionService: TransactionService, // Inject TransactionService
) {
super(repository);
}

// ...

async complexOperation() {
const queryRunner = this.transactionService.startTransactionByQueryRunner(); // Create a new transaction
try {
// Perform operations within the transaction
const input1: Partial<Test> = { type: 'test1' };
const input2: Partial<Test> = { type: 'test2' };

await this.saveInTransaction(input1, queryRunner);
await this.saveInTransaction(input2, queryRunner);

// Commit the transaction
await this.transactionService.commitTransactionByQueryRunner(queryRunner);
} catch (error) {
// Handle errors and rollback on failure
await this.transactionService.rollbackTransactionByQueryRunner(queryRunner);
throw error;
} finally {
// Release the transaction
await this.transactionService.releaseByQueryRunner(queryRunner);
}
}
}

or by RXJS

// TestService.ts

import { Injectable } from '@nestjs/common';
import { Test } from './entities/test.entity';
import { BaseService } from '../../services/base.service';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TransactionService } from '../../services/transaction.service'; // Import TransactionService

@Injectable()
export class TestService extends BaseService<Test> {
constructor(
@InjectRepository(Test)
protected readonly repository: Repository<Test>,
private readonly transactionService: TransactionService, // Inject TransactionService
) {
super(repository);
}

test() {
return forkJoin({
queryRunner: this.transactionService.startTransactionByQueryRunner(),
}).pipe(
switchMap(({ queryRunner }) => {
return of(queryRunner).pipe(
switchMap(() => {
const input1: Partial<Test> = {
type: 'test1',
};
const input2: Partial<Test> = {
type: 'test2',
};
return forkJoin({
test1: this.testService.saveInTransaction(input1,queryRunner),
test2: this.testService.saveInTransaction(input2,queryRunner),
});
}),
switchMap(() => {
return this.transactionService.commitTransactionByQueryRunner(
queryRunner,
);
}),

catchError((error) => {
return this.transactionService
.rollbackTransactionByQueryRunner(queryRunner)
.pipe(
switchMap(() => {
throw new InternalServerErrorException(error);
}),
);
}),
finalize(() => {
return this.transactionService.releaseByQueryRunner(queryRunner);
}),
);
}),
);
}


}

Conclusion

In this two-part series, we’ve discussed the benefits of using a generic base service for database operations in a Nest.js application with TypeORM. Additionally, we’ve explored how to integrate transaction management using the TransactionService alongside the BaseService to ensure data consistency and integrity.

By following these best practices, your application becomes more maintainable, consistent, and robust, making it easier to manage complex database operations and transactions effectively.

Contact Me

--

--

Ahmad Alhourani

https://www.linkedin.com/in/ahmad-alhourani Experienced Software Engineer & Team Lead with 10+ years in RESTful APIs, Microservices,TypeScript, and Blockchain.