skip to content

What happens to an object that code modifies inside a unit of work that was marked read-only?

level: middleimportance: should knowfreq 52%

answer

  1. nothing to compare against
  2. no dirty check, so no statement
  3. commit emits only reads
  4. silent success is the danger

basics

~10 s

Usually it is discarded: with no snapshot and no dirty check, the layer emits no update and the unit commits having written nothing. Some layers instead declare the transaction read-only and reject the write.

solid answer

~50 s

The honest answer is that it depends on how far the marking travels. If read-only is only a local hint, the object is simply not tracked for changes: no comparison runs at the end, no `UPDATE` is emitted, and the request succeeds having lost the write. If the layer also declares the transaction or connection read-only, the engine rejects the statement and you get an error instead. The dangerous case is the first one, because nothing in the logs or the response says anything went wrong - the defect surfaces later as a row that never changed. That is why a shared service method reused from a read-only path is a classic bug source, and why teams add an explicit assertion or a test that fails when a write path is invoked under a read-only unit.

go deeper

for a junior

Recall the core outcome: with no change tracking there is no update statement, so the row keeps its old value even though the object in memory looks updated. Success in the response does not mean anything was written.

for a middle

Explain the chain - no snapshot, no dirty check, no flush - and note that some layers instead declare the transaction read-only so the engine refuses the write. Say which of the two you would rather have and why.

for a senior

Focus on detection. Describe how the bug arrives through reuse of a shared method, why tests that re-read the same object pass anyway, and how you would verify from a separate unit and turn on the strict mode where available.

for a principal

Treat the marking as a contract across teams. Decide whether read-only is enforced or merely advisory in your stack, where side writes on read paths are allowed to live, and how a mis-marked unit is prevented from also being routed away from the primary.

## Why the change disappears Automatic persistence works by comparison. The layer keeps the values it read into an object - the **snapshot** - and at the end of the unit of work compares object to snapshot, generating an `UPDATE` for every column that differs. That comparison is the **dirty check**; issuing the resulting statements is the **flush**. Marking the unit read-only removes both. No snapshot is kept, so there is nothing to compare against; no comparison runs, so nothing is queued; the flush has no work. The object in memory really does hold the new value - the caller can read it back and see the field it just set - but that value exists only in memory. When the unit ends, the object is discarded along with it and the row is untouched. So the sequence a candidate should be able to narrate is: 1. the object is loaded and returned **untracked**; 2. application code sets a field, and the in-memory object changes; 3. the unit ends, and **no comparison pass and no flush** ever look at that object; 4. commit closes a transaction that emitted only reads; 5. the caller sees success, and the row still holds the old value. ## Two behaviours, and why you cannot assume either Data-access layers differ in how far the marking is pushed: | behaviour | what a modification does | what you see | |---|---|---| | marking is a local hint only | change is not tracked, nothing is flushed | success, silently lost write | | marking is pushed to the transaction or connection | the write statement is refused by the engine | an error at the statement | A layer in the second column protects you; one in the first does not. And even in the second, only statements that actually reach the database are caught - a change that was never turned into a statement, because no dirty check ran, produces nothing for the engine to refuse. That is the subtle point: **the strict mode catches explicit writes, not the ones that were optimised away**. ## Why silence is worse than an error A rejected write is a bad request that fails loudly at the moment of the mistake, with a stack that names the offending call. A dropped write is a request that **succeeds**. Its damage shows up somewhere else: - a status field that never advances, discovered days later by a support ticket; - an idempotency or de-duplication row that was never recorded, so the same work runs twice; - a counter or audit stamp that is quietly wrong, corrupting reports built on it; - a test that passes because it asserts on the in-memory object, which does hold the new value. That last one deserves emphasis. Verifying the change by re-reading the object you just modified proves nothing inside a read-only unit; the in-memory copy always agrees with you. The assertion has to re-read the row in a **separate** unit. ## How the bug gets in It is almost never someone writing inside a path they knew was read-only. The realistic route is reuse: - a service method that both reads and updates a last-seen stamp, called from a listing endpoint marked read-only; - a lazily resolved association whose accessor performs a repair-on-read fix-up; - a background refresh that started as a pure read and grew a write during a later change; - a shared method whose read-only caller was added long after it was written. In every case the write is correct code invoked under the wrong boundary. ## Containing it A few things work in practice, in rough order of value: - **Keep the marking honest.** Read-only is a promise about the whole call tree beneath the boundary, not about the top-level method's name. If any branch may write, do not mark the unit. - **Make the strict mode the default** where the layer supports pushing read-only down, so explicit writes fail loudly rather than vanishing. - **Assert on re-read.** Tests for write paths must verify the row from a fresh unit, never from the object still in hand. - **Separate the boundaries.** Command paths get their own read-write unit; query paths never call into them. Where a read genuinely needs a side write - a cache stamp, a hit counter - move it out of the read unit rather than upgrading the read unit. - **Watch for the mixed unit.** A unit that reads a lot and writes one small row is not a candidate for the read-only marking at all, and it is also not a candidate for replica routing, since the marking is usually the routing signal too.

  • How would you catch this class of bug in tests?
    Assert against the database, not the object. Inside a read-only unit the in-memory object holds the new value, so any test that re-reads the same instance passes regardless. Verify the row from a fresh unit of work. Where the layer can push read-only down to the transaction, enabling that strict mode in tests turns silent losses into failures at the statement.
  • Is upgrading the unit to read-write the right fix when a read path needs one small write?
    Rarely. It gives up the tracking saving for the whole path and, where the marking drives routing, forces the entire read onto the primary. Better to keep the read unit read-only and perform the side write in its own short unit afterwards, accepting that the two are no longer atomic - which for a hit counter or a cache stamp is usually fine.
  • Does the strict mode catch every lost write?
    No. It refuses statements the engine actually receives. A change that was never turned into a statement, because no dirty check ran over the untracked object, produces nothing to refuse. Strict mode catches explicitly issued writes; it does not resurrect the ones the optimisation removed.

saying these in an interview costs you the question

  • Says the change is queued and applied by the next unit of work
  • Assumes the layer promotes the unit to read-write automatically
  • Verifies the write by re-reading the same in-memory object
  • Believes every layer raises an error, so the risk is theoretical
  • Treats read-only as applying only to the top method, not the whole call tree