skip to content

Why can a projected result not be changed and saved back the way a loaded mapped object can?

level: middleimportance: must knowfreq 57%

answer

  1. the mapper never registered it
  2. no snapshot means nothing to diff
  3. absent columns, not deferred ones
  4. re-load by key to write

basics

~20 s

Nothing about a projected row is registered - no snapshot, no identity-map entry, no version read - so a flush has nothing to compare or update. To write, load the mapped object by its key.

solid answer

~40 s

When a mapper materialises a mapped object it does bookkeeping: it puts the instance in the identity map under its key, keeps a snapshot of the column values it loaded, and remembers the version if there is one. At flush it compares current state against that snapshot and emits an update for whatever differs. A projection skips all of it - the layer built a plain value carrier and forgot about it. So mutating a projected result changes nothing in the database; there is no snapshot to diff against and often no key at all. Columns the statement did not select are absent rather than deferred, and links cannot be walked afterwards. To write, re-read the mapped object by key inside a unit of work and change it there.

go deeper

for a junior

Remember the rule and the reason: a projection is values, not a managed object, so changing it saves nothing. If you need to write, load the real object by its key first.

for a middle

Explain the bookkeeping you skipped - identity map, snapshot, version - and why change detection needs the snapshot. Say clearly that unselected columns are absent rather than lazily loadable.

for a senior

The judgment is about failure mode: this mistake is silent. Show how you keep read carriers out of write paths and how you spot the write that quietly does nothing.

for a principal

Decide the convention: whether read types are physically separated from the write model, and what you accept in exchange for the extra read that every projected write path costs.

## The bookkeeping a tracked object gets When a mapping layer turns a row into a mapped object it does more than fill fields. Typically it: 1. **Registers the instance in the identity map** under its key, so a second load of the same row in the same unit of work returns the same instance rather than a duplicate. 2. **Keeps a snapshot** of the column values as loaded, held beside the instance. 3. **Records the version**, where the class has a version column, so a later update can check it in the `WHERE` clause. 4. **Installs stand-ins** for links that were not fetched, so touching one later can resolve it. At flush the layer walks what it is tracking, compares each instance against its snapshot, and emits an `UPDATE` for the columns that differ. That comparison - **dirty checking** - is the entire mechanism behind "change the object and it gets saved". It works only because the snapshot exists. ## What a projected row gets instead | | Tracked mapped object | Projected result | |---|---|---| | Identity-map entry | Yes, under its key | None | | Snapshot of loaded values | Yes | None | | Version remembered | Yes, if mapped | No | | Change detected at flush | Yes | Nothing to detect | | Unselected data | Deferred, resolvable | Absent, no field at all | | Cost per row | Snapshot plus registration | The values themselves | So a projected result is a plain carrier of values. Changing one of its fields changes an object the mapper has never heard of. Flush walks its tracked set, does not find it, and writes nothing. No error is raised, which is what makes the mistake survivable in a test and expensive in production: the code looks like a write and is a no-op. ## Absent is not deferred The second half of the same idea. On a tracked object an unfetched link is *deferred*: the field holds a stand-in and touching it can trigger another statement. On a projected result an unselected column is *missing*: the type has no field for it, nothing is installed, and nothing can fire. That is a feature - it is why a projection cannot surprise you with a statement during rendering, long after the unit of work you were reasoning about - but it means the shape of the projection must be decided by the use case up front. If the screen later needs one more field, the statement changes. ## How you write when the read was projected - **Re-load by key.** Read the mapped object inside a unit of work, change it, let the flush do its job. This is a second statement, and that is the honest cost of having read cheaply the first time. - **Name the columns in an explicit statement.** Where the change is mechanical and does not need the model's behaviour, an `UPDATE ... WHERE id = ?` expresses it directly. Note that a statement issued this way bypasses the tracked set, so any copy of that row already loaded in the current unit of work is now stale. - **Do not try to reconstruct a snapshot.** Some layers will let you attach a detached instance, but a projection is not a detached object - it has no key-and-values completeness. Inventing the missing columns in order to "save" it is how blanks get written over real data. ## Where this bites in practice - A method reads a projection for display, a later change to the same code path sets a field on it, and the write silently disappears. Naming projected types so they read as read-only carriers is a cheap defence. - Code checks a version value carried on a projection and assumes optimistic checking is in force. It is not: no update is being emitted, and the version was read as an ordinary column, at an arbitrary earlier moment. If you want the concurrency check you have to issue an update that names the version in its `WHERE` clause. - A projected read is used to decide a write, and the two are separated by time. That is a read-then-write race regardless of the read's shape; the projection neither causes it nor protects against it. ## The short version Tracking is a service the mapper offers in exchange for materialising a full mapped object and remembering what it looked like. Projecting is declining that service. You get the values cheaply and you give up the write path - which is the right trade for a read, and the wrong one the moment the code wants to change something.

  • If the projection carries the primary key, can the layer not just track it?
    Carrying a key lets you find the row again; it does not give the layer a snapshot it never took. Without the loaded column values there is nothing to compare against, so any write derived from a projection either re-reads the mapped object by that key or names the columns it changes explicitly.
  • Does a projected read still need a unit of work kept open?
    It runs inside a transaction like any statement, but nothing needs to stay open afterwards: there is nothing to flush and no link left to resolve. Read consistency is the ordinary question - one statement sees one consistent view, several do not unless the isolation level guarantees it.
  • Why is a silent no-op worse than an error here?
    Because it passes review and passes most tests. The code reads like a write, the flush completes, and nothing is logged. Teams usually defend against it by naming projected types as read carriers and keeping them out of the packages where write logic lives.

saying these in an interview costs you the question

  • Mutates a projected result and expects the change to be written
  • Thinks unselected columns will load on first access like a link
  • Believes a projection is tracked because it carries the key
  • Assumes the mapper can diff against a snapshot it never took
  • Reads a version column on a projection and calls it optimistic checking