Definition
- Swallowed exception -- a try-catch block in the controller catches the service exception, suppresses it, and returns a success response. The transaction rolled back, but the client receives HTTP 200.
- Silent false success -- the database correctly undoes the work, yet the client is told the operation succeeded. The two outcomes disagree, causing reconciliation breaks days later.
- @RestControllerAdvice -- a centralized exception handler that maps each exception type to the correct HTTP status code. Exceptions propagate out of the controller and are handled in one place.
- State machine -- model the operation as a database record with a status column: PENDING -> COMPLETED or FAILED. The status is updated inside the same transaction as the business logic.
- Idempotency -- the state machine record with a unique request ID enables idempotent retries. If the client retries, the API checks the status instead of re-executing.
At the National Bank of India, Rajesh asks to transfer Rs.50,000 to Priya. Deep inside, the vault refuses the transfer because of a rule violation and correctly puts the money back. But the clerk at the counter hides the rejection slip and hands Rajesh a receipt that says "Success." Rajesh leaves believing the rent is paid. Weeks later, Priya says no money arrived, the bank's reconciliation finds the mismatch, and now there is a dispute. The clerk who hid the rejection and printed a fake success receipt is the swallowed-exception bug.
Understanding the Concepts
What happens when a transaction rolls back but the API returns 200?
Spring's declarative transactions work through a proxy. When your @Transactional service method throws a runtime exception, the proxy catches it, rolls back the transaction, and re-throws the exception so the caller knows something went wrong. The database is now clean, as if nothing happened. The danger is entirely in what the caller does with that exception.
The classic bug looks like this:
package com.bank.controller;
import com.bank.service.TransferService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
@RequestMapping("/api/transfers")
public class BrokenTransferController {
private final TransferService transferService;
public BrokenTransferController(TransferService transferService) {
this.transferService = transferService;
}
// WRONG: catch-all swallows the exception
@PostMapping
public ResponseEntity<String> transfer(
@RequestParam String fromId,
@RequestParam String toId,
@RequestParam BigDecimal amount) {
try {
transferService.transfer(fromId, toId, amount);
} catch (Exception e) {
// Exception swallowed! Transaction already rolled back.
// But we return 200 OK anyway.
return ResponseEntity.ok("Transfer done");
}
return ResponseEntity.ok("Transfer done");
}
}
Execution flow:
1. Client sends POST /api/transfers?fromId=SAV2001&toId=CUR3001&amount=50000
2. transferService.transfer() starts TX
3. Debit SAV2001: 500000 -> 450000
4. Credit CUR3001: 100000 -> 150000
5. Exception: InsufficientFundsException (or DB constraint violation)
6. Proxy catches exception -> ROLLBACK
7. SAV2001 = 500000, CUR3001 = 100000 (unchanged)
8. Exception propagates to controller
9. Controller catches exception -> returns HTTP 200 "Transfer done"
10. Client receives 200 OK. Thinks money moved.
11. Database says nothing happened.
12. Disagreement: client vs database.
Why is this the most dangerous pitfall?
A plain crash is visible and gets fixed. The server returns 500, the client sees an error, ops gets alerted, and the bug is found within hours. A false success is invisible. The client goes about their day thinking the operation succeeded. The discrepancy surfaces days or weeks later during reconciliation, when the ledger and the client's belief do not match and nobody can explain why.
In banking, this creates regulatory risk. Every false 200 is a potential audit finding, a customer dispute, and a trust violation.
Crash (visible, fixable):
Server returns 500
Client sees error, retries or contacts support
Ops alerted within minutes
Bug found and fixed within hours
False success (invisible, catastrophic):
Server returns 200
Client thinks operation succeeded
No alert, no retry, no support call
Discovered during reconciliation (days/weeks later)
Customer dispute, audit finding, trust violation
How do you fix it with @RestControllerAdvice?
The first part of the fix is to stop swallowing exceptions. Let the exception propagate out of the controller and handle it in one centralized place with @RestControllerAdvice and @ExceptionHandler. Each exception type maps to the correct HTTP status code.
package com.bank.controller;
import com.bank.service.TransferService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
@RequestMapping("/api/transfers")
public class FixedTransferController {
private final TransferService transferService;
public FixedTransferController(TransferService transferService) {
this.transferService = transferService;
}
// CORRECT: no try-catch, let exceptions propagate
@PostMapping
public ResponseEntity<TransferResponse> transfer(
@RequestParam String fromId,
@RequestParam String toId,
@RequestParam BigDecimal amount) {
transferService.transfer(fromId, toId, amount);
return ResponseEntity.ok(
new TransferResponse("SUCCESS", "Transfer completed"));
}
}
package com.bank.controller;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(InsufficientFundsException.class)
public ResponseEntity<ErrorResponse> handleInsufficientFunds(
InsufficientFundsException ex) {
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(new ErrorResponse("INSUFFICIENT_FUNDS", ex.getMessage()));
}
@ExceptionHandler(AccountNotFoundException.class)
public ResponseEntity<ErrorResponse> handleAccountNotFound(
AccountNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("ACCOUNT_NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(ComplianceViolationException.class)
public ResponseEntity<ErrorResponse> handleComplianceViolation(
ComplianceViolationException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ErrorResponse("COMPLIANCE_VIOLATION", ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR",
"An unexpected error occurred"));
}
}
package com.bank.controller;
public record ErrorResponse(String code, String message) {}
public record TransferResponse(String status, String message) {}
With @RestControllerAdvice:
Insufficient funds -> HTTP 422 {"code":"INSUFFICIENT_FUNDS",...}
Account not found -> HTTP 404 {"code":"ACCOUNT_NOT_FOUND",...}
Compliance issue -> HTTP 403 {"code":"COMPLIANCE_VIOLATION",...}
Unknown error -> HTTP 500 {"code":"INTERNAL_ERROR",...}
Success -> HTTP 200 {"status":"SUCCESS",...}
The HTTP status always matches the transaction outcome.
How does a state machine prevent false successes?
The second part of the fix is to return success only after a confirmed commit. You model the operation as a database record with a status column that transitions through states: PENDING -> COMPLETED or FAILED.
The business logic and the status update happen in the same transaction. If anything throws, the whole transaction (including the status update) rolls back, so the status never reaches COMPLETED.
package com.bank.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "transfers")
public class Transfer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String requestId; // idempotency key
private String fromAccountId;
private String toAccountId;
private BigDecimal amount;
@Enumerated(EnumType.STRING)
private TransferStatus status; // PENDING, COMPLETED, FAILED
private String failureReason;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Transfer() {}
// Getters and setters
public Long getId() { return id; }
public String getRequestId() { return requestId; }
public void setRequestId(String requestId) { this.requestId = requestId; }
public String getFromAccountId() { return fromAccountId; }
public void setFromAccountId(String fromAccountId) { this.fromAccountId = fromAccountId; }
public String getToAccountId() { return toAccountId; }
public void setToAccountId(String toAccountId) { this.toAccountId = toAccountId; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public TransferStatus getStatus() { return status; }
public void setStatus(TransferStatus status) { this.status = status; }
public String getFailureReason() { return failureReason; }
public void setFailureReason(String reason) { this.failureReason = reason; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
enum TransferStatus {
PENDING, COMPLETED, FAILED
}
package com.bank.service;
import com.bank.model.Transfer;
import com.bank.model.TransferStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Service
public class StateMachineTransferService {
private final AccountRepository accountRepository;
private final TransferRepository transferRepository;
public StateMachineTransferService(AccountRepository accountRepository,
TransferRepository transferRepository) {
this.accountRepository = accountRepository;
this.transferRepository = transferRepository;
}
@Transactional(rollbackFor = Exception.class)
public Transfer transfer(String requestId, String fromId,
String toId, BigDecimal amount) {
// Idempotency check
var existing = transferRepository.findByRequestId(requestId);
if (existing.isPresent()) {
return existing.get(); // already processed
}
// Create transfer record in PENDING state
Transfer tx = new Transfer();
tx.setRequestId(requestId);
tx.setFromAccountId(fromId);
tx.setToAccountId(toId);
tx.setAmount(amount);
tx.setStatus(TransferStatus.PENDING);
tx.setCreatedAt(LocalDateTime.now());
transferRepository.save(tx);
// Business logic
var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
var to = accountRepository.findByIdForUpdate(toId).orElseThrow();
if (from.getBalance().compareTo(amount) < 0) {
tx.setStatus(TransferStatus.FAILED);
tx.setFailureReason("Insufficient funds");
tx.setUpdatedAt(LocalDateTime.now());
return tx;
}
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
tx.setStatus(TransferStatus.COMPLETED);
tx.setUpdatedAt(LocalDateTime.now());
return tx;
// If anything throws, ENTIRE TX rolls back
// Transfer record never reaches COMPLETED
}
}
package com.bank.controller;
import com.bank.model.Transfer;
import com.bank.model.TransferStatus;
import com.bank.service.StateMachineTransferService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
@RequestMapping("/api/transfers")
public class StateMachineController {
private final StateMachineTransferService transferService;
public StateMachineController(
StateMachineTransferService transferService) {
this.transferService = transferService;
}
@PostMapping
public ResponseEntity<Transfer> transfer(
@RequestHeader("X-Request-Id") String requestId,
@RequestParam String fromId,
@RequestParam String toId,
@RequestParam BigDecimal amount) {
Transfer result = transferService.transfer(
requestId, fromId, toId, amount);
// HTTP status based on committed status
if (result.getStatus() == TransferStatus.COMPLETED) {
return ResponseEntity.ok(result);
} else if (result.getStatus() == TransferStatus.FAILED) {
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(result);
} else {
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(result);
}
}
}
State machine flow:
1. POST /api/transfers with X-Request-Id: TXN-001
2. Transfer record created: status = PENDING
3. Business logic executes
4a. Success: status = COMPLETED -> HTTP 200
4b. Business failure: status = FAILED -> HTTP 422
4c. Exception: TX rolls back, PENDING record also rolled back
-> @RestControllerAdvice returns HTTP 500
4d. Retry: client sends same X-Request-Id
-> idempotency check returns existing record
The HTTP status is ALWAYS based on the committed database status.
Client, database, and downstream systems can never disagree.
What are common mistakes with exception handling in controllers?
The first mistake is the catch-all that returns 200 (shown above). The second mistake is catching the exception, logging it, and returning a generic 200 with an error message in the body. The client's HTTP library checks the status code, not the body.
package com.bank.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
@RequestMapping("/api/transfers")
public class SubtleMistakeController {
private final TransferService transferService;
public SubtleMistakeController(TransferService transferService) {
this.transferService = transferService;
}
// WRONG: HTTP 200 with error in body
@PostMapping("/subtle-mistake")
public ResponseEntity<String> transferSubtle(
@RequestParam String fromId,
@RequestParam String toId,
@RequestParam BigDecimal amount) {
try {
transferService.transfer(fromId, toId, amount);
return ResponseEntity.ok("{\"status\":\"SUCCESS\"}");
} catch (Exception e) {
// HTTP 200 but body says error -- client ignores body
return ResponseEntity.ok(
"{\"status\":\"ERROR\",\"message\":\""
+ e.getMessage() + "\"}");
}
}
// CORRECT: HTTP status matches the outcome
@PostMapping("/correct")
public ResponseEntity<String> transferCorrect(
@RequestParam String fromId,
@RequestParam String toId,
@RequestParam BigDecimal amount) {
transferService.transfer(fromId, toId, amount);
return ResponseEntity.ok("{\"status\":\"SUCCESS\"}");
// Exceptions propagate to @RestControllerAdvice
}
}
WRONG (200 with error body):
HTTP 200 OK
Body: {"status":"ERROR","message":"Insufficient funds"}
Client's HTTP library: status = 200 -> success!
Client's retry logic: not triggered (200 = success)
Monitoring dashboard: all 200s -> "everything is fine"
CORRECT (422 with error body):
HTTP 422 Unprocessable Entity
Body: {"status":"ERROR","message":"Insufficient funds"}
Client's HTTP library: status = 422 -> failure!
Client's retry logic: triggered appropriately
Monitoring dashboard: 422s visible -> "investigate"
How does @TransactionalEventListener(AFTER_COMMIT) help?
For the most robust design, you can trigger the success response or the downstream notification only after the commit is real, using a @TransactionalEventListener(phase = AFTER_COMMIT) to fire post-commit actions.
package com.bank.listener;
import com.bank.event.TransferCompletedEvent;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;
@Component
public class PostCommitNotifier {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onTransferCommitted(TransferCompletedEvent event) {
// This fires ONLY after the TX commits
// Safe to send SMS, publish Kafka event, etc.
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void onTransferRolledBack(TransferCompletedEvent event) {
// This fires ONLY if the TX rolls back
// Alert ops, log for investigation
}
}
With AFTER_COMMIT:
TX commits -> listener fires -> notification sent
TX rolls back -> AFTER_ROLLBACK fires -> ops alerted
The notification is NEVER sent for a rolled-back transaction.
What should a senior engineer know?
- HTTP status is a contract -- the HTTP status code is the first thing clients, load balancers, circuit breakers, and monitoring tools look at. A 200 means "success" to the entire ecosystem, not just the client.
- Reconciliation -- in banking, every transaction must reconcile. A false 200 creates a reconciliation break where the API response log says success but the database says nothing happened. These breaks cost hours of investigation.
- Idempotency from the state machine -- the Transfer record with a requestId enables safe retries. If the client retries with the same requestId, the service returns the existing record instead of re-executing. This is essential for unreliable networks.
- Testing for false successes -- write integration tests that verify the HTTP status matches the database state. After a failed transfer, assert both HTTP 4xx AND that the database balances are unchanged.
package com.bank.controller;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class TransferApiTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private AccountRepository accountRepository;
@Test
void insufficientFundsReturns422NotFalse200() throws Exception {
// SAV2001 has balance 500
mockMvc.perform(post("/api/transfers")
.param("fromId", "SAV2001")
.param("toId", "CUR3001")
.param("amount", "999999")) // more than balance
.andExpect(status().isUnprocessableEntity()); // 422, NOT 200
// Verify DB: balance unchanged
BigDecimal balance = accountRepository.findById("SAV2001")
.orElseThrow().getBalance();
// assertThat(balance).isEqualByComparingTo(new BigDecimal("500"));
}
}
Test verifies BOTH:
1. HTTP status is 422 (not 200)
2. Database balance is unchanged (transaction rolled back)
If either assertion fails, the false-success bug exists.
What are common interview follow-up questions?
- Why not return the error in the response body with HTTP 200? Because clients, load balancers, circuit breakers, and monitoring tools all use the HTTP status code to determine success or failure. Returning 200 with an error body is semantically wrong and breaks the entire ecosystem.
- How does the state machine provide idempotency? The requestId is a unique key. On retry, the service checks if a Transfer record with that requestId already exists and returns it instead of re-executing. This prevents double-debits.
- What if the database crashes during commit? The client gets a timeout or 500. On retry, the idempotency check finds no record (TX was rolled back), so it re-executes safely.
- How do you handle async operations? Return HTTP 202 Accepted with a location header for the status. The client polls the status endpoint until it sees COMPLETED or FAILED.
Visual Overview
┌──────────────────────────────────────────────────────────┐ │ WRONG: Exception swallowed, false 200 returned │ │ │ │ Client ──POST──► Controller ──► Service │ │ │ │ │ │ │ @Transactional │ │ │ debit + credit │ │ │ EXCEPTION! │ │ │ ROLLBACK (DB undone) │ │ │ │ │ │ │◄─── exception propagates │ │ │ │ │ catch(e) │ │ return 200 "Transfer done" │ │ │ │ │ Client ◄── 200 ──┘ │ │ │ │ Client thinks: SUCCESS │ │ Database says: NOTHING HAPPENED │ │ Disagreement! │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ CORRECT: State machine + @RestControllerAdvice │ │ │ │ Client ──POST──► Controller ──► Service │ │ │ │ │ │ │ @Transactional │ │ │ Transfer: PENDING │ │ │ debit + credit │ │ │ Transfer: COMPLETED │ │ │ COMMIT │ │ │ │ │ │ │◄─── Transfer returned │ │ │ │ │ status == COMPLETED │ │ return 200 │ │ │ │ │ Client ◄── 200 ──┘ │ │ │ │ Client thinks: SUCCESS │ │ Database says: COMPLETED │ │ Agreement! │ └──────────────────────────────────────────────────────────┘
Key Takeaway
This pitfall happens when a transactional method fails, Spring correctly rolls back the database, but the controller catches the exception, swallows it, and returns HTTP 200 OK anyway. The database and the API now tell different stories. The money did not move, yet the client is told it did. This is the most dangerous transactional pitfall because it is silent. A crash is visible and gets fixed quickly, but a false success is invisible and surfaces days later as a reconciliation break.
In Rajesh's transfer API at the National Bank of India, the controller wrapped the service call in a try-catch that returned 200 in every case because an early developer wanted to never show errors to the customer. When Priya tried to send Rs.9,99,999 she did not have, the service threw, Spring rolled back, and the API still returned success. The end-of-month reconciliation found thousands of successful responses with no matching ledger entries.
The fix has two parts. First, stop swallowing exceptions. Let them propagate out of the controller and handle them in one place with a @RestControllerAdvice, mapping each exception type to the correct HTTP status. A rolled-back transfer returns a 422, never a 200. Second, model the operation as an explicit state machine. The transfer record starts in PENDING, and the status is set to COMPLETED inside the same transaction that moves the money. The controller returns 200 only when the committed status is COMPLETED.
Remember one sentence: the HTTP status must be a faithful mirror of the committed transaction outcome -- never return 200 unless the database confirms COMPLETED.
---