Skip to content
Forward Engineering
Go back

[JPA + Spring Mastery 08] Transaction Split Patterns — Saga / Outbox / REQUIRES_NEW, from academic origins to a 9-scenario EXP-09b measurement

- views

Table of contents

Open Table of contents

Preface

The prior measurement (EXP-09) showed the pitfall of calling external APIs inside a transaction, line by line. With HikariCP pool=10, extDelay=3,000ms, concurrent=60, and connection-timeout=1s, only 16.7% of requests passed; 50 calls hit SQLTimeoutException. The pool-occupation time stretched in lockstep with the external call.

The cure is obvious: move the external call outside the transaction. The how is the hard part. StackOverflow answers scatter across plain split (call externally first, then INSERT), Saga’s compensating transactions, Transactional Outbox, and more. Few articles answer which pattern fits which domain with measurements.

So I ran a 9-scenario matrix — three patterns (A: plain split / B: Saga / C: Outbox) × three chaos modes (OFF / DB_FAIL / EXT_FAIL). The result: the three patterns carry fundamentally different trade-offs. Plain split lets external success diverge from local DB failure — 60 calls to the external PG, 0 rows in our DB. Saga’s triple safety net (worker compensation + sweeper) keeps consistency. Outbox shortens user-visible response by 41×, at the cost of making completion 30× slower.

These patterns are not new. Saga lives in Garcia-Molina’s 1987 ACM SIGMOD paper. Outbox lives in Pat Helland’s 2005 CIDR paper Data on the Outside vs Data on the Inside. Korean tech blogs (Toss SLASH24, 29CM, Ridi) have written operational reflections on these. Stitching academic → operational → my own measurement together makes it line-by-line clear why each pattern fits its domain.

This article is that three-layer record:

  1. The incident — EXP-09’s pool exhaustion; plain split alone is not enough
  2. PROPAGATION 7 — REQUIRED / REQUIRES_NEW / NESTED / SUPPORTS / MANDATORY / NOT_SUPPORTED / NEVER, with their precise meanings
  3. The limits of 2PC (XA) — why microservices can’t use it; Helland’s Life Beyond Distributed Transactions
  4. Saga — Garcia-Molina 1987 — Choreography vs Orchestration / compensating transactions
  5. Outbox — Helland CIDR 2005 — academic origin + Korean operational reports
  6. EXP-09b 9-scenario measurement matrix — A/B/C × OFF/DB_FAIL/EXT_FAIL
  7. Two latencies, separateduser response vs processing completion (different metrics)
  8. Domain mapping — payments → Saga, notifications → Outbox, cache only → plain split
  9. @TransactionalEventListenerAFTER_COMMIT / BEFORE_COMMIT precise semantics + Spring source

The headline:

The maxim “just split the transaction” is half an answer. Three layers — academic, operational, measured — get the rest of it.


1. The incident — EXP-09’s pool exhaustion, plain split as a starting point

1.1 EXP-09’s two runs

The prior measurement (EXP-09) examined the pool-exhaustion mechanism in two runs.

Run #1 — silent latency blowup (timeout 5,000ms / concurrent 30 / extDelay 2,000ms):

Run #2 — fail-fast (timeout 1,000ms / concurrent 60 / extDelay 3,000ms):

Same exhaustion; the connection-timeout decides the system character. Operations sees opposite signals — Run #1 looks slow, Run #2 looks broken.

1.2 The intuitive fix — pull external calls out of the transaction

// Before — EXP-09's anti-pattern
@Transactional
public void process(OrderRequest req) {
    Order order = repo.save(req);          // INSERT
    paymentClient.charge(order.getId());   // external PG (3s)
    // pool occupied = INSERT + external + commit ≈ 3,005ms
}

// After — plain split (pattern A)
public void process(OrderRequest req) {
    paymentClient.charge(req.getId());     // external first (outside Tx)
    saveOrder(req);                         // new transaction
}

@Transactional
public void saveOrder(OrderRequest req) {
    repo.save(req);                         // INSERT only
    // pool occupied ≈ 5ms
}

Is pattern A the answer? The matrix in EXP-09b says: it depends.

1.3 EXP-09b — the 9-scenario matrix’s key finding

#PatternchaosOKInconsist.Compen.ExtFailMeaning
1AOFF60000normal path
2ADB_FAIL0600060 charged externally / 0 saved locally ⚠️
3AEXT_FAIL00060external failed — no losses
4BOFF60000Saga normal
5BDB_FAIL00060 (sweeper)sweeper cleans 60 ✅
6BEXT_FAIL00600worker compensates 60 ✅
7COFF60 (ACK)000Outbox normal
8CDB_FAIL60 (ACK)000Outbox auto-retry
9CEXT_FAIL60 (ACK)000Outbox auto-retry

The key finding: scenario 2 — A/DB_FAIL — produces 60 external charges with 0 local rows. Plain split alone breaks consistency on the external OK / local DB failure failure mode. Wrong fit for payments / orders.

The remainder of this article unpacks why — academically and in measurements. First, the precise meanings of PROPAGATION’s seven values.


2. PROPAGATION 7 — Spring’s transaction-propagation semantics

The seven values look enum-like, but each is a different transactional model. Spring 6’s AbstractPlatformTransactionManager handles them as a 7-way branch in handleExistingTransaction.

2.1 The semantics, precise

PropagationWith existing TxWithout existing TxBehavior
REQUIRED (default)joinstart newthe most common choice
REQUIRES_NEWsuspend + start newstart newseparate connection
NESTEDcreate savepointstart newJDBC 3.0 savepoint
SUPPORTSjoinrun without Txoptional
MANDATORYjoinerrorenforced
NOT_SUPPORTEDsuspend + run without Txrun without Txavoid Tx
NEVERerrorrun without Txforbid Tx

2.2 REQUIRED vs REQUIRES_NEW — the decisive difference

@Service
public class OrderService {
    @Transactional  // PROPAGATION_REQUIRED
    public void process() {
        repo.save(order);
        notificationService.send(order);  // joins same Tx
        // notificationService throwing? — this Tx rolls back too
    }
}

@Service
public class NotificationService {
    @Transactional(propagation = REQUIRES_NEW)  // separate Tx
    public void send(Order order) {
        // separate connection / commit / rollback
        // exceptions here don't affect OrderService Tx
    }
}

REQUIRED’s inner throwing creates the Woowahan why is this rolling back? incident pattern. REQUIRES_NEW keeps the failure isolated. (See series article 7, §8 for the self-invocation combination.)

2.3 NESTED’s real meaning — Savepoints

NESTED is not a new transaction. It is a JDBC 3.0 Savepoint — same connection, same transaction, with a partial-rollback marker.

-- NESTED's SQL sequence
BEGIN;
INSERT INTO orders ...;        -- outer's work
SAVEPOINT nested_1;             -- enter inner
INSERT INTO order_items ...;   -- inner's work
ROLLBACK TO SAVEPOINT nested_1; -- roll back inner only
INSERT INTO orders ...;         -- outer continues
COMMIT;                          -- outer commits (orders persisted)

The point: NESTED commits as a whole at the outer commit point. Inner does not commit independently. It is the wrong fit for payments — you cannot commit at the external-call boundary.

2.4 Operational traps

TrapMechanismWorkaround
REQUIRED rollback-onlyinner throws → status flagged → outer commit hits UnexpectedRollbackExceptionswitch inner to REQUIRES_NEW
REQUIRES_NEW self-invocationsame-class call → proxy bypassed → new Tx never startsextract a separate bean (article 7)
NESTED misunderstandingmistaken for separate Tx → expect external-system commitswitch to REQUIRES_NEW
SUPPORTSNOT_SUPPORTEDreverse behavior — SUPPORTS joins existing, NOT_SUPPORTED suspendsone-line guide: use existing Tx if present? → SUPPORTS / don’t? → NOT_SUPPORTED

These seven are Spring’s abstraction. In the database it boils down to connection / transaction / savepoint combinations. §3 shows how that abstraction breaks in distributed environments.


3. The limits of 2PC (XA) — why microservices can’t use it

3.1 How 2PC works

Java EE’s JTA standardized 2-Phase Commit (2PC) as the way to coordinate distributed transactions:

sequenceDiagram
    participant TC as Transaction Coordinator
    participant DB1 as DB-1
    participant DB2 as DB-2
    participant External as External System

    Note over TC,External: Phase 1: Prepare
    TC->>DB1: prepare()
    DB1-->>TC: ready
    TC->>DB2: prepare()
    DB2-->>TC: ready
    TC->>External: prepare()
    External-->>TC: ready

    Note over TC,External: Phase 2: Commit (only if all ready)
    TC->>DB1: commit()
    TC->>DB2: commit()
    TC->>External: commit()

Phase 1 — Prepare: the coordinator asks all participants whether they can commit. Once all reply “ready”, no one can abort.

Phase 2 — Commit: all commit. Or if any participant fails, all roll back.

3.2 Pat Helland’s critique — Life Beyond Distributed Transactions (CIDR 2007)

Pat Helland’s CIDR 2007 paper makes the case that 2PC does not scale to large systems.

“In production systems, distributed transactions don’t seem to be needed because applications are designed without them. In the absence of distributed transactions, application designers focus on lower forms of consistency.” — Pat Helland, Life Beyond Distributed Transactions, CIDR 2007

The thrust:

  1. Cost of blocking — between Prepare and Commit/Abort, every participant is blocked. If the coordinator dies, everyone hangs indefinitely. Under availability or partition stress, the system halts.
  2. Scalability ceiling — N participants → N² messages, N round trips. 100 microservices means 100×100 = 10,000 messages and 100× round trips.
  3. Operational cost — the coordinator’s persistent recovery log is a single point of failure with operational cost beyond ordinary RDB transaction logs.
  4. The P in CAP — under partition, 2PC sacrifices availability. Microservices treat partitions as ordinary; full rollback per partition isn’t viable.

3.3 Last Resource Gambit — XA 1.3’s hack

The JTA 1.3 spec defines a Last Resource Gambit — treat one resource as non-XA to reduce overhead. It carries a critical-bug risk — coordinator crash leaves the Last Resource’s commit/rollback in an unknown state.

In production this pattern often loses money. It’s the reason Helland recommends “lower forms of consistency”.

3.4 The conclusion — eventually consistent over 2PC

Werner Vogels’ ACM Queue 2008 article names the standard:

“Several inconsistency models exist: causal consistency, read-your-writes consistency, session consistency, monotonic read consistency, monotonic write consistency. Eventual consistency is the most relaxed of these.”

For the microservices era, transactions are eventually consistent. Saga (Garcia-Molina 1987) and Outbox (Helland 2005) sit on top of that model.


4. Saga — Garcia-Molina 1987 origin

4.1 Academic origin

Saga’s origin is Hector Garcia-Molina, Kenneth Salem — Sagas (ACM SIGMOD 1987).

“A saga is a long lived transaction that can be written as a sequence of transactions that can be interleaved with other transactions. Each transaction in the sequence is associated with a compensating transaction that semantically undoes its effects.” — Garcia-Molina, Salem, Sagas, ACM SIGMOD 1987

The definition:

  1. A saga is a sequence of transactions — T1 → T2 → … → Tn
  2. Each Ti has a compensating transaction Ci — semantically undoing Ti
  3. Compensation is not commutative — Ci doesn’t fully erase Ti’s effects (e.g., a sent notification can’t be unsent)

4.2 Two flavors — Choreography vs Orchestration

Microsoft’s Saga pattern docs summarize the two variants.

(1) Choreography — each service listens for events and emits the next event after its step:

[Order Service] -- OrderCreated --> [Payment Service]
                                      ↓ (charge OK)
                              -- PaymentCharged --> [Inventory]
                                                     ↓ (reserve OK)
                                              -- InventoryReserved --> [Shipping]

(2) Orchestration — a Saga Coordinator commands every step and consumes responses:

[Saga Coordinator] -- Charge --> [Payment Service]
                  <-- Charged ----
                  -- Reserve --> [Inventory]
                  <-- Reserved ---
                  -- Ship --> [Shipping]
AxisChoreographyOrchestration
Couplingloose (event-based)tight (Coordinator knows all)
Debuggingharder (events fan out)easier (Coordinator is the single point)
SPOFnonethe Coordinator
Fitsevent-based domains (DDD)command-based (RPC-style)

4.3 Toss SLASH24 — Saga distributed-transaction compensation

Toss SLASH24 is the Korean operational counterpart:

A telling quote from the talk:

“Compensating transactions can themselves fail. Manual operations have to be designed in. Assuming Saga handles every failure automatically is dangerous.”

4.4 EXP-09b pattern B — three safety nets

Pattern B is a minimal Saga implementation with three safety nets.

// 3 transactions — Tx1 reserve / Tx2 confirm / Tx3 cancel
@Transactional
public OrderId reserve(OrderRequest req) {  // Tx1
    Order order = new Order(req, Status.PENDING);
    return repo.save(order).getId();
}

public void process(OrderId orderId, OrderRequest req) {
    try {
        paymentClient.charge(req);  // external (outside Tx)
        confirm(orderId);            // Tx2
    } catch (Exception e) {
        cancel(orderId);             // Tx3 (compensating)
        throw e;
    }
}

@Transactional
public void confirm(OrderId orderId) {  // Tx2
    Order o = repo.findById(orderId);
    o.setStatus(Status.CONFIRMED);
}

@Transactional
public void cancel(OrderId orderId) {  // Tx3 — compensating
    Order o = repo.findById(orderId);
    o.setStatus(Status.CANCELLED);
}

SagaSweeper adds the time-based safety net — auto-cancel any PENDING that didn’t get confirmed/compensated:

@Scheduled(fixedDelay = 5000)
public void sweep() {
    repo.findPendingOlderThan(Duration.ofSeconds(5))
        .forEach(o -> cancel(o.getId()));
}

4.5 Measured — pattern B across the 9 scenarios

ScenarioResultSafety net
B/OFF60 ✅normal path
B/DB_FAILsweeper recovers 60 → CANCELLEDsweeper recovers what worker compensation couldn’t
B/EXT_FAILworker compensates 60 → CANCELLEDworker compensation fires immediately

Triple safety net: (1) try/catch worker compensation / (2) time-based sweeper recovery / (3) audit trail (CANCELLED rows). The two operational safety nets fire in turn; looking at one scenario in isolation hides the value.

P99 = 3,106ms — external call + Tx2 commit. Close to EXP-09 (3,302ms), but pool occupation is now 5ms × 2 (Tx1 + Tx2). Pool exhaustion gone.


5. Outbox — Helland CIDR 2005 origin

5.1 Origin — Data on the Outside vs Data on the Inside

The Outbox pattern’s academic origin is Pat Helland — Data on the Outside vs Data on the Inside (CIDR 2005).

“Data on the inside is the data that is private to a service… Data on the outside is the data that flows between services.” “We need a mechanism to publish outside data atomically with inside data changes.” — Pat Helland, Data on the Outside vs Data on the Inside, CIDR 2005

The insights:

  1. Inside the transaction = inside data (your own DB)
  2. Message queues / API calls = outside data (other systems)
  3. We need a mechanism to publish outside data atomically with inside changes
  4. Outbox — write the outside message to an inside outbox table in the same transaction → atomic → a separate poller / CDC publishes it

5.2 Mechanism — Polling vs CDC

graph LR
    Tx[Transaction]
    Tx --> InsideOrder[INSERT order]
    Tx --> InsideOutbox[INSERT outbox]
    Tx --> Commit[atomic commit]
    Commit --> Poller[Outbox Poller<br/>100ms~5s]
    Poller --> External[external system]
    Commit --> CDC[Debezium CDC<br/>ms]
    CDC --> External2[external system]
VariantLatencyOperational cost
Polling100ms~5ssimple (Spring @Scheduled)
CDC (Debezium)<100msbinlog access + Kafka Connect operations

5.3 Korean operational reports — 29CM, Ridi

29CM Transactional Outbox in production:

Ridi’s Transactional Outbox:

Both reports stay practical — no academic citation. What this article adds is the three-layer thread: academic origin (Helland CIDR 2005) → operational reports → my own measurement.

5.4 EXP-09b pattern C — measured implementation

@Transactional
public OrderId acceptAndQueue(OrderRequest req) {
    Order order = new Order(req, Status.PENDING);
    OrderId id = repo.save(order).getId();
    outboxRepo.save(new OutboxEvent(id, "CHARGE_REQUEST", req));  // same Tx
    return id;
}

@Scheduled(fixedDelay = 200)  // 200ms polling
public void pollOutbox() {
    List<OutboxEvent> batch = outboxRepo.findUnprocessed(10);  // FOR UPDATE SKIP LOCKED
    for (OutboxEvent e : batch) {
        try {
            paymentClient.charge(e);
            confirmOrder(e.getOrderId());
            outboxRepo.markProcessed(e);
        } catch (Exception ex) {
            outboxRepo.bumpRetry(e);
        }
    }
}

Design choices:

5.5 Three latencies — the finding from this measurement

(Mirrors the original measurement note, §5.4.)

C/OFF (the “happy path”) decomposes into three different metrics:

Latency typeC/OFF [measurement]Meaning
ACK latency72msworker tells the user “queued for processing”
Completion latency (avg)92,573msorders.PENDING → CONFIRMED (external + poller-cycle position)
Completion latency (max)181,935msthe last cycle’s row

The same pattern carries two latencies:

The real Outbox trade-off: separating user response from external call costs you in time-to-completion. A poor fit for payments where the user waits for completion. A great fit for notifications where ACK is enough.

Three ways to shorten completion latency:


6. EXP-09b 9-scenario matrix — the measurements that compare the patterns

6.1 Core indicators across nine scenarios

#PatternchaosOKInconsist.Compen.ExtFailsweeperP99 (ms)awaiting peak
1AOFF600003,07157 ⚠️
2ADB_FAIL060000
3AEXT_FAIL000600
4BOFF6000003,1060
5BDB_FAIL0000600
6BEXT_FAIL0060000
7COFF60 (ACK)000720
8CDB_FAIL60 (ACK)000670
9CEXT_FAIL60 (ACK)000660

6.2 Final DB state

#Patternchaosorders distributionoutbox
1AOFFA.CONFIRMED=600
2ADB_FAIL(none)0
3AEXT_FAIL(none)0
4BOFFB.CONFIRMED=600
5BDB_FAILB.CANCELLED=60 (sweeper)0
6BEXT_FAILB.CANCELLED=60 (worker compensation)0
7COFFC.CONFIRMED=59 / PENDING=11
8CDB_FAILC.CONFIRMED=9 / PENDING=5151
9CEXT_FAILC.PENDING=6060

6.3 Pattern A’s awaiting=57 spike

A/OFF’s P99 of 3,071ms beats EXP-09’s 3,302ms because pool occupation drops to 5ms × wave. But awaiting peak=57 — once the external sleep(3,000ms) returns, all 60 workers race to INSERT simultaneously → pool of 10 fills → awaiting spikes.

Implication: pool occupation per call is short (5ms × wave), but the instantaneous concurrency spike still matters. It can affect other APIs during that ~10ms window. Surfacing it requires millisecond-resolution pool metrics — Prometheus + Hikari’s metrics binder.

6.4 Pattern B’s triple safety net, validated by scenarios 5+6

Both safety nets work in turn. Looking at one scenario in isolation hides their value. Both must be measured.

6.5 Code line ↔ measurement mapping

MeasurementCode locationVerified
A/DB_FAIL Inconsistent=60PatternARunner.java chaos branch
A/EXT_FAIL ExtFail=60PlatformAStub.java sleep then throw
B/OFF P99=3,106msPatternBRunner.java reserve + external + confirm
B/DB_FAIL sweeper=60SagaSweeper.java UPDATE HOLD>5s
B/EXT_FAIL Compensated=60PatternBRunner.java catch → cancel
C ACK P99=72msPatternCRunner.java orders+outbox in one Tx
C/OFF processed=59 (180s)OutboxPoller.java serial batch
C/DB_FAIL chaosSkipped=50OutboxPoller.java deterministic orderId%2
C/EXT_FAIL retries=19OutboxPoller.java bumpRetry

All nine measurements map 1:1 to code lines.


7. Two latencies, separated — user response vs completion

7.1 What the latencies mean per pattern

PatternUser response ↔ completionSync/Async
Asame (external + INSERT on same worker thread)sync
Bsame (reserve + external + confirm on same worker thread)sync
Cdifferent (worker = ACK / poller = completion)async

7.2 User response latency

IndicatorEXP-09 #2A/OFFB/OFFC/OFF
Success rate16.7%100%100%100% (ACK)
User response P993,302ms3,071ms3,106ms72ms
Pool occupation per Tx~3,000ms~5ms~5ms × 2~10ms

7.3 Completion latency

PatternCompletion minCompletion avgCompletion max
A/OFF3,071ms (= user response)3,071ms3,071ms
B/OFF3,106ms (= user response)3,106ms3,106ms
C/OFF3,233ms92,573ms181,935ms

→ Compared on completion latency, C is on average 30× slower than A.

7.4 Domain mapping flows from these two latencies

DomainUser response priorityCompletion priorityRecommended pattern
Payment confirm⭐⭐⭐Saga (B) — completion consistency required
Notifications (SMS / Push)⭐⭐⭐Outbox (C) — ACK is enough
Cache invalidation⭐⭐⭐Plain split (A) — DB consistency tolerant
Order creation⭐⭐⭐⭐⭐Saga (B) — consistency
Search index updateOutbox (C)
Idempotent retry-state update⭐⭐⭐⭐Saga or Outbox (per domain)

7.5 Why completion latency is volatile

C’s completion latency varies by cycle position:

poller cycle 200ms × 60 rows × batch=10 ≈ 60 × 200 / 10 = 1,200ms (theory)
measured average = 92,573ms (77× theory)

The gap is the external call sleep(3,000ms) serialized inside the batch. With batch=10, one cycle takes 30,000ms (10 × 3,000) — the external call dominates the cycle.

7.6 Three paths to shrink completion latency

  1. Multi-poller (ShedLock distributed lock + N instances)
  2. Parallel-in-batch (CompletableFuture.allOf for concurrent external calls)
  3. CDC evolution (Debezium binlog-based — polling cost goes to zero)

These three paths are measured in W6’s commerce-batch-orchestrator, ShedLock + EXP-12 / EXP-12b.


8. Domain mapping — payments to Saga, notifications to Outbox

8.1 Decision tree

Q1: External call requires *synchronous* consistency?
  YES → Saga (B)
  NO  → Q2

Q2: User response can absorb the external-call duration?
  YES → Plain split (A) — but accept the external-OK / DB-fail risk
  NO  → Outbox (C)

Q3: Completion can be slower without harm?
  YES → Outbox (C) is enough
  NO  → Outbox + multi-poller / parallel / CDC

8.2 Per-domain fit

DomainRecommended patternReason
Payment confirmSaga (B)Completion consistency + compensation
RefundSaga (B)External PG idempotency + DB reconciliation
Notifications (SMS / Push / Email)Outbox (C)Fast ACK; completion can lag
Search index updateOutbox (C)Async tolerated, weak consistency
Cache invalidationPlain split (A)Stale cache acceptable; the next read fixes it
Order creationSaga (B)External inventory + local DB consistency
Domain notification (event-driven)Outbox (C)DDD domain-event canonical
Order cancel (self-decided)Plain split (A)No external call
Order cancel (with PG refund)Saga (B)External PG + local DB
Operations dashboard updatesPlain split (A)Read-only cache

8.3 Applied to commerce-comment-platform-be

Domain                     → Pattern
Credit deduction (payment) → Saga (B)
Auto-comment notification  → Outbox (C)
Operator-dashboard update  → Plain split (A)
Order refund               → Saga (B)
Search index sync          → Outbox (C)

This is the mapping ADR-BE-008 codifies — validated by the 9-scenario EXP-09b.


9. @TransactionalEventListener — Spring’s commit-after hook

9.1 Behavior

@TransactionalEventListener publishes events at the transaction’s commit point. Spring’s TransactionalEventListener defines it.

@Service
public class OrderEventPublisher {
    private final ApplicationEventPublisher publisher;

    @Transactional
    public void confirmOrder(Order order) {
        order.confirm();
        repo.save(order);
        publisher.publishEvent(new OrderConfirmedEvent(order));  // publish
        // listener fires after commit (default = AFTER_COMMIT)
    }
}

@Component
public class NotificationListener {
    @TransactionalEventListener  // default = AFTER_COMMIT
    public void onOrderConfirmed(OrderConfirmedEvent event) {
        // notify externally after commit
        notificationClient.send(event);
    }
}

9.2 Four phases

PhaseWhenUse
BEFORE_COMMITjust before commitDB validation / extra INSERTs
AFTER_COMMIT (default)just after commitexternal notification / cache invalidation
AFTER_ROLLBACKjust after rollbackcompensation / audit
AFTER_COMPLETIONafter commit OR rollbackcleanup

9.3 The Spring source — TransactionSynchronizationManager

TransactionSynchronizationManager is the engine. registerSynchronization registers the callback:

// TransactionalEventListenerFactory calls this internally
TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            // listener fires
        }
    }
);

9.4 The trap — RuntimeException inside AFTER_COMMIT

If an AFTER_COMMIT listener throws — the transaction is already committed → no rollback possible → external system inconsistency is on the table.

The remedy:

This trap is why Outbox is academically sound. External calls inside AFTER_COMMIT don’t match Helland’s outside-data publication mechanism — Outbox is the answer.


10. Conclusion — transaction split through a senior lens

10.1 Layered understanding — academic → operational → measured

LayerUnderstanding
L1 surface”Don’t call external APIs inside a transaction”
L2 mechanismPROPAGATION 7 + TransactionSynchronizationManager + @TransactionalEventListener
L2.5 sourceAbstractPlatformTransactionManager#handleExistingTransaction 7-way / TransactionalEventListenerFactory
L3 measurementEXP-09 two runs + EXP-09b 9-scenario matrix — 9 measurements mapped 1:1 to code lines
L4 ops (Korea)Toss SLASH24 SAGA / 29CM·Ridi Outbox / Woowahan why is this rolling back?
L4 ops (global)Stripe Idempotency / Microsoft Saga pattern docs
L5 academicGarcia-Molina 1987 (Saga) / Helland CIDR 2005 (Outbox) / Helland CIDR 2007 (2PC limits) / Vogels 2008 (Eventually Consistent)

10.2 Domain-mapping decisions

10.3 Operational checklist

10.4 What the next articles cover


11. References

Academic (L5)

Official documentation (primary)

Spring 6 source (cited directly)

Korean tech-blog incident reports

Global production reports

Vlad Mihalcea (Hibernate Steering Committee)

Author’s own measurements


Share this post on:

Previous Post
[JPA + Spring Mastery 01] L1 Cache · flush · Transaction Lifecycle — what readOnly really shaves off, dirty checking's true cost
Next Post
[JPA + Spring Mastery 07] Spring AOP self-invocation — the real reason @Transactional doesn't work, decomposed down to TransactionInterceptor.invoke 6 stages