skip to content

When a data-access layer locks a loaded object, what does that lock cover for its deferred references and child collections?

level: middleimportance: should knowfreq 44%

answer

  1. the lock is about rows, not the graph
  2. an unloaded reference issued no statement
  3. children live in another table
  4. a row that does not exist cannot be locked
  5. one gatekeeper row per invariant

basics

~10 s

Only the rows the layer read to build that object. An unloaded reference and child-collection rows are separate rows, so they can change while you hold the lock unless you request an extended scope.

solid answer

~50 s

A lock request names an object, and the layer turns it into a lock on the row or rows it actually reads for that object. A deferred reference that has not been loaded is just an identifier in a column: no statement was issued for the target row, so no lock exists on it, and loading it later inside the same transaction is an ordinary unlocked read unless you ask again. A child collection is worse, because it is a set of rows in another table that the parent's lock never touched — new children can be inserted and existing ones changed while you hold the parent. Some layers offer an extended scope that propagates the request along mapped associations, which is genuine protection but enlarges the locked footprint and the wait it causes. The guarantee you have by default is narrow: this row cannot change under me.

go deeper

for a junior

Remember that a lock request covers the rows read for that one object; the collection you can navigate to is other rows and is not covered.

for a middle

Explain the deferred-reference case precisely: the locked row holds the pointer, the target row was never read and so was never locked.

for a senior

Show the insert hole — no lock stops a row that does not exist yet — and the gatekeeper-row convention that makes an aggregate-wide invariant hold.

for a principal

Weigh an extended lock scope against its footprint: wider guarantees mean more rows held for the whole boundary and more overlap between concurrent claimants.

People reach for a pessimistic lock because they want an invariant to hold while they work. The invariant is usually about a small graph — an order and its lines, an account and its entries — while the lock is about **rows**. Getting the difference wrong produces code that looks protected and is not. ## What the layer locks The request is expressed against an object, but what reaches the database is a locking read for the rows that make up that object as the layer loads it: - Ordinary mapping: **one row in one table**. - An object whose state is spread across more than one table — an inheritance layout or an additional table holding some fields — makes the layer read several rows, and a locking read normally covers the rows it reads to build the object. - Nothing else. A row the layer did not read while satisfying your request is not locked, whatever the mapping says about the relationship. ## Deferred references A reference loaded on demand is stored as a foreign-key value in the row you locked. Locking that row locks the *pointer*, not the target: 1. The pointer cannot be repointed by anyone else while you hold the lock — that much is real, and it is sometimes all you need. 2. The referenced row is untouched. Another transaction can update it freely. 3. Navigating to it later, inside the same transaction, issues a plain read. It does not inherit the parent's lock mode. So "I locked the order, so its customer cannot change" is false. If the customer's state matters to the decision, the customer row needs its own lock request. ## Child collections This is the case that bites hardest, because the collection feels like part of the object. It is not: it is a set of rows in another table joined by a foreign key. Locking the parent row prevents nobody from: - updating an existing child row, - deleting one, - or, most awkwardly, **inserting a new child** — a row that does not exist yet cannot be locked at all, and its arrival is exactly what breaks a "sum of children must not exceed the parent's limit" invariant. | What you want to hold still | Does locking the parent row do it? | |---|---| | The parent's own fields | Yes | | Which row a deferred reference points at | Yes — the pointer lives in the locked row | | The referenced object's fields | No | | Existing child rows | No | | New children appearing | No | ## Extended scope, and its price Layers commonly offer a wider lock scope that propagates the request along associations the mapping marks as owned by the parent. Where it exists, it does what it says: the locking read is issued for the associated rows too. Two caveats are worth stating in an interview. First, it follows the **mapping**, so it protects what the mapping declares to be part of the aggregate and nothing beyond that. Second, it multiplies the locked footprint — more rows held for the same boundary, more transactions queued behind you, and a larger chance of two units of work claiming overlapping sets in different orders. ## Designing around the narrow guarantee The workable pattern is to make one row the gatekeeper for the invariant: 1. Pick the row that represents the whole — usually the parent or aggregate root. 2. Require **every** writer that could break the invariant, including one that only inserts a child, to take the lock on that row first. 3. Do the child reads after the lock is held, so they are protected by the serialisation the gatekeeper provides. That works because the lock's real function is mutual exclusion between the code paths that agree to use it. It buys nothing against a path that writes the child table without asking, which is why the convention has to be enforced in the code rather than assumed. Where no such gatekeeper row exists, the honest answers are to add one, to lock the child rows explicitly, or to express the invariant as a database constraint that no application path can bypass. The sentence to carry away: **a lock request protects the rows the layer read for it, not the object graph you can navigate from them.**

  • How do you protect an invariant that spans a parent and its children?
    Nominate the parent row as the gatekeeper and make every path that could break the invariant — including one that only inserts a child — take the lock on it before writing. The exclusion is between the code paths that cooperate, so the rule has to be enforced in the code, or backed by a constraint the database itself checks.
  • Why can't the layer simply lock the children when it locks the parent?
    It can, where an extended scope along mapped associations is offered, but only for rows that already exist and only along the associations the mapping declares. It also enlarges the held footprint for the whole boundary, so it trades a wider guarantee for more waiting and more overlap between concurrent claims.

saying these in an interview costs you the question

  • Believes locking the parent object also locks its child rows
  • Thinks navigating to a deferred reference inherits the parent's lock mode
  • Expects a row lock to prevent new child rows from being inserted
  • Assumes an extended lock scope reaches every association, not just mapped ones
  • Treats the loaded object graph as the unit the database locks