Definition
- Pseudo-saga -- a pattern where a transactional method holds a database connection open while calling another service over HTTP. The connection is held for the entire network round trip.
- Connection waste -- during the 2-30 second REST call, the database connection is checked out but idle. The database has no work to do, but the pool slot is occupied.
- Double connection problem -- REQUIRES_NEW suspends the outer connection and opens a second one, so one thread pins two connections during the slow HTTP call.
- Pool starvation -- with 20 connections and 3-second partner calls, throughput is capped at roughly 7 requests per second. Beyond that, requests fail with "Connection is not available."
- Not a real Saga -- a real Saga uses local transactions per step with compensating actions. The pseudo-saga has no compensation and no atomicity across services.
- Correct refactor -- Transactional Outbox: write business data and an outbox event in one short transaction, then relay the event asynchronously via Kafka to the partner service.
At the National Bank of India, a teller holds one vault key (connection) open while calling the partner bank (ICICI) on the phone. The phone call takes 30 seconds. During those 30 seconds, the vault key is wasted -- nobody else can use it. If 20 tellers all call partner banks simultaneously, all 20 keys are tied up on phone calls, and the vault is effectively closed for new customers. The fix: the teller finishes the vault work in 10 milliseconds, puts the key back, and drops a note in an outbox. A separate courier picks up the note and makes the phone call.
Understanding the Concepts
What is the pseudo-saga anti-pattern?
A pseudo-saga tries to fake distributed transaction behavior by holding a database connection open while making a synchronous network call to another service. Inside a @Transactional method, the code debits the local account, then calls a partner bank over REST, and only commits after the call returns. The database connection is checked out from HikariCP for the entire method duration, including the slow network wait.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
@Service
public class PseudoSagaTransferService {
private final AccountRepository accountRepository;
private final RestTemplate restTemplate;
public PseudoSagaTransferService(AccountRepository accountRepository,
RestTemplate restTemplate) {
this.accountRepository = accountRepository;
this.restTemplate = restTemplate;
}
// WRONG: holds DB connection during REST call
@Transactional
public void crossBankTransfer(String fromId, BigDecimal amount,
String partnerAccountId) {
// Step 1: debit local account (10ms DB work)
var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
from.setBalance(from.getBalance().subtract(amount));
// Step 2: call partner bank (2-30 seconds network wait)
// Connection is HELD during this entire call!
restTemplate.postForEntity(
"https://api.icici.com/v1/credit",
new CreditRequest(partnerAccountId, amount),
Void.class
);
// Step 3: commit (connection finally released)
}
}
Timeline for one request:
0ms: connection checked out from HikariCP
10ms: debit SAV2001 (actual DB work done)
10ms: REST call to ICICI starts
3010ms: REST call returns (3 second network latency)
3015ms: COMMIT, connection released
Connection held: 3015ms
Actual DB work: 10ms
Connection wasted: 3005ms (99.7% of the time!)
Why does REQUIRES_NEW make it worse?
Adding REQUIRES_NEW to the pseudo-saga doubles the connection waste. The outer transaction's connection is suspended (held but idle), and the inner transaction checks out a second connection for the REST-call phase. Now one thread holds two connections for the duration of the network wait.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
@Service
public class WorseTransferService {
private final AccountRepository accountRepository;
private final PartnerCreditService partnerCreditService;
public WorseTransferService(AccountRepository accountRepository,
PartnerCreditService partnerCreditService) {
this.accountRepository = accountRepository;
this.partnerCreditService = partnerCreditService;
}
@Transactional // holds connection-1
public void transfer(String fromId, BigDecimal amount,
String partnerAccountId) {
var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
from.setBalance(from.getBalance().subtract(amount));
// REQUIRES_NEW: connection-1 SUSPENDED, connection-2 checked out
partnerCreditService.creditPartner(partnerAccountId, amount);
// Both connections held during REST call!
}
}
@Service
class PartnerCreditService {
private final RestTemplate restTemplate;
private final PartnerLogRepository logRepository;
PartnerCreditService(RestTemplate restTemplate,
PartnerLogRepository logRepository) {
this.restTemplate = restTemplate;
this.logRepository = logRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void creditPartner(String partnerAccountId, BigDecimal amount) {
// connection-2 acquired
logRepository.save(new PartnerLog(partnerAccountId, amount));
// REST call: connection-1 AND connection-2 both held!
restTemplate.postForEntity(
"https://api.icici.com/v1/credit",
new CreditRequest(partnerAccountId, amount),
Void.class
);
}
}
With REQUIRES_NEW + REST:
Thread holds: connection-1 (suspended) + connection-2 (active)
Both held during 3-second REST call
Pool consumption: 2 connections per thread
20 threads = 40 connections needed!
Pool of 20: deadlocks after 10 threads start
What is the throughput impact?
The throughput ceiling is determined by the connection pool size divided by the connection hold time. With a pool of 20 and a 3-second partner call:
Throughput calculation:
Pool size: 20 connections
Connection hold time: 3 seconds (network latency)
Max throughput: 20 / 3 = ~6.7 requests/second
With REQUIRES_NEW (2 connections per thread):
Max concurrent threads: 20 / 2 = 10
Max throughput: 10 / 3 = ~3.3 requests/second
After Outbox refactor:
Connection hold time: 10 milliseconds (DB only)
Max throughput: 20 / 0.01 = 2000 requests/second
Improvement: 2000 / 6.7 = ~300x throughput increase
Why is this not a real Saga?
A real Saga pattern has two essential properties that the pseudo-saga lacks:
- Local transactions per step -- each step has its own transaction that commits immediately. There is no held connection between steps.
- Compensating actions -- if a later step fails, the Saga runs compensating actions (reverse operations) to undo earlier steps.
The pseudo-saga has neither. It holds a connection across steps and has no compensation. If the REST call succeeds but the local commit fails (database crash), the partner bank has the money but the local debit is lost.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
// CORRECT: Saga with local TX per step + compensating action
@Service
public class SagaTransferOrchestrator {
private final AccountRepository accountRepository;
private final OutboxRepository outboxRepository;
public SagaTransferOrchestrator(AccountRepository accountRepository,
OutboxRepository outboxRepository) {
this.accountRepository = accountRepository;
this.outboxRepository = outboxRepository;
}
// Step 1: local debit + outbox event (one short TX)
@Transactional
public void initiateTransfer(String fromId, BigDecimal amount,
String partnerAccountId) {
var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
if (from.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException(fromId, amount);
}
from.setBalance(from.getBalance().subtract(amount));
// Outbox event in same TX -- atomically consistent
outboxRepository.save(new OutboxEvent(
"PARTNER_CREDIT",
"{\"partnerAccountId\":\"" + partnerAccountId
+ "\",\"amount\":" + amount + "}"
));
// TX commits in ~10ms, connection released
}
// Step 2: compensating action if partner rejects
@Transactional
public void compensateDebit(String fromId, BigDecimal amount) {
var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
from.setBalance(from.getBalance().add(amount));
// Credit the money back -- compensating action
}
}
Saga flow:
1. TX-1: debit SAV2001 + write outbox event -> COMMIT (10ms)
2. Outbox relay reads event, publishes to Kafka
3. Partner consumer receives event, calls ICICI API
4a. ICICI succeeds -> mark outbox event as PROCESSED
4b. ICICI fails -> publish COMPENSATE event
5b. Compensation consumer -> TX-2: credit SAV2001 back -> COMMIT
Connection hold time per step: 10ms
No connection held during network call
What is the Transactional Outbox pattern?
The Transactional Outbox pattern writes the business data and an outbox event in the same short transaction. A separate relay process reads the outbox table and publishes events to a message broker (Kafka, RabbitMQ). The consumer on the other side performs the slow work.
package com.bank.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String eventType;
@Column(columnDefinition = "TEXT")
private String payload;
private String status; // PENDING, PUBLISHED, PROCESSED
private LocalDateTime createdAt;
public OutboxEvent() {}
public OutboxEvent(String eventType, String payload) {
this.eventType = eventType;
this.payload = payload;
this.status = "PENDING";
this.createdAt = LocalDateTime.now();
}
// Getters and setters
public Long getId() { return id; }
public String getEventType() { return eventType; }
public String getPayload() { return payload; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
}
Outbox table:
id | event_type | payload | status | created_at
1 | PARTNER_CREDIT | {"partnerAccountId":"...",..} | PENDING | 2024-01-15 10:30:00
2 | PARTNER_CREDIT | {"partnerAccountId":"...",..} | PUBLISHED | 2024-01-15 10:30:01
3 | PARTNER_CREDIT | {"partnerAccountId":"...",..} | PROCESSED | 2024-01-15 10:30:05
What are common mistakes with the pseudo-saga?
The first mistake is assuming the REST call is fast. Network latency varies, and a partner API that responds in 100ms on average can spike to 30 seconds under load or during a partner outage. The second mistake is wrapping the REST call in a try-catch and ignoring failures, which creates an inconsistency between the local debit and the partner credit.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
@Service
public class MistakeTransferService {
private final AccountRepository accountRepository;
private final RestTemplate restTemplate;
public MistakeTransferService(AccountRepository accountRepository,
RestTemplate restTemplate) {
this.accountRepository = accountRepository;
this.restTemplate = restTemplate;
}
// WRONG: swallow REST failure, local debit commits
@Transactional
public void transferWrong(String fromId, BigDecimal amount,
String partnerAccountId) {
var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
from.setBalance(from.getBalance().subtract(amount));
try {
restTemplate.postForEntity(
"https://api.icici.com/v1/credit",
new CreditRequest(partnerAccountId, amount),
Void.class
);
} catch (Exception e) {
// WRONG: REST failed but local debit will commit!
System.out.println("Partner call failed: " + e.getMessage());
}
}
// CORRECT: outbox pattern, no REST inside TX
@Transactional
public void transferCorrect(String fromId, BigDecimal amount,
String partnerAccountId) {
var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
from.setBalance(from.getBalance().subtract(amount));
// No REST call. Just write the outbox event.
// Relay handles the partner call outside any TX.
}
}
WRONG (swallow REST failure):
Local debit: SAV2001 balance reduced by 50000 -> COMMITTED
Partner credit: FAILED (network error)
Result: money disappeared! Customer lost 50000 rupees.
CORRECT (outbox):
Local debit + outbox event -> COMMITTED atomically
Relay publishes event to Kafka
If partner credit fails, relay retries or triggers compensation
Money is either transferred completely or refunded
What should a senior engineer know?
- Distributed monolith -- the pseudo-saga creates tight coupling between services. If the partner is down, the local service cannot process any transfers because every thread is stuck waiting on the REST call.
- Circuit breaker -- even with a circuit breaker (Resilience4j), the connection is still held during the circuit-open wait time. The circuit breaker helps with partner failures but does not solve the connection holding problem.
- Temporal and Camunda -- for complex multi-step workflows, use a workflow orchestrator like Temporal or Camunda. Each step is a separate activity with its own transaction, and the orchestrator manages retries and compensation.
- Debezium CDC -- instead of a custom outbox relay, use Debezium to capture changes from the outbox table via the database's write-ahead log (WAL). This guarantees exactly-once delivery without polling.
- The rule -- Pankaj, the architect at National Bank of India, enforces one rule: transactions must contain only database operations. Network calls, file I/O, and external API calls must happen outside the transaction boundary.
Pankaj's rule:
@Transactional {
database work only (debit, credit, outbox write)
}
// AFTER TX commits:
// relay -> Kafka -> partner call
// or: @TransactionalEventListener(AFTER_COMMIT)
Never:
@Transactional {
database work
REST call // <-- connection held during network wait
more database work
}
What are common interview follow-up questions?
- What happens if the outbox relay crashes after publishing to Kafka but before marking the event as PUBLISHED? The relay re-publishes on restart, so the consumer must be idempotent (use a unique event ID to deduplicate).
- How is the Transactional Outbox different from a Saga? The outbox is a delivery mechanism for events. A Saga is a pattern for coordinating multi-step business processes with compensating actions. The outbox enables the Saga.
- Why not just use @Async to call the partner after the transaction? @Async does not survive process restarts. If the application crashes after commit but before the async call, the event is lost. The outbox table is durable.
- What about two-phase commit (2PC)? 2PC requires all participants to support the XA protocol, is slow, and creates a single point of failure at the coordinator. It is rarely used in microservices.
Visual Overview
┌─────────────────────────────────────────────────────────┐
│ Pseudo-Saga (WRONG) │
│ │
│ @Transactional { │
│ debit SAV2001 (10ms DB work) │
│ REST call to ICICI (3000ms network wait) │
│ commit (5ms) │
│ } │
│ │
│ Connection held: ████████████████████████████ 3015ms │
│ Actual DB work: █ 15ms │
│ Wasted: ░░░░░░░░░░░░░░░░░░░░░░░░░ 3000ms │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Transactional Outbox (CORRECT) │
│ │
│ @Transactional { │
│ debit SAV2001 (10ms) │
│ write outbox event (5ms) │
│ commit (5ms) │
│ } │
│ Connection held: █ 20ms │
│ │
│ Outbox Relay (separate process): │
│ read outbox event │
│ publish to Kafka │
│ mark event PUBLISHED │
│ │
│ Partner Consumer: │
│ receive Kafka event │
│ call ICICI REST API (3000ms, own connection) │
│ mark event PROCESSED │
└─────────────────────────────────────────────────────────┘
Key Takeaway
The pseudo-saga anti-pattern holds a database connection open while making a synchronous REST call to another service. The connection is wasted during the entire network wait because the database has no work to do. REQUIRES_NEW makes it worse by holding two connections per thread. With 20 connections and a 3-second partner API call, maximum throughput is only 7 transfers per second. At any higher rate, the pool exhausts and the application fails even though the database is perfectly healthy.
In Rajesh's cross-border transfer service at the National Bank of India, the correct refactor replaces the synchronous REST call inside the transaction with a Transactional Outbox pattern. The transaction contains only database operations: save the transfer and write an outbox event in the same transaction. The connection is held for 10 milliseconds of actual DB work, then released. A separate outbox relay process publishes the event to Kafka, and a partner integration consumer makes the slow REST call with its own connection. Throughput jumps from 7 to 2000 transfers per second because connections are held for milliseconds of actual database work instead of seconds of network waiting.
Pankaj, the architect, enforces a simple rule: transactions must contain only database operations. Network calls, file I/O, and external API calls must happen outside the transaction boundary, either through AFTER_COMMIT event listeners or through the Transactional Outbox pattern with async relay.
Remember one sentence: never hold a database connection while waiting on a network call -- use the Transactional Outbox pattern to keep transactions short and move slow work outside.
---