skip to content

Application Cache Boundary

Caching finished transfer models in the service layer rather than leaning on the mapper's tiers: where the entry is populated relative to the commit, and what its key must carry. A layering call.

on this pageshow

questions

5

Where should a service read and populate an application cache, relative to its transaction?

level: middleimportance: must knowfreq 57%

answer

  1. both calls outside the transaction
  2. lookup before a connection is taken
  3. flush is not commit
  4. publish only what is durable
  5. the outermost boundary owns the store

basics

~20 s

Read before the unit of work opens, so a hit costs no connection or transaction. Populate only after the commit returns, never from inside the transaction or the mapper's flush path, so nothing that could still roll back is published.

solid answer

~40 s

Put both calls **outside** the transaction, on either side of it. The lookup goes first, before the unit of work opens: a hit should cost no connection, no transaction and no mapper call at all, which is most of the saving. On a miss, open the unit of work, load and assemble, let it commit, and store the finished model **after** the commit call returns. Populating from inside the transaction — or from a hook that runs during flush — publishes a value that other readers can see before it is durable, and that they keep seeing if the transaction then rolls back. The same placement applies on the write path: the record is changed through the mapper, and the entry is refreshed or dropped once the commit has returned.

go deeper

for a junior

Remember the shape: look in the cache first, before any database work, and only write the entry once the commit has come back successfully.

for a middle

Explain why flush is not commit, and what a rollback leaves behind when the entry was already stored: a value nothing will ever correct.

for a senior

Handle the awkward cases — nested transactions where the inner boundary is not a real commit, and a store failure after a successful commit that must not undo durable data.

for a principal

Decide where this rule is enforced: a convention every service repeats will be broken, so make the after-commit hook part of the platform rather than each method's discipline.

## The two calls and where they belong A service method that uses an application cache has exactly two extra touch points, and the whole design is deciding where they sit relative to the transactional boundary. ``` lookup(key) <-- before the unit of work opens hit -> return miss -> begin unit of work load + assemble commit store(key, model) <-- after commit returns return ``` Everything interesting follows from keeping both calls outside the transaction. ## Why the read goes first - **A hit should cost nothing on the database side.** If the lookup happens inside an open unit of work, every hit has still taken a connection from the pool and started a transaction, then thrown that away. Under load the pool, not the query, is often the scarce resource, so a cache that still borrows a connection has given back much of its value. - **A hit should not touch the mapper.** The point of caching above the mapper is to skip the mapper's work as well as the query. Reading the cache from inside the mapper's own load path defeats that. - **It keeps the transaction short.** Whatever remains inside the boundary is only the work a miss genuinely needs. The exception worth stating out loud is a read that happens *inside* a write use case — code that must see its own uncommitted changes. That read has to go to the mapper, not the cache, because the cache by construction holds the last committed answer. ## Why the populate goes after the commit Storing the assembled model while the transaction is still open makes the cache visible to other readers before the database is. Three concrete failures follow: 1. **Rollback publishes a phantom.** The transaction fails after the entry is stored — a constraint violation, a concurrency check, a timeout on a later statement — and the store now holds a value that never existed in the database. Nothing will correct it: no write happened, so no invalidation runs. It survives until its expiry. 2. **Readers see the future.** Between the store and the commit, other requests are served data the database has not accepted yet. If they act on it, the ordering error propagates outward. 3. **The value may not even be final.** Values the database supplies — column defaults, trigger effects, computed columns — reach the assembled model only after the statements have actually run, and an identifier handed back during an insert is still undone by a rollback. An entry snapshotted early can be missing, or wrong about, exactly the fields that make it useful. The same reasoning bans populating from a hook that fires during flush, or from a callback the mapper invokes while assembling objects. Flush is not commit: pending statements have reached the database, and can still be undone. Only the return of the commit call is the point at which the value is real. ## The write path mirrors it | Step | Inside the transaction | After the commit returns | |---|---|---| | Change the record through the mapper | yes | — | | Read the current state to build the new model | yes | — | | Store or refresh the cache entry | never | yes | | Drop the entry so the next read reassembles | never | yes | Two practical notes on the "after" column. First, the code that runs after the commit is *not* covered by the transaction, so a failure there — the store is unreachable, the process dies — leaves the database correct and the cache wrong. That is the right direction to fail, but it means the entry must be able to be wrong safely, which is what expiry is for. Second, "after commit" has to mean after the *outermost* commit. In a nested call where an inner service participates in a caller's transaction, the inner method's own boundary may not be a real commit at all; hanging the store off it publishes early. The cleanest arrangement is to let the outermost boundary trigger the population, rather than each layer doing its own. ## What this placement does not solve Getting the order right prevents publishing something the database rejected. It does not make the entry fresh: from the moment it is stored, another writer can change the underlying data and the entry will happily keep serving what it captured. Ordering is a correctness rule about *never publishing an uncommitted value*; keeping a committed value current afterwards is a separate discipline with its own trade-offs.

  • Why is a hook that fires during flush the wrong place to populate the entry?
    Flush only sends pending statements to the database; the transaction can still roll back afterwards. An entry written at flush time can therefore describe a state that never became durable, and no later write exists to correct it.
  • A service method participates in a caller's transaction. Where does its populate belong then?
    At the outermost boundary, not the inner one. The inner method's exit is not a commit — the enclosing transaction can still fail. Hanging the store off the outer commit, for example by registering the work to run once that commit returns, keeps the rule intact.
  • What if storing the entry fails after the commit succeeded?
    The database is correct and the cache is missing or stale, which is the safe direction. Log it and rely on the entry's expiry as the backstop; do not retry inside the transaction or roll the commit back, which would sacrifice durable, correct data to a cache.

saying these in an interview costs you the question

  • Populates the entry inside the transaction so readers see it sooner
  • Treats a flush as the point where data becomes durable
  • Reads the cache after opening the unit of work, keeping a connection per hit
  • Rolls the transaction back when the cache store is unavailable
  • Populates from the inner method of a nested transaction
  • Serves a write path's own read from the cache instead of the mapper
open as a page

What does an application cache of transfer models give up that the mapper's own cache keeps?

level: middleimportance: must knowfreq 60%

basics

~20 s

Identity, deferred completion and change detection. A cached answer is a detached snapshot with nothing behind it, so no shared instance, no link that can still load, no dirty checking. In exchange it skips the whole assembly path and travels between processes.

open as a page

When a service caches a finished transfer model above the mapper, what does the entry hold?

level: juniorimportance: should knowfreq 50%

basics

~20 s

A plain, already-assembled snapshot of a read result, usually serialized. It is detached: no unit of work tracks it, editing it writes nothing to the database, and it shows the data as of the moment it was built.

open as a page

What must the key of an application-cached transfer model carry beyond the record's identifier?

level: seniorimportance: should knowfreq 52%

basics

~20 s

Every input that changed the stored answer: the tenant, the viewer's permission scope if the model is filtered or redacted per viewer, the query parameters, and a version for the model's shape. An identifier alone serves one caller's answer to another.

open as a page

Your service already runs the mapper's shared cache, and you are asked to add an application cache of assembled models. How do you decide, and what does running both cost?

level: principalimportance: should knowfreq 45%

basics

~20 s

Decide by where the cost is: row fetching favours the mapper's tier, which maintains itself; assembly favours an application entry, which the mapper cannot help with. Running both doubles the invalidation surface, so keep their scopes disjoint.

open as a page