When a data-access layer must notify another system about a change, how do publishing before the commit and publishing from an after-commit callback fail differently?
answer
- false positive versus false negative
- the broker is outside the boundary
- the change is invisible until commit returns
- make the notification data, not an effect
- at-least-once is the price of never losing
basics
~20 sPublishing before the commit risks a false positive: the notification survives a rollback and announces a change that never landed. Publishing afterwards risks a false negative: the change is durable but the notification can vanish.
solid answer
~50 sThe two orderings trade one failure for the other. Send before the commit and the message escapes a transaction that can still fail, so receivers act on a change that was rolled back — and even on the happy path a receiver that reads the source record back may not see it yet, because the change is invisible until the commit returns. Send from an after-commit callback and the change is certainly durable, but the send sits outside every guarantee: a crash, an unavailable receiver or a failing callback loses it silently. Neither ordering is atomic, because the receiving system is not part of the transaction the layer controls. The way out from this side of the boundary is to write the notification as a row in the same unit of work, so the change and the intent to announce it commit together.
go deeper
Learn the two directions of failure: announce too early and you may announce something false; announce after the commit and the announcement can go missing. Sending inside the transaction is not the safer option.
Explain why the transaction cannot cover the other system, and describe the visibility race a pre-commit send creates for a receiver that reads back the record.
Pick a placement and defend it against the failure it inherits, then show the escape: write the notification as a row in the same unit so change and intent are atomic. Say how you would detect silent loss.
Own the consequence of that choice across the system: at-least-once delivery imposes a repeat-tolerance requirement on every receiver, and reconciliation is what makes silent loss visible rather than eventually reported by a customer.
## Two systems, one boundary that only covers one of them A unit of work commits **database** work. The transaction it controls is the engine's, over one connection: the layer can roll back rows, and it can roll back nothing else. A notification handed to another system has left the building. There is no ordering of the two operations that makes them one, and understanding this is the whole question — everything else is picking which failure to inherit. ## Publishing before the commit: the false positive The send happens while the transaction is still open and undecided. - If the commit then fails — a constraint deferred to the end, a version check that finds nothing to update, a dropped connection, an exception further up the stack — the message is already gone. Receivers have recorded a change the database never adopted, and no retraction reaches them, because the sender does not know who acted on it or how. - Even on the happy path there is a **visibility race**. The change is not readable by anyone else until the commit returns. A receiver that reacts by reading the source record can arrive first and see the old state, or nothing at all. Then it either fails, or — worse — succeeds with stale data and writes a decision based on it. The consequence to state clearly: this ordering is not "usually fine and occasionally wrong". It is wrong in a way whose blast radius grows with the number of receivers, because each one has independently absorbed a fact that has to be undone by hand. ## Publishing after the commit: the false negative Moving the send into an after-commit callback fixes the direction: nothing is announced unless it really happened. What it buys is honesty; what it costs is delivery. - The process can die between the commit returning and the send. The data is durable; the notification never existed. - The receiving system can be unavailable. Retrying inside the callback holds a thread and can still exhaust its attempts; giving up loses the message. - The callback's own failure changes nothing about the data, so there is no automatic path back to a consistent state. - The loss is **silent by default** — the state is correct, so nothing downstream detects the absence except the receiver's own suspicion that it has not heard from you. | | Publish before commit | Publish from after-commit callback | |---|---|---| | Failure produced | Announced a change that never landed | Landed a change nobody was told about | | Detectability | Receivers act, then diverge | Silent absence | | Repairable by retry | No — the send already happened | Yes, if the intent was recorded somewhere | | Race on reading the source | Yes, the change is not yet visible | No, the change is committed | ## Why neither can be made atomic here The layer's boundary spans its own transactional work. Enrolling an outside system into that boundary is not something the mapper can do on its own: the other system has its own commit, its own failure modes, and no obligation to obey a decision made elsewhere. Coordinating two independent commits is a separate discipline with its own costs, and it is not what an after-commit callback is. So the honest framing for a candidate is: **choose the failure you can absorb**, and then remove it if the cost justifies it. ## The move that changes the trade The notification does not have to be an *effect*. It can be **data**. Writing the message as an ordinary row in the same unit of work as the state change makes the two atomic in the only place atomicity is available — inside one database transaction. Either both are there after the commit or neither is. Delivery then becomes a separate step, performed after the data is durable and free to retry because the intent is safely recorded; an after-commit callback is a fine way to nudge that step to run immediately instead of waiting. That shifts the guarantee from "the notification happened" to "the notification will happen", which is usually what the business actually needed. It also changes the receiver's contract: a step that may retry can deliver the same thing more than once, so receivers must be able to absorb a repeat. That is the price of never losing one. ## The compression Before the commit you can lie; after the commit you can forget. Only data written inside the transaction can be neither.
- Even when the commit succeeds, why can a receiver that reads back the source record still get the wrong answer?Because a message sent before the commit escapes ahead of the change's visibility. The receiver can read the row while the transaction is still open and see the previous state, or no row at all. Publishing after the commit removes that race entirely, since anything a receiver reads afterwards already includes the change.
- If the notification is written as a row in the same unit of work, what has actually been guaranteed?That the change and the intent to notify share one fate: both are there after the commit, or neither is. Delivery is not guaranteed by that write — something still has to send it, and may send it more than once. What disappears is the case where the data changed and the intent was never recorded at all.
- How would you detect that a system is silently losing after-commit notifications?By reconciling the two sides rather than trusting the send. Count committed changes against notifications the receiver acknowledges over a window and alert on the gap, and record locally that an effect was performed so an unperformed one is visible as data. A loss that shows up only as a receiver's complaint is a loss you found far too late.
saying these in an interview costs you the question
- Sends the message inside the transaction because it feels more atomic
- Assumes a receiver can immediately read the change it was told about
- Thinks an after-commit send cannot be lost
- Believes the layer can enlist a message system in its transaction
- Retries the send inside the callback and calls the problem solved
- Treats a lost notification as harmless because the data is correct