Protect AtlasMart invariants with atomic single-document updates first, and recognize when a transaction would only compensate for an avoidable document boundary.
Single-Document Atomicity First: When Transactions Are Unnecessary
Protect one-document invariants with conditional atomic writes before accepting the latency, retry, and resource costs of a multi-document transaction.
Learning objectives
Define MongoDB single-document atomicity and identify the invariant boundary it protects.
Replace read-check-write sequences with conditional atomic updates when one document owns the invariant.
Explain why updateMany() is not atomic as one
multi-document unit even though each changed document is
atomic.
Recognize when embedding or denormalizing an aggregate removes the need for a distributed transaction.
State honestly when a multi-document invariant still requires coordination.
This lesson pins MongoDB Community Server
8.3.8 with
mongodb/mongodb-community-server:8.3.8-ubuntu2204-slim, mongosh 2.10.0, and PyMongo
4.17.0 where a driver example is used. Transactions
require a replica set or sharded cluster, so the mandatory local
lab runs a disposable replica set published only on loopback
beginning at 127.0.0.1:27077. Authentication and
TLS are disabled only for this isolated learning topology.
Feature Compatibility Version (FCV) is observed and never
changed. Unless a section explicitly overrides them, read/write
concern and read preference use the transaction/client defaults
described beside the example. Atlas, Search, Vector Search, KMS,
and Enterprise Advanced are not mandatory. A one-member replica
set is used only because later transaction examples require a
replica-set-capable topology; this lesson deliberately solves
the core invariant without opening a transaction. The product
commands were not executed in this generation environment
because Docker, mongod, mongosh, and PyMongo are unavailable
here; measured output must be recorded on the learner's machine.
1. AtlasMart problem: prevent two reservations from consuming the same stock
Atomicity means an operation appears
indivisible at its protected boundary. MongoDB write operations
are atomic for one document, even when they change several
fields or array elements inside that document. An
invariant is a rule that must remain true after
every accepted write; here, available must never
fall below zero and every accepted reservation must be
represented in the same stock aggregate.
A transaction is not the first tool to reach for. If the stock count and reservation list belong to one bounded SKU aggregate, a single conditional update can test capacity and mutate the entire invariant in one atomic document write.
docker rm -f atlasmart-mongo-ch13-l1 2>/dev/null || truedocker volume rm atlasmart-mongo-ch13-l1-data 2>/dev/null || truedocker run -d --name atlasmart-mongo-ch13-l1 \ -p 127.0.0.1:27077:27017 \ -v atlasmart-mongo-ch13-l1-data:/data/db \ mongodb/mongodb-community-server:8.3.8-ubuntu2204-slim --replSet atlasmart-rs13-l1 --bind_ip_alluntil mongosh "mongodb://127.0.0.1:27077/admin?directConnection=true" --quiet --eval \'quit(db.runCommand({ping:1}).ok === 1 ? 0 : 1)'; do sleep 1; donemongosh "mongodb://127.0.0.1:27077/admin?directConnection=true" --quiet --eval \'rs.initiate({_id:"atlasmart-rs13-l1",members:[{_id:0,host:"localhost:27017"}]})'until mongosh "mongodb://127.0.0.1:27077/admin?directConnection=true&replicaSet=atlasmart-rs13-l1" --quiet --eval \'quit(db.hello().isWritablePrimary ? 0 : 1)'; do sleep 1; donemongosh "mongodb://127.0.0.1:27077/admin?directConnection=true&replicaSet=atlasmart-rs13-l1" --quiet --eval \'printjson({server:db.version(),setName:db.hello().setName,primary:db.hello().isWritablePrimary}); printjson(db.runCommand({getParameter:1,featureCompatibilityVersion:1,transactionLifetimeLimitSeconds:1,maxTransactionLockRequestTimeoutMillis:1}))'
2. Establish the bounded inventory aggregate
The fixture starts with five available units. The reservation array is intentionally bounded for the lesson; a production design would need a retention/bucketing policy before allowing it to grow without limit.
const c=db.inventory_ch13_l1;c.drop();c.insertOne({ _id:"SKU-RED-CHAIR", tenantId:"tenant-a", available:5, reserved:0, reservations:[]});printjson(c.findOne({_id:"SKU-RED-CHAIR"}));
3. Deliberately wrong: read, calculate, then write stale state
Two callers can read the same value before either writes. The
controlled example stores two stale snapshots, then applies both
calculations. The final document looks internally valid, but the
application could already have promised two four-unit
reservations. This is a lost-update style
business bug: the second absolute $set overwrites
evidence of the first decision.
const c=db.inventory_ch13_l1;const staleA=c.findOne({_id:"SKU-RED-CHAIR"});const staleB=c.findOne({_id:"SKU-RED-CHAIR"});// Two callers both believe they reserved 4 units from the same stale state.c.updateOne({_id:"SKU-RED-CHAIR"},{$set:{available:staleA.available-4,reserved:staleA.reserved+4}});c.updateOne({_id:"SKU-RED-CHAIR"},{$set:{available:staleB.available-4,reserved:staleB.reserved+4}});printjson(c.findOne({_id:"SKU-RED-CHAIR"}));
The bug does not require multiple documents. It occurs because correctness was split between a prior read and a later write. Wrapping this pattern in a transaction is possible, but a conditional single-document update is simpler and has a smaller coordination boundary.
4. Repair: move the predicate into the atomic write
The corrected update includes the expected business
precondition—enough stock—in the write filter itself. MongoDB
re-evaluates that filter at the atomic document write. The first
four-unit reservation should report matchedCount:1;
the second should report matchedCount:0 because
only one unit remains. Both the quantity fields and reservation
evidence change together.
const c=db.inventory_ch13_l1;c.replaceOne({_id:"SKU-RED-CHAIR"},{_id:"SKU-RED-CHAIR",tenantId:"tenant-a",available:5,reserved:0,reservations:[]});function reserve(orderId,qty) { const r=c.updateOne( {_id:"SKU-RED-CHAIR",tenantId:"tenant-a",available:{$gte:qty}}, {$inc:{available:-qty,reserved:qty},$push:{reservations:{orderId,qty}}} ); return {orderId,matched:r.matchedCount,modified:r.modifiedCount};}printjson(reserve("ORD-A",4));printjson(reserve("ORD-B",4));printjson(c.findOne({_id:"SKU-RED-CHAIR"}));
| Evidence | Interpretation |
|---|---|
matchedCount=1 |
The precondition held when the write was applied. |
modifiedCount=1 |
The document state actually changed. |
Second matchedCount=0 |
The request did not obtain inventory; the application must not claim success. |
| One document contains counts + reservation | The invariant fits the single-document atomic boundary. |
5. Boundary: when one document is no longer enough
If AtlasMart must atomically debit one independently owned wallet document and credit another, or atomically mutate inventory and a separately governed payment ledger, one document cannot protect the complete invariant without redesigning ownership. MongoDB supports multi-document transactions for those cases. But the server documentation explicitly warns that distributed transactions cost more than single-document writes and should not replace effective schema design.
Production judgment. Prefer the smallest atomic boundary that actually owns the invariant. Large “god documents” are not a free substitute for transactions: they can create growth, hot-document contention, index fan-out, and lifecycle coupling. On replicas or shards, single-document atomicity remains the document boundary, but durability and visibility still depend on read/write concern. Tenant predicates are authorization requirements, not merely query optimization. Test duplicate requests, stale clients, and retry behavior, and keep idempotency keys where business requests can be repeated.
Bridge. Lesson 2 uses a genuinely cross-document transfer to justify a transaction and then traces the session, start, commit, abort, and driver APIs.
docker rm -f atlasmart-mongo-ch13-l1docker volume rm atlasmart-mongo-ch13-l1-data
Check your understanding
- What does MongoDB guarantee atomically by default?
- Why is read-check-write weaker than a conditional update?
-
Does
updateMany()make all matching documents one atomic unit? - When is a transaction justified?
- Why can embedding too much also be harmful?
Review the answers
1. A single write is atomic at the single-document boundary, even when multiple fields in that document change.
2. The value can change between the read and write; moving the predicate into the atomic write lets the server test and mutate one document as one protected operation.
3. No. Each individual document modification is atomic, but the multi-document operation as a whole is not.
4. When one required invariant truly spans multiple independently stored documents/collections and cannot be safely redesigned into a smaller atomic aggregate or workflow.
5. It can create unbounded growth, hot documents, index/write amplification, and lifecycle coupling.
Authoritative references
- MongoDB 8.3 release notes — Current 8.3 release line and patch-sensitive server behavior.
- Atomicity and transactions — Single-document atomicity and guidance to minimize unnecessary distributed transactions.
- Transactions — Sessions, transaction read/write concern, read preference, and transaction semantics.
- Drivers API for transactions — Callback versus core APIs and retry labels for transient transactions and ambiguous commits.
- Transaction production considerations — Runtime, locking, cache, DDL, conflicts, and operational constraints.
- Sharded transaction considerations — Cross-shard snapshot semantics, commit coordination, migrations, and outside reads.
- Transactions and operations — Operations permitted and prohibited inside transactions.
- $currentOp — Session/transaction observability including lsid, txnNumber, timing, and sharded coordinators.
- PyMongo transactions — PyMongo session, with_transaction, retry, and callback behavior.
- PyMongo release notes — Current PyMongo 4.17 behavior and session APIs.
- mongosh release notes — Current mongosh 2.10.0 release baseline.