Unit Of Work Pattern with NestJS and TypeORM
- Pavol Megela
- Dec 20, 2022
- 4 min read

When working with databases and complex business logic one of the main challenges is ensuring consistency when saving and updating data. This is where the Unit of Work pattern comes in handy. The Unit of Work pattern helps you keep track of changes and coordinate the writing out of these changes to your database atomically (one logical transaction). This is particularly useful when you have multiple operations that need to be treated as a single unit, meaning either everything succeeds together or everything fails and is rolled back.
In this article, I’ll show you a simple example of how to implement the Unit of Work pattern in a NestJS application that uses TypeORM. The goal is to walk you through a practical, easy-to-understand approach without using too many complicated terms.
Why even use the unit of work pattern?
Imagine you have multiple steps that must happen in sequence. Creating an Product, updating some related data, and saving a history record. If an error occurs halfway through you need to revert any changes made before the error. Doing this manually can be tedious and error-prone. By using the Unit of Work pattern, you rely on a single transaction that ties all operations together. If anything fails, the entire transaction is rolled back. We start from the end, using the unit of work pattern and we work our way back to learn to understand how it works.
Using the Unit of Work in your service
Let’s say we have a ProductService that needs to create a new product in the database. We want to make sure all database operations happen in a transaction. If something fails, we roll back to keep data consistent.
The example below shows a method that saves a list of Product objects to the database. Notice how we wrap the saving logic in withTransaction. If any error happens, the entire transaction is rolled back automatically. Repository needed for saving is provided by the method itself.
@Injectable()
export class ProductService {
constructor(private readonly productUnitOfWork: ProductUnitOfWork) {}
public async createProduct(name: string, price: number): Promise<void> {
await this.productUnitOfWork.withTransaction(
async (repositories: ProductRepositories) => {
// 1. Create and save the product
const product = repositories.product.create({ name, price, categoryId });
const savedProduct = await repositories.product.save(product);
// 2. Update the related category in the same transaction
const category = await repositories.category.findOneBy({id: categoryId});
if (!category) {
throw new Error(`Category with ID ${categoryId} not found.`);
}
category.numberOfProducts += 1;
await repositories.category.save(category);
}
);
}
}
So what’s happening here?
withTransaction()
We call withTransaction on our ProductUnitOfWork and pass in a function (often called a callback). This function receives the ProductRepositories (which we’ll see soon) and can perform database operations.
Automatic Transaction Management
Inside withTransaction, if everything succeeds, the transaction is committed; if something fails, everything is rolled back. This frees us from manually handling commits and rollbacks.
Clear Logic
The service code is clear and concise. It does exactly what it needs to: create a product within a transaction. All lower-level transaction details are hidden away.
The concrete Unit of Work class
The next piece of puzzle is a concrete class called ProductUnitOfWork, which extends our abstract base class, UnitOfWork<T>. It knows how to provide the specific repositories that we’ll use in our operations.
@Injectable()
export class ProductUnitOfWork extends UnitOfWork<ProductRepositories> {
public constructor(
dataSource: DataSource
) {
super(dataSource);
}
protected getRepositories(queryRunner: QueryRunner): ProductRepositories {
// Return the repositories needed for product operations
product: return new ProductRepositoryFacade(queryRunner.manager),
category: return new CategoryRepositoryFacade(queryRunner.manager);
}
}
Here we:
Extends UnitOfWork: Here we specify ProductRepository as our repository type parameter.
getRepositories(): This method tells the base class how to obtain a ProductRepository (or any necessary repository/facade) with the active QueryRunner.
Abstract Class
Finally, here is the abstract class that ProductUnitOfWork (and any other specific unit-of-work classes) will extend. This is where the main transaction logic lives.
It uses the DataSource from TypeORM to create a new QueryRunner, which helps manage database transactions. If you provide an existing QueryRunner, it will reuse that transaction instead of creating a new one.
Key points to note:
withTransaction() This is where the main work happens. You provide a function, operation, that contains all your database operations. If anything inside this function throws an error, the transaction will roll back.
Flexibility: The method accepts an optional QueryRunner, which allows you to reuse transactions. If you don’t provide one, it creates a new transaction.
getRepositories() We already saw implementation of this abstract method, here you can see how it is being used.
Now take a look at the code and try to understand it.
export abstract class UnitOfWork<T> {
protected constructor(
private readonly dataSource: DataSource
) {
}
public async withTransaction<U>(
operation: (repositories: T, internalQueryRunner: QueryRunner) => Promise<U>,
externalQueryRunner?: QueryRunner
): Promise<U> {
let queryRunner: QueryRunner;
if (externalQueryRunner) {
queryRunner = externalQueryRunner;
} else {
queryRunner = this.dataSource.createQueryRunner();
}
try {
if (!externalQueryRunner) {
await queryRunner.startTransaction();
}
const repositories = this.getRepositories(queryRunner);
const result = await operation(repositories, queryRunner);
if (!externalQueryRunner) {
await queryRunner.commitTransaction();
}
return result;
} catch (error: unknown) {
// Log the error
if (!externalQueryRunner) {
await queryRunner.rollbackTransaction();
}
return rethrow(error);
} finally {
if (!externalQueryRunner) {
await queryRunner.release();
}
}
}
protected abstract getRepositories(queryRunner: QueryRunner): T;
}
How it works:
Transaction Control - Handles starting, committing, or rolling back the transaction on your behalf.
Reusability - You can pass in an existing QueryRunner to chain multiple operations together if needed.
getRepositories() - An abstract method you implement in derived classes, telling UnitOfWork which repository (or repositories) to use inside the transaction.
Wrapping Up
Why is this useful:
Data Consistency - If anything breaks during one of the steps, your database stays consistent because the transaction is rolled back
Cleaner Services - Service methods focus on business logic rather than dealing with manual transaction code
Scalability - As your application grows you can create new unit-of-work classes for different aggregates or modules, without duplicating transaction logic, all while keeping aggregate boundaries respected.
With this approach, you get a clean, organized way to ensure that multiple database actions succeed or fail together, keeping your data consistent and your code maintainable.