Find the Transaction Owner
Observe rollback and commit with independent queries, then distinguish database participation from external side effects.
A flow inserts A-1001, calls the warehouse, and then rejects the order because a final validation fails. The error handler returns a tidy response, but the database row remains and the warehouse has already reserved stock — putting all three processors inside one Try scope did not give them a shared rollback mechanism.
A transaction has a resource boundary and an owner. The resource decides which changes can be undone together; the owner decides whether that unit finishes successfully. A response saying rolled back is easy to produce, so the question worth asking is whether a second request can still find the inserted row.
Checkpoint 12 keeps the database setup and selection endpoints from chapter 11. It adds two controlled failures with different handler placement. Run /lab/setup before the experiment so an older sentinel row cannot change what the insert tests.

Open this checkpoint in ACB
Stop the previous local application, then use File → Open Folder to open book/checkpoints/12-transactions. Open src/main/mule/app.xml and use Flow List to select the named flow for each example. Choose Run and Debug → Run Mule Application and wait for deployment. Save canvas edits and use Save and Hot-deploy to Local Runtime before repeating a request.
Run python3 book/run.py verify 12 from the companion root to exercise this running checkpoint with its synthetic fixtures. The verifier supplies requests and checks results; it does not start the ACB application. Keep the editor on this checkpoint while reading a failure so an old deployment cannot supply a misleading answer.
Begin a local transaction explicitly
Example 031 — Roll back an inserted order
The complete source is in checkpoints/12-transactions/src/main/mule/app.xml.
Choose Flow List → rollback-order and select Try. In General, keep Transaction Type LOCAL. In Advanced, choose Transactional Action ALWAYS_BEGIN.
Expand Try. Select Insert: Connection Config is Orders_DB, Transactional Action is ALWAYS_JOIN, and SQL is:
INSERT INTO orders(order_id,customer,total)
VALUES ('ROLLBACK','Dana',32)
The next Raise Error has Type APP:REJECTED. In Try → Error handler, inspect On Error Propagate, Type APP:REJECTED. Then inspect the separate flow-level Error handler: its On Error Continue matches the same type and contains Set Payload with text rolled back. These two handlers belong to different owners; keep both expanded while tracing the result.
Beginning the transaction and joining it are separate settings, and the HTTP Listener does not begin a database transaction at all — which is why an HTTP-driven flow needs a Try to establish the unit explicitly. Here that Try uses Transactional Action ALWAYS_BEGIN and Transaction Type LOCAL to own one. The Database Insert uses ALWAYS_JOIN, which requires it to participate in an active transaction rather than quietly running outside one.
The insert writes the recognizable identity ROLLBACK, then Raise Error deliberately fails the work. The handler on the owning Try propagates that error. The outer flow’s Continue handler supplies the response text after the transactional scope has failed.
Send the two requests separately:
curl -X POST http://127.0.0.1:18881/lab/rollback
curl http://127.0.0.1:18881/lab/orders/ROLLBACK
rolled back
[]
The first body describes the handler’s response. The second body is the evidence about the row. So “Continue commits” is an incomplete rule — here the flow’s On Error Continue ran and the inserted row still rolled back. The Try began the transaction, so its Propagate handler is what decided the outcome; by the time the flow-level handler chose a response there was no transaction left for it to commit. The response and the row were settled by two different components.

Try Advanced tab with ALWAYS_BEGIN and its nested propagating handler.
Move the handler to the owner
Example 032 — Complete the owning Try
The complete source is in checkpoints/12-transactions/src/main/mule/app.xml.
Choose Flow List → continue-in-owner. Its Listener path is /lab/continue. The Try still uses ALWAYS_BEGIN and LOCAL, and its Database Insert still uses Orders_DB with ALWAYS_JOIN.
The SQL writes a different sentinel:
INSERT INTO orders(order_id,customer,total)
VALUES ('CONTINUE-INNER','Dana',32)
After Raise Error, inspect Try → Error handler. This time On Error Continue belongs to the Try itself, matches APP:REJECTED, and sets the payload to handled inside. Compare its location with the flow-level Continue in rollback-order, not merely its display name.
This variant uses the new identity CONTINUE-INNER, avoiding a collision with the first experiment. That detail matters more than it looks: a leftover ROLLBACK row would fail the insert on its primary key before the injected error ever ran, and the experiment would quietly test a different path. The Continue handler now belongs to the Try that began the transaction. After POST /lab/continue, query /lab/orders/CONTINUE-INNER independently.
The intended comparison is between a failed transactional owner and a successfully completed one. The query determines the actual row outcome; a changed response string alone cannot establish it. The companion verification checks both the response and the row for each variant.
As in the earlier Try lesson, Continue does not execute the remaining normal processors inside the failed Try. It finishes that owner. The fact that a handler completed successfully is meaningful only when we also know which scope owns the transaction.
One resource configuration defines this unit
Both experiments use Orders_DB. A second configuration pointing at the same JDBC URL should not be casually treated as the same participating resource: the selected Connection Config is part of the design — it names the resource configuration expected to participate. Local transactions also do not provide nested independent transactions merely because a Try sits inside another Try.
| Setting | Meaning in this design |
|---|---|
Try ALWAYS_BEGIN | Begin a new transaction owned by this boundary |
Try BEGIN_OR_JOIN | Join a suitable existing transaction or begin one |
Database ALWAYS_JOIN | Require an active transaction to join |
Database JOIN_IF_POSSIBLE | Participate when a transaction is available |
Database NOT_SUPPORTED | Perform the operation outside the active transaction |
When you extract persistence into a reusable flow, its participation becomes part of its contract. Document it, because the caller has to know whether it must supply a transaction or whether the called unit owns one.
Keep external work outside the rollback claim
An HTTP reservation does not become reversible because its Request operation appears inside this Try. The remote service owns its state, and waiting for that call inside a database transaction only holds a connection and its locks for longer — it buys no rollback power over the remote system.
Prepare data before beginning the short database unit where possible. If the application later needs to publish an accepted-order event, chapter 19 will store publication intent in the same database transaction. The actual delivery then happens after commit.
A record intended to survive the order transaction’s rollback needs a separate, intentional boundary — a mandatory failure audit cannot live only inside the transaction whose failure it must record. That independent write has its own failure policy; it should not be hidden behind an assumption that a logger is durable business storage.
Atomicity also does not settle concurrency by itself. An all-or-nothing update can still use a value read before the transaction began, which is why uniqueness constraints, conditional updates, isolation levels and explicit locks each address a particular race. Concurrent use of one request key is tested in the idempotency lesson rather than inferred from this single-request rollback.
The independent read of /lab/orders/CONTINUE-INNER returned the committed sentinel row with customer Dana and total 32. The corresponding read of /lab/orders/ROLLBACK returned an empty array. These are separate executed outcomes for the two handler locations.
Try it
1. Separate response from state. Why does a normal response from Roll back an inserted order coexist with an absent row?
Show answer
The transactional Try first propagates the failure and rolls back. The outer flow then handles that propagated error and chooses a normal response. Response selection occurs after the inner owner has decided the transaction outcome.
2. Preserve a rejection audit. Where must an audit row be written if it must remain after the order transaction rolls back?
Show answer
It needs an independent persistence boundary outside the rolled-back unit. Decide what happens if that audit write fails, especially if audit completion is mandatory. Reusing the same transactional insert without changing participation does not make the row survive.
3. Add a warehouse call mentally. If the warehouse reserves stock and a later local insert fails, what evidence can this H2 test provide?
Show answer
It can establish the outcome of participating H2 writes on the tested stack. It cannot undo or establish the warehouse’s reservation outcome. Recovery needs the warehouse’s own idempotency, cancellation or reconciliation contract.
A transaction gives us a precise local boundary. We can now assemble the public operation around that boundary and describe its input with a formal API contract.
Comments