Patterns
Coarse-Grained Lock
Lock the parent once; every child inside it is implicitly protected by the same acquisition.
Coarse-Grained Lock
The pattern
Lock one object and let the lock transitively cover all the related objects inside it. The database sees a single lock; the application sees many objects protected. Often the natural form is to lock the parent (or aggregate root) by id, and every child of that parent is implicitly protected because application code only navigates from the root.
The contrast is with fine-grained locking, where each child gets its own lock. Fine-grained is more precise (unrelated transactions don’t block each other) but more deadlock-prone (every lock acquisition is a chance to deadlock with another transaction’s order).
Where it fits
When a business transaction spans multiple records that must change together — an order with line items, an invoice with payments, a workflow with steps. Locking each piece separately invites partial locks and deadlocks; one coarse lock is simpler and faster.
It does not fit when the protected set has no natural parent — you can’t coarse-lock an arbitrary set of records unless there is an aggregate root to lock by.
A worked example
EditLockService.AcquireLock in the Storeboost monorepo acquires a single monitor that covers every edit on one creative:
public EditLockResult AcquireLock(string creativeId, string sessionId, /* ... */)
{
var gate = _stripedLocks.GetOrAdd(creativeId, _ => new object());
lock (gate)
{
// renew same session, or 409-style conflict if another session holds non-expired lock
_locks[creativeId] = entry;
return new EditLockResult(true, entry);
}
}
A ConcurrentDictionary<string, object> maps each creativeId to its own gate; lock (gate) then serializes every edit on that creative while leaving other creatives unblocked. The lock is taken on the root (the creative record), and the children — its asset variants, panel placements, copy fields — follow inside the same lock window. One acquisition, many protected objects.
Tradeoffs
- One round-trip to lock everything; one round-trip to release.
- Deadlock-free when every transaction follows the same coarse path.
- More contention — unrelated transactions block each other because they share the coarse lock.
- Requires a natural parent — coarse locking by an id that doesn’t actually parent the protected set is a bug.
- Less precise — a transaction that only needs one line item blocks the whole order.
When to use, when not to
Use when the business invariant spans multiple records that share a natural parent. Don’t use when the lock surface is much larger than the transaction needs. The natural DDD fit: aggregates are coarse-grained by definition; locking the root locks everything inside.