An object edited outside the tracked set is written back an hour later and another user's change vanishes — why, and how do you fix the write path?
answer
- the copy is a snapshot with an age
- whole-object write, not a delta
- matched on the key alone, so it succeeds
- the window opened before the transaction
- send the change, not the object
basics
~20 sThe detached object is a snapshot of the row an hour earlier, and writing it back assigns every mapped field, so a column another writer changed is overwritten with the old value and the update still succeeds.
solid answer
~50 sWhat travelled out of the set was a full copy of the row's state at read time, and putting it back writes the whole object rather than the fields the user actually edited. Between the two moments another writer changed a different column; the update matches on the key alone, one row is affected, and it quietly restores the stale value — the classic lost update. Wrapping the write in a short transaction does not help, because the staleness was accumulated before that transaction opened. Detection needs the write to carry a condition that fails if the row moved since it was read, so a stale write affects zero rows instead of winning. The structural fix is to stop round-tripping whole objects: send what the user changed, load the row inside the unit of work, and apply only those fields.
go deeper
Recall that an object taken out of a data-access layer is a copy of the row at that moment. It does not follow later changes, and writing it back writes what it remembers.
Explain why the overwrite is silent: the update is matched on identity alone, so it affects exactly one row and reports success no matter how old the values it carries are.
Diagnose from the shape of the write. Look at which columns the update actually sets, measure how long objects live outside the set, and add a condition that makes a stale write affect zero rows instead of winning.
Treat it as a contract choice. Last-writer-wins is acceptable for some data and is data loss for other data, and deciding that per field or per aggregate belongs with the domain owner rather than being inherited from whichever write path was easiest.
An object that leaves a **unit of work** is a snapshot: the values of a row as they were at the moment they were read, held in memory, ageing. Nothing keeps it in step with the database afterwards. When it comes back an hour later and is written, the layer has only that snapshot to work with, and it faithfully writes it. ## Why the other writer's change disappears Putting a detached object back writes the object, not the user's intention. The user edited two fields; the object carries every mapped field; the resulting update assigns all of them. In the interval, someone else changed a third field. Their value is in the row, is not in the snapshot, and is overwritten with what the snapshot remembers. The reason nobody notices is the shape of the statement: - The update is matched on the primary key alone — `UPDATE orders SET ... WHERE id = ?`. - The key has not changed, so exactly one row matches and the statement reports one row affected. - One row affected is indistinguishable from success, because it *is* success by every measure the layer has. This is the **lost update**, and its defining property is silence. ## What does not help | Proposed fix | Why it fails | |---|---| | Wrap the write in a transaction | Transactions govern atomicity and isolation *during* the write; the staleness was created before it opened | | Raise the isolation level | Isolation describes what concurrent statements observe, not how old a value in memory is | | Re-read the row just before writing | The whole-object assignment overwrites whatever the read produced; and the difference it shows cannot distinguish a deliberate edit from a stale one | | Hold the unit of work open across the user's editing time | Trades a lost update for held connections and long-lived locks; it does not survive real user think-time | ## What actually detects it Detection needs the write itself to describe the state the writer believed it was changing, so that the database can reject it if reality has moved on. Concretely, the update carries an extra condition beyond the key — a value that was read out with the object and is compared in the `WHERE` clause — and the write is checked for how many rows it affected: ``` UPDATE orders SET status = ?, total = ?, ... WHERE id = ? AND <state the writer believed> ``` Zero rows affected now means "the row moved since you read it", and the layer can turn that into a conflict for the application to resolve. The mechanics and transport of that carried value are a topic of their own; the point here is structural: **without some condition beyond the key, a stale write is indistinguishable from a fresh one, and it wins.** ## Shrinking the problem instead of detecting it Detection tells you a collision happened. Design decides how often one can. The main lever is to stop round-tripping whole objects: 1. **Send the change, not the object.** A request that says which fields the user edited lets the server touch only those columns, so a concurrent edit to a different column simply survives. 2. **Load inside the unit of work.** Read the row in the same short piece of work that writes it, apply the incoming fields to the loaded object, and let change detection generate a narrow update. 3. **Keep the window short.** The exposure is the time between read and write. A form that loads its data when it opens and posts an hour later has an hour-long window; one that reads fresh state at submit time has a millisecond one. 4. **Decide ownership where you can.** Fields that only one actor ever writes — a workflow status, a fulfilment stamp — cannot be lost to another actor if the write path for each is separate. ## The judgment part None of this makes concurrent editing free. Two people editing the *same* field still collide, and no amount of narrowing changes that; it needs a conflict check and a human decision about what to do with the loser's work. What the structural fixes remove is the much larger and much stupider class of loss: values nobody edited being written back because they happened to be riding along in an object. So the answer an interviewer wants has three parts. Explain the mechanism — a whole-object write from an aged snapshot, matched on key, reporting success. Add the detection — a condition beyond the key plus a rows-affected check, so the stale write fails instead of winning. Then make the design point: last-writer-wins is a legitimate choice for some data and unacceptable for other data, and choosing per field or per aggregate is a decision the domain owner should make deliberately rather than inherit from whichever write path was easiest to build.
- Why does a short transaction around the write not prevent the loss?Because the staleness was created before the transaction opened. A transaction makes the write atomic and isolates it from other statements while it runs, but it carries no information about how old the values being written are, so it commits stale ones perfectly cleanly.
- What makes a stale write fail instead of succeed?A condition in the update describing the state the writer believed it was changing, carried out with the copy and compared in the `WHERE` clause, plus a check of how many rows were affected. Zero rows means the row moved, and the layer can raise a conflict rather than report success.
- How much does sending only the edited fields actually help?It removes the large class of loss caused by writing back columns nobody touched, so a concurrent edit elsewhere in the row survives. It does not settle two writers editing the same field — that still needs a conflict check and a decision about whose work loses.
saying these in an interview costs you the question
- Thinks the write only touches the fields the user edited
- Believes wrapping the write in a transaction prevents a stale overwrite
- Assumes the layer compares against the current row before writing
- Expects a stale write to fail loudly on its own
- Proposes holding a lock across the user's thinking time