Why must an after-commit callback avoid resolving a deferred link or writing through the unit of work that just committed?
answer
- the unit is already closed
- stand-in with no route home
- capture values before, act after
- a write there joins nothing
- atomic work belongs inside the unit
basics
~20 sBy the time an after-commit callback runs, its unit of work is finished. The tracked set is closed, so a link that was never loaded has nothing left to resolve it, and a write has no transaction to join.
solid answer
~50 sAn after-commit callback is invoked on the far side of the boundary: the commit has returned and the layer has torn the unit down. Two things follow. First, an association that was loaded lazily is backed by a stand-in that needs the open unit to fetch its data — once the unit is gone, most layers either fail outright or fall back to an unmanaged read, so the callback must capture the values it needs while the unit is still open. Second, a save issued through that same unit is no longer covered by the commit that just happened: depending on the layer it either errors or silently starts a separate transaction, which then has its own independent fate. The practical rule is that a callback should read only from data it already holds and, if it must write, do so in a new, explicitly opened unit of work.
go deeper
Remember that the callback runs after the unit of work is closed. Read the values you need before the commit and pass them into the callback rather than reaching back through objects.
Explain the mechanics: a lazily loaded association is a stand-in that needs its unit, and a save through a finished unit joins no transaction. Name both the loud and the silent failure.
The judgment to show is spotting the silent variant — a second transaction that looks like part of the first. Say how you would detect it and where you would move genuinely atomic work instead.
Set the boundary as policy: callbacks read captured values and call outward, never write. Anything that must land with the change is designed as data inside the unit, with delivery handled separately.
## Where the callback stands A unit of work has a lifetime: it opens, it accumulates changes in a **tracked set**, it flushes them as SQL, it commits, and then it closes. An **after-commit callback** is deliberately scheduled after the commit returns — which means it is also, in most layers, after the unit that produced the change has been closed and its resources released. That single fact generates all the rules the callback lives under. It is worth separating three things that novices merge: - the **transaction**, which is the database's boundary and ended at commit; - the **connection**, which by then has typically been handed back to the pool; - the **tracked set**, the layer's in-memory bookkeeping, which is cleared when the unit closes. The callback is running with all three gone. ## Why a deferred link cannot be resolved When an association is loaded lazily, the layer hands back a **stand-in** — an object that looks like the real thing but holds only the key and a reference to the unit that created it. Touching it triggers a fetch through that unit. Once the unit is closed, the stand-in has no route to the database. Layers differ in what happens next: some raise a specific error, some quietly open an unmanaged read, some return an object that behaves as if it were empty. **All three outcomes are bad in a callback**, because two of them are silent. So the discipline is the same everywhere: whatever the effect needs — the identifier, the customer's address, the total, the list of changed fields — must be pulled into plain values, or into a small transfer object, **while the unit is still open**, and captured by the registration. This is also why building the payload for a notification is not the same task as sending it. Build early, inside; send late, outside. ## Why a write through the finished unit is a trap The instinct "I have a reference to the unit, so I will just save one more thing" fails for a reason that is easy to state and easy to forget: **the commit that this callback is celebrating has already happened**. Nothing can be added to it. What actually occurs depends on the layer: | Attempt inside the callback | Typical outcome | Why it is a problem | |---|---|---| | Save through the closed unit | An error about a finished or closed unit | Fails loudly, but often only in production traffic | | Save through a unit the layer silently reopens | A second, independent transaction | Its failure leaves the two writes out of step | | Read through a closed stand-in | Error, or an unmanaged read | Silent variants hand wrong data to the effect | The middle row is the dangerous one, because it looks like it worked. You now have two transactions where the code reads as one, and any crash between them leaves the database in a state neither transaction intended. ## What a callback may safely do - Use values captured before the commit. - Call an external system, accepting that the call may be lost if the process dies first. - Publish an in-process notification for other components. - Open a **new** unit of work explicitly, understanding that it commits separately and can fail on its own. - Enqueue the work for something else to perform later. And the two rules it lives under, stated as one sentence each: **it may not read what it did not already load**, and **it may not write anything that needed to be atomic with the change it is reacting to**. ## If the write genuinely must be atomic Then it was never callback work. A change that must land with the original change belongs **inside** the same unit of work, before the commit — including the case where the "write" is the record of an effect to perform. Writing that record as an ordinary row in the same unit makes the state change and the intent commit or roll back together; performing it is then a separate, retryable step outside the boundary. The callback's role shrinks to what it is good at: nudging that separate step to run promptly. ## The compression An after-commit callback is a **reader of captured values and a caller of outside systems**, not an extension of the transaction. Every bug in this area comes from treating it as an extension: loading what should have been captured, or writing what should have been committed.
- How should the callback get the data it needs to build its payload?By capturing it while the unit is still open. Read the fields, or assemble a small transfer object, at registration time and close over those plain values. That keeps the callback free of the tracked set entirely, and has the side benefit of snapshotting exactly what was committed rather than whatever the objects look like later.
- Is opening a brand-new unit of work inside the callback ever acceptable?Yes, when the write is genuinely allowed to have a separate fate — recording that the notification was sent, for example. Do it explicitly rather than relying on the layer to reopen something for you, keep it short, and design for the case where it fails after the first transaction already committed.
saying these in an interview costs you the question
- Loads an association inside the callback and assumes it still resolves
- Saves through the same unit expecting it to join the commit
- Thinks the layer reopens the closed unit transparently
- Believes a silent unmanaged read is a harmless fallback
- Puts work that must be atomic with the change into a callback