What is a data-access cache that outlives a unit of work and is keyed by identifier, and which read does it serve?
answer
- lives on after the unit of work
- keyed by type and identifier only
- a hit sends no statement
- stores state, hands out copies
- evictable, so never authoritative
basics
~20 sA shared cache holds mapped row state by type and identifier outside any single unit of work, so a load by key that hits it returns an object with no statement sent. Filtered queries still reach the database.
solid answer
~40 sEach unit of work keeps its own map of the objects it has already loaded, and that map dies with it. A shared cache sits behind it: entries keyed by mapped type plus identifier, holding the row's state rather than live objects, and reused by later units of work. On a load by key the layer looks there before emitting a `SELECT`; a hit builds a fresh instance into the caller's own map, so no statement runs at all. It answers only lookups by identifier — a query with a `WHERE` clause still executes, because an identifier-keyed store cannot tell which entries satisfy a predicate. An entry can also be evicted for capacity at any moment, so the load path behind a miss must always work.
go deeper
Remember the two facts that get asked: it survives past one unit of work, and it is keyed by identifier, so only a load by key can hit it.
Explain that entries hold state rather than objects, so each unit of work hydrates its own copy, and that a predicate query may fill the cache while never being served from it.
Show that you treat it as evictable and cold after every deploy, so the load path must carry full traffic on its own, and that you weigh read-by-key volume against write volume before caching a type.
Frame it as buying latency with memory and a class of correctness risk, and be ready to say which types are worth that trade and what evidence would make you turn it off.
A data-access layer keeps copies of rows at more than one depth. The one this question is about **outlives a single unit of work**: its entries survive after the unit of work that loaded the row has closed, and later units of work — later requests, later transactions, in one process or in a store several processes share — read from it. Its key is fixed: **the mapped type plus the row's identifier**, and nothing else. ## What an entry holds An entry is a snapshot of **state**, not an object graph: - the row's mapped column values, in whatever internal form the layer can rebuild an instance from - the identifier, which is the key rather than part of the payload - usually the version or timestamp stamp the write path compares against - for a link to another object, the *identifier* of the other side — a stand-in is resolved later, not stored - nothing belonging to a particular caller: no live instance, no reference to the map it came from Because the entry is state and not an object, every unit of work that hits it **builds its own instance** and registers that instance in its own map. Two concurrent requests reading the same identifier end up with two equal objects, not one shared one. That is deliberate: an object handed to application code is mutable, and sharing one across concurrent units of work would let one caller's half-finished edits show up in another caller's read before anything had been written. ## The read it can answer Exactly one shape of read: *give me the row with this identifier*. That covers more ground than an explicit load call: 1. an explicit load by key 2. resolving a to-one association through its stand-in, which is a load by the stored key value 3. resolving the members of a collection entry that stores only member identifiers 4. every repeat of the same identifier from a later unit of work A read with a predicate — `SELECT * FROM orders WHERE status = 'NEW'` — cannot be answered from it, because an identifier-keyed map holds no index over column values and cannot know which entries match. That query goes to the database. The traffic is one-way but not empty, though: in many layers the rows such a query materialises are afterwards **written into** identifier entries, so a query can populate a cache that will never serve it. ## What a hit saves, and what it still costs | a hit avoids | a hit still pays | |---|---| | the statement round trip and the parse, plan and execute on the engine | a lookup in the shared store, which may itself cross a network | | the engine's buffer and consistency work for that row | building an instance from the snapshot and registering it locally | | contention on a row that every request reads | keeping the entry correct on every write to that row | The right-hand column is why caching is a decision rather than a default. A row read hundreds of times between writes turns the table strongly in your favour. A row written about as often as it is read pays invalidation on every write **and** a miss on the next read, so the cache makes the system slower while adding a new way to be wrong. ## Why it is never the source of truth Three things remove an entry without the application asking: - **capacity** — a bounded store drops entries to free space, at a moment nothing in the application chose - **a write** — the entry for a changed row is dropped or replaced by whichever strategy that type uses - **a cold start** — a restarted process, a new instance or a redeploy all begin with nothing in it So the load path behind a miss has to work at all times, and be fast enough to survive the whole cache disappearing at once. Treat the cache as an accelerator on top of a correct load path, never as storage: the database defines the truth and the cache only removes work. A design that would break with an empty cache is a design that will break, because sooner or later the cache will be empty. ## What an interviewer is listening for - that you name the key precisely — type plus identifier, not "the query" and not "the object" - that a hit yields a copy per unit of work rather than one shared instance - that a filtered query is not served by it even though it may fill it - that your first question about a candidate table is the ratio of reads by key to writes - that you can say what happens the moment the entry is not there
- Two units of work hit the same entry at the same time. Do they share one object?No. The entry stores state, so each unit of work hydrates its own instance into its own map. An edit in one is invisible to the other until it is written and the entry is dropped or replaced. Sharing one mutable instance would leak partially edited values between concurrent callers.
- Can a lookup by a unique natural key be served without a statement?Only if the layer keeps a second kind of entry mapping that natural key to the identifier. Otherwise the lookup runs a statement to find the identifier, after which the identifier entry can supply the state. The main store is keyed by identifier and knows nothing about other unique columns.
- Does a hit always avoid a network round trip?No. It avoids the database statement. If the shared store lives in the same process, the saving is the full round trip; if the entries live in a store outside the process, a hit still costs a network call, just a cheaper and more predictable one than the query it replaced.
A library hold shelf: with the call number you collect a book instantly, but to find every book on a subject you still walk the stacks.
saying these in an interview costs you the question
- Thinks it caches query results rather than rows by identifier
- Believes a hit hands every caller the same live object
- Assumes any read of a cached type is served from it, filters included
- Treats the cache as authoritative, so a miss reads as missing data
- Confuses it with the per-unit-of-work map that is discarded at the end
- Assumes caching a type is free because reads get faster