Read-Modify-Write isolation in NoSQL, part 2: When the invariant spans multiple aggregates.

Published: (May 27, 2026 at 01:45 AM EDT)
4 min read
Source: Dev.to

Source: Dev.to

Introduction

In part 1 we saw the single‑document case, where optimistic locking saves you with a simple version field. Now we cross the line that breaks that comfort.

The Scenario

Your product sells seats, and an organization buys a license capped at 100 seats. Those seats are spread across many Teams, and each Team is its own aggregate with its own lifecycle. You can’t stuff the list of all teams into one document: it grows unbounded and violates every aggregate‑design instinct you have.

Invariant:

Σ(team.seats) ≤ 100

The sum of seats over all Team aggregates must never exceed 100.

The Naïve Approach and Its Pitfall

To enforce that on every “add seats to a team” operation, the honest move is to read the current state across every Team and sum it—a fan‑out that scans N Team documents and gets slower as the org grows.

It feels correct to read the real, current teams inside a transaction, but that intuition is the trap.

Write‑Skew Example

guard: Σ seats over all Team docs = 90   (max 100)

Tx A reads all teams → Σ = 90 → 90+8 ≤ 100 ✅ → writes Team Alpha (+8) → COMMIT
Tx B reads all teams → Σ = 90 → 90+8 ≤ 100 ✅ → writes Team Beta  (+8) → COMMIT
      └─ each reads its OWN snapshot — neither sees the other's in‑flight write.
         Two DIFFERENT Team docs → no write‑write conflict to abort.

Result:

Σ seats across Team aggregates = 106 > license cap 100

No documents collided, nothing was overwritten. The invariant dies between the documents. This is write skew.

Each transaction read a valid state and made a locally correct decision — yet the real total is now 106, and you’ve oversold a 100‑seat license.

Lost Update vs. Write Skew

  • Lost update – two writes hit the same document, one erases the other, and the stored value itself is wrong.
  • Write skew – the two writes land on different documents, both commit cleanly, and every document stays internally consistent, but the cross‑document invariant breaks.

Transactions in MongoDB

Wrapping the whole thing in a native MongoDB multi‑document transaction (≥ 4.0 on replica sets, 4.2+ on sharded clusters) gives snapshot isolation: every transaction sees a consistent point‑in‑time snapshot of the database. This eliminates classic ANSI read anomalies (dirty reads, non‑repeatable reads, phantoms) and single‑document lost updates.

However, snapshot isolation does not provide full serializability, so it can’t stop write skew. Two transactions each see a consistent snapshot, make valid decisions, and still violate the invariant.

The storage engine (WiredTiger) only aborts a transaction when two of them write the same document. In the example, Tx A writes Team Alpha, Tx B writes Team Beta – different documents, so there is nothing for WiredTiger to abort. The constraint lives in your code, not in the data the engine watches for conflicts.

Making the Invariant Visible to the Engine

The fix must make the invariant part of what the engine watches for conflicts. A common pattern is to materialize the invariant into a single document that every writer also touches:

{
  "license": {
    "usedSeats": 90,
    "maxSeats": 100
  }
}

Now the invisible skew becomes a write‑write conflict that the engine can detect.

Summary

Write skew isn’t a flaw in transactions, and MongoDB isn’t “wrong.” Write skew occurs when an invariant never becomes part of the database’s conflict‑detection model—a mismatch between the read scope (all Team docs) and the write scope (the single document you update). All remedies must serialize these writes, and none are free.

Next Steps

The first reflex many developers try is a distributed lock (e.g., using Redis) to freeze the world around the read and write. While this can achieve serializability on paper, it introduces latency, deadlocks, and TTL dilemmas.

The discussion continues in Part 3, where we explore more robust solutions.

0 views
Back to Blog

Related posts

Read more »