While working with databases, keeping the integrity of the data is crucial. For example, imagine transferring money from one bank account to another. To do that, we need to perform two separate actions. First, we withdraw the amount from the first bank account. Then, we add the same amount to the second account.
If the second operation fails for whatever reason while the first succeeds, we end up with an invalid state of the database. We need the above operations to all succeed or all fail together. We can accomplish that with transactions.
ACID properties
A transaction to be valid needs to have the following properties. Together, they form the ACID acronym:
Atomicity
Operations in the transaction are a single unit. Therefore, it either fully succeeds or fails together.
Consistency
The transaction moves the database from one valid state to the next.
Isolation
The isolation property ensures that multiple transactions can occur concurrently, resulting in a valid database state. To better understand that, let’s continue the example with the banking transaction from above. Another transaction should see the funds in one account or the other, but not in both.
Durability
Once the changes from a transaction are committed, they should survive permanently.
Transactions in MongoDB and Mongoose
Fortunately, MongoDB is equipped with support for multi-document transactions since version 4.0. We can tell the database that we do a transaction, and it keeps track of every update we make. If something fails, then the database rolls back all our updates. The above requires the database to do extra work making notes of our updates and locking the involved resources. Other clients trying to perform operations on the data might be stuck waiting for the transaction to complete. Therefore, this is something to watch out for.
Running a replica set
Transactions with MongoDB only work with a replica set, a group of MongoDB processes that maintain the same data set. In this series, we’ve been using docker-compose to run MongoDB for us. We can either run a replica set locally with docker or use MongoDB atlas. For this article, I’m doing the latter.
If you want to run a replica set, check out this page on Stackoverflow.
Deleting a user
Let’s implement a feature of deleting a user. When we remove users from the database, we also want to delete all posts they wrote.
1import { Injectable, NotFoundException } from '@nestjs/common';
2import { InjectModel } from '@nestjs/mongoose';
3import { Model } from 'mongoose';
4import { UserDocument, User } from './user.schema';
5import PostsService from '../posts/posts.service';
6
7@Injectable()
8class UsersService {
9 constructor(
10 @InjectModel(User.name) private userModel: Model<UserDocument>,
11 private readonly postsService: PostsService,
12 ) {}
13
14 async delete(userId: string) {
15 const user = await this.userModel
16 .findByIdAndDelete(userId)
17 .populate('posts');
18 if (!user) {
19 throw new NotFoundException();
20 }
21 const posts = user.posts;
22
23 return this.postsService.deleteMany(
24 posts.map((post) => post._id.toString()),
25 );
26 }
27
28 // ...
29}
30
31export default UsersService;To do the above, we also need to define the deleteMany in our PostsService.
1import { Model } from 'mongoose';
2import { Injectable } from '@nestjs/common';
3import { InjectModel } from '@nestjs/mongoose';
4import { Post, PostDocument } from './post.schema';
5
6@Injectable()
7class PostsService {
8 constructor(@InjectModel(Post.name) private postModel: Model<PostDocument>) {}
9
10 async deleteMany(ids: string[]) {
11 return this.postModel.deleteMany({ _id: ids });
12 }
13
14 // ...
15}
16
17export default PostsService;The shortcoming of the above code is that the delete method might succeed partially. When this happens, we delete the user, but the posts are left in the database without the author. We can deal with the above issue by defining a transaction.
To start a transaction, we need to access the connection we’ve established with MongoDB. To do that, we can use the @InjectConnection decorator:
1import { Injectable } from '@nestjs/common';
2import { InjectModel } from '@nestjs/mongoose';
3import { Model } from 'mongoose';
4import { UserDocument, User } from './user.schema';
5import PostsService from '../posts/posts.service';
6import { InjectConnection } from '@nestjs/mongoose';
7import * as mongoose from 'mongoose';
8
9@Injectable()
10class UsersService {
11 constructor(
12 @InjectModel(User.name) private userModel: Model<UserDocument>,
13 private readonly postsService: PostsService,
14 @InjectConnection() private readonly connection: mongoose.Connection,
15 ) {}
16
17 // ...
18}
19
20export default UsersService;Controlling the transaction
There are two ways of working with transactions with Mongoose. To have full control over it, we can call the startTransaction method:
1const session = await this.connection.startSession();
2session.startTransaction();When we indicate that everything worked fine, we need to call session.commitTransaction(). This writes our changes to the database.
If we encounter an error, we need to call session.abortTransaction() to indicate that we want to discard the operations we’ve performed so far. Once we’re done with the transaction, we need to call the session.endSession() method.
To indicate that we want to perform an operation within a given session, we need to use the session() method.
1async delete(userId: string) {
2 const session = await this.connection.startSession();
3
4 session.startTransaction();
5 try {
6 const user = await this.userModel
7 .findByIdAndDelete(userId)
8 .populate('posts')
9 .session(session);
10
11 if (!user) {
12 throw new NotFoundException();
13 }
14 const posts = user.posts;
15
16 await this.postsService.deleteMany(
17 posts.map((post) => post._id.toString()),
18 );
19 await session.commitTransaction();
20 } catch (error) {
21 await session.abortTransaction();
22 throw error;
23 } finally {
24 session.endSession();
25 }
26}Still, there is an important issue with the above code. Although we’ve deleted the user within a transaction, we didn’t do that when removing posts. To delete posts within a session, we need to modify the postsService.deleteMany function:
1import { Model } from 'mongoose';
2import { Injectable } from '@nestjs/common';
3import { InjectModel } from '@nestjs/mongoose';
4import { Post, PostDocument } from './post.schema';
5import * as mongoose from 'mongoose';
6
7@Injectable()
8class PostsService {
9 constructor(@InjectModel(Post.name) private postModel: Model<PostDocument>) {}
10
11 async deleteMany(
12 ids: string[],
13 session: mongoose.ClientSession | null = null,
14 ) {
15 return this.postModel.deleteMany({ _id: ids }).session(session);
16 }
17
18 // ...
19}
20
21export default PostsService;By adding the optional session argument to the deleteMany method, we can delete posts within a transaction. Let’s use it:
1async delete(userId: string) {
2 const session = await this.connection.startSession();
3
4 session.startTransaction();
5 try {
6 const user = await this.userModel
7 .findByIdAndDelete(userId)
8 .populate('posts')
9 .session(session);
10
11 if (!user) {
12 throw new NotFoundException();
13 }
14 const posts = user.posts;
15
16 await this.postsService.deleteMany(
17 posts.map((post) => post._id.toString()),
18 session,
19 );
20 await session.commitTransaction();
21 } catch (error) {
22 await session.abortTransaction();
23 throw error;
24 } finally {
25 session.endSession();
26 }
27}If removing the posts fail for some reason, the user is not deleted from the database either. Thanks to that, the whole operation either succeeds as a whole or fails completely.
A simpler way of using transactions
Instead of controlling every step of the transaction manually, we can use the session.withTransaction() helper.
1async delete(userId: string) {
2 const session = await this.connection.startSession();
3
4 await session.withTransaction(async () => {
5 const user = await this.userModel
6 .findByIdAndDelete(userId)
7 .populate('posts')
8 .session(session);
9
10 if (!user) {
11 throw new NotFoundException();
12 }
13 const posts = user.posts;
14
15 await this.postsService.deleteMany(
16 posts.map((post) => post._id.toString()),
17 session,
18 );
19 });
20
21 session.endSession();
22}Please notice that we no longer need to call startTransaction(), commitTransaction(), and abortTransaction(). We still are required to end the session with the endSession method, though.
Summary
In this article, we’ve gone through transactions in MongoDB by describing their principles and use-cases. We’ve also implemented them into our application with Mongoose. It is definitely worth it to understand transactions because they can increase the reliability of our application quite a lot.