Transactions are undoubtedly the most important new feature in MongoDB 4.0. MongoDB has supported ACID for single document operations for many years, and denormalized data meant many apps didn't need transactions. However, for certain applications, there's no way to escape the need for multi-document transactions. In this article, I'll demonstrate using transactions with the MongoDB Node.js driver and mongoose.

Transferring Money Between Accounts in Node.js

MongoDB currently only supports transactions on replica sets, not standalone servers. To run a local replica set for development on OSX or Linux, use npm to install run-rs globally and run run-rs --version 4.0.0 . Run-rs will download MongoDB 4.0.0 for you.

$ npm install run-rs -g $ run-rs --version 4.0.0 Purging database... Running '/home/node/lib/node_modules/run-rs/4.0.0/mongod' Starting replica set... Started replica set on "mongodb://localhost:27017,localhost:27018,localhost:27019" Connected to oplog

Once you have a MongoDB 4.0.0 replica set running, you'll also need v3.1.0 of the MongoDB Node.js driver.

npm install mongodb@3.1

One example of an app that needs multi-document transactions is a bank account app. If you transfer money from account A to account B , nobody should see an in-between state where money has been deducted from account A but hasn't been added to account B . And, if subtracting money from account A fails due to insufficient funds, you shouldn't add money to account B .

In MongoDB, transactions are built on top of sessions. To start a transaction, you first need to start a session using client.startSession() . You can then call the session's startTransaction() method to start a transaction.

const { MongoClient } = require ( 'mongodb' ); const uri = 'mongodb://localhost:27017,localhost:27018,localhost:27019/txn' ; const client = await MongoClient.connect(uri, { useNewUrlParser: true , replicaSet: 'rs' }); const db = client.db(); await db.dropDatabase(); await db.collection( 'Account' ).insertMany([ { name: 'A' , balance: 5 }, { name: 'B' , balance: 10 } ]); await transfer( 'A' , 'B' , 4 ); try { await transfer( 'A' , 'B' , 2 ); } catch (error) { error.message; } async function transfer ( from, to, amount ) { const session = client.startSession(); session.startTransaction(); try { const opts = { session, returnOriginal: false }; const A = await db.collection( 'Account' ). findOneAndUpdate({ name: from }, { $inc: { balance: -amount } }, opts). then(res => res.value); if (A.balance < 0 ) { throw new Error ( 'Insufficient funds: ' + (A.balance + amount)); } const B = await db.collection( 'Account' ). findOneAndUpdate({ name: to }, { $inc: { balance: amount } }, opts). then(res => res.value); await session.commitTransaction(); session.endSession(); return { from : A, to: B }; } catch (error) { await session.abortTransaction(); session.endSession(); throw error; } }

Transactions with Mongoose

To use transactions with Mongoose, you need mongoose >= 5.2.0 .

npm install mongoose@5.2

Transactions with Mongoose are similar to with the MongoDB driver. The big difference is that, with Mongoose, startSession() returns a promise rather than a session, so you need to use await .

const mongoose = require ( 'mongoose' ); const uri = 'mongodb://localhost:27017,localhost:27018,localhost:27019/txn' ; await mongoose.connect(uri, { replicaSet: 'rs' }); await mongoose.connection.dropDatabase(); const Account = mongoose.model( 'Account' , new mongoose.Schema({ name: String , balance: Number })); await Account.create([{ name: 'A' , balance: 5 }, { name: 'B' , balance: 10 }]); await transfer( 'A' , 'B' , 4 ); try { await transfer( 'A' , 'B' , 2 ); } catch (error) { error.message; } async function transfer ( from, to, amount ) { const session = await mongoose.startSession(); session.startTransaction(); try { const opts = { session, new : true }; const A = await Account. findOneAndUpdate({ name: from }, { $inc: { balance: -amount } }, opts); if (A.balance < 0 ) { throw new Error ( 'Insufficient funds: ' + (A.balance + amount)); } const B = await Account. findOneAndUpdate({ name: to }, { $inc: { balance: amount } }, opts); await session.commitTransaction(); session.endSession(); return { from : A, to: B }; } catch (error) { await session.abortTransaction(); session.endSession(); throw error; } }

With both Mongoose and the MongoDB Node.js driver, MongoDB will report a "WriteConflict" error if two transactions attempt to write conflicting data, like if two transfer() calls attempt to transfer money from the same account at the same time.

const mongoose = require ( 'mongoose' ); try { await Promise .all([ transfer( 'A' , 'B' , 4 ), transfer( 'A' , 'B' , 2 ) ]); } catch (error) { error.message; }

Moving On

Transactions are the most important feature to land in MongoDB in recent memory. The ability to execute multiple operations in isolation and potentially undo all of them is useful for any app, not just apps that need to transfer currency between accounts. For example, you can update denormalized data in multiple collections and easily undo all the operations using abortTransaction() if schema validation failed. So download run-rs, MongoDB driver 3.1.0, and Mongoose 5.2.0 and get started with transactions today!