Build1 publisher3 min readPublished
A rolled-back payment transaction still leaves the WhatsApp notification sent
A Go lab injects failures into a three-write payment flow and prints the row counts that result. Inside one transaction the database restores every row it wrote; the notification already sent through WhatsApp stays sent.
The Engineer · Build desk
What happened
- The safe variant wraps the same writes in BeginTx with a deferred rollback, and the same injected failure leaves payments at 0, the order back at pending and wallet_transactions at 0.
- An outbox example commits an invoice update to paid and a pending outbox_events row inside one local transaction.
- A distributed variant sends a WhatsApp notification inside the transaction and then hits a simulated ERP error, and the rollback clears both database rows while the WhatsApp sent count stays at 1.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint The boundary covers exactly the writes one BeginTx can reach, so a notification or ERP call placed inside it has to be undone by hand or moved after commit.
- decision Each side effect now needs a placement decision: keep the call inline and accept it firing on a flow that fails, or spend a table and a dispatcher to move it behind a committed row.
- exposure A caller that received an error has no way to learn the order is already paid, so the mismatch surfaces to whoever answers the support ticket.
PaymentServiceSafe puts its correctness in a boolean. The service sets `committed := false` before the writes and defers a closure that calls `tx.Rollback()` whenever that flag is still false [8]. Rollback is the default path. Commit is the exception, and the only thing that flips the flag is `tx.Commit()` returning without error [9]. Any return path added to that function later still rolls back.
The unsafe version runs the same three statements through `db.ExecContext`, with no shared boundary around them [2]. The injected failure arrives after the payment insert and the order update, before the wallet transaction insert [3]. The test then prints payment persisted = 1, order.status = paid, wallet_transactions = 0 [4]. Two of the three writes survived a call that returned an error [16]. The dev.to post's diagnosis is that the service "treated a multi-step business operation as separate database statements" [15].
Wrapping the writes in `BeginTx` fixes the local case and then finds its own edge. DistributedOrderService opens a transaction, inserts a payment, updates an invoice, sends a WhatsApp notification, and then hits a simulated ERP integration error, so the transaction rolls back [12]. The rows go back: payments = 0, paid invoices = 0. WhatsApp sent count = 1 [13]. According to the post, "The database can roll back its own rows. It cannot recall a message that has already been sent through another system." [14] The write-up applies the same reasoning to email and SMS [17].
For the safe result to transfer to a real service, every write in the invariant has to be reachable from one `BeginTx`. The post's framing is that anything inside the boundary commits or rolls back together, and anything outside it "cannot be magically undone by the database" [19]. Move wallet_transactions to a second database and the guarantee shrinks to the tables that stayed behind. The failure here is injected in application code, so it exercises the deferred rollback and not a process killed between statements. `BeginTx` is called with nil options and the post does not name an isolation level [18]; for three writes in one flow that probably does not change the counts.
The outbox example uses the same boundary to store intent. InvoiceServiceOutbox updates invoices to paid and inserts an outbox_events row with status 'pending' in one local transaction, and both commit together [11]. After commit, the send is a separate problem with a durable record behind it. The post does not include the process that reads those rows and dispatches them. In my view that missing piece decides whether the pattern is worth its cost: a relay that sends and then marks the row sent will sometimes send twice, so the receiving system has to tolerate a duplicate.
What to watch
- Whether a later lab in the series adds a compensating action for the WhatsApp message that was already sent.
- Whether the safe path is retested with a killed process instead of an error injected in application code.
- Whether the series measures what it costs to hold a database transaction open across the notification call.