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.

// Boilerplate: connect to DB
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();

// Insert accounts and transfer some money
await db.collection('Account').insertMany([
  { name: 'A', balance: 5 },
  { name: 'B', balance: 10 }
]);

await transfer('A', 'B', 4); // Success
try {
  // Fails because then A would have a negative balance
  await transfer('A', 'B', 2);
} catch (error) {
  error.message; // "Insufficient funds: 1"
}

// The actual transfer logic
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) {
      // If A would have negative balance, fail and abort the transaction
      // `session.abortTransaction()` will undo the above `findOneAndUpdate()`
      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) {
    // If an error occurred, abort the whole transaction and
    // undo any changes that might have happened
    await session.abortTransaction();
    session.endSession();
    throw error; // Rethrow so calling function sees 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.

// Boilerplate: connect to DB
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
}));

// Insert accounts and transfer some money
await Account.create([{ name: 'A', balance: 5 }, { name: 'B', balance: 10 }]);

await transfer('A', 'B', 4); // Success
try {
  // Fails because then A would have a negative balance
  await transfer('A', 'B', 2);
} catch (error) {
  error.message; // "Insufficient funds: 1"
}

// The actual transfer logic
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) {
      // If A would have negative balance, fail and abort the transaction
      // `session.abortTransaction()` will undo the above `findOneAndUpdate()`
      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) {
    // If an error occurred, abort the whole transaction and
    // undo any changes that might have happened
    await session.abortTransaction();
    session.endSession();
    throw error; // Rethrow so calling function sees 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; // "MongoError: WriteConflict"
}

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!

Transactions are much better with async/await in Node.js so you can use try/catch rather than promise chaining. If you're looking to get up to speed with async/await fast, check out my new ebook, Mastering Async/Await.

Found a typo or error? Open up a pull request! This post is available as markdown on Github
comments powered by Disqus