Imagine you’re working on a backend of a fintech startup. This piece of code handles a fund transfer functionality, and it is mission critical that the transactions are consistent.
One way is the basic approach, you increase the balance in recipient’s account, and reduce the balance in sender’s account, two API calls. But what if, the DB crashed in middle, or some network issue occurred! This creates a state called Database Inconsistency, and it’s where we need Transactions.
Transactions ensure data consistency by guaranteeing that a series of database operations are treated as a single unit. This means either all operations succeed, or none of them do, preventing data inconsistencies.
Database transactions adhere to the ACID properties:
-
Atomicity: All operations are treated as one.
-
Consistency: The database transitions from one valid state to another.
-
Isolation: Transactions are isolated from each other, preventing conflicts.
-
Durability: Once committed, changes persist even in case of system failures.
In mongoose, this especially becomes much easier to work with. In our case, we can specify when we started a session, using:
const session = await mongoose.startSession();
Now, we have two ways of approaching:
-
Either do an API call, and check if it resolved correctly, then do a
session.commitTransaction()
. This creates an awful lot of commit statements, violating the DRY principle. -
We can do something called
session.withTransaction()
accountRoute.post("/transfer", authMiddleware, async (req,res) => {
const fromAcntUserId = req.userId
const toAcntUserId = req.body.to
const txnAmount = parseFloat(req.body.amount)
if (txnAmount <= 0) {
return res.status(400).json({
error: "Invalid Transaction Amount"
})
}
const session = await mongoose.startSession();
let fromAcnt, toAcnt;
try {
await session.withTransaction(async() => {
fromAcnt = await Account.findOneAndUpdate(
{ userId: fromAcntUserId, balance: { $gte: txnAmount }},
{ $inc: { balance: - txnAmount } },
{new : true, session}
)
if (!fromAcnt) {
throw new Error("Insufficient funds or sender not found.")
}
toAcnt = await Account.findOneAndUpdate(
{ userId: toAcntUserId },
{ $inc: { balance: txnAmount } },
{new : true, session}
)
if (!toAcnt) {
throw new Error("Recipient account not found")
}
})
res.status(200).json({
message: "Transfer Successful",
fromAccountBalance: fromAcnt.balance,
toAccountBalance: toAcnt.balance
})
}
catch (error) {
return res.status(400).json({
error: error.message
})
}
finally {
session.endSession()
}
})
This code first finds the sender’s account (fromAcnt
) and checks if their balance is sufficient. If not, it throws an error. Then, it finds the recipient’s account (toAcnt
) and updates their balance.
The beauty of second approach is, only if all the steps mentioned in the await session.withTransaction(async () => {...
are executed successfully, is the whole transaction committed fully. If any one of it fails, the whole transaction is rolled back.
At last, after transaction has been either rolled back or committed, session.endSession()
, to close this session.