12 Spring Transaction Failure Scenarios: Why 90% of Developers Fall for #8

This article systematically breaks down 12 common scenarios where Spring @Transactional fails, from non-public methods and self-invocation to exception swallowing, wrong propagation, controller-layer annotations, long transactions, and misconfigured transaction managers. Each scenario includes code examples, root-cause analysis, and practical fixes.

Programmer1970
Programmer1970
Programmer1970
12 Spring Transaction Failure Scenarios: Why 90% of Developers Fall for #8

I. How Spring Transactions Work Under the Hood

Spring transactions are fundamentally AOP proxies. The call chain is:

Business method → TransactionInterceptor.invoke() → getTransaction() → PlatformTransactionManager.getTransaction() → Create Connection → Set autoCommit=false → Execute SQL → commit/rollback

Transaction failure means this chain breaks at some link. The article dissects 12 such break points.

II. 12 Failure Scenarios

Scenario 1: Method Is Not public

@Transactional
private void transfer(Long from, Long to, BigDecimal amount) {
  // ❌ private/protected/package-private all fail
  // Spring AOP uses JDK dynamic proxy by default, only proxies public methods
  // Even with CGLIB (proxyTargetClass=true), private methods cannot be intercepted
}

Fix: Make the method public, or enable CGLIB with @EnableTransactionManagement(proxyTargetClass=true).

Scenario 2: Self-Invocation (Calling Another Method in the Same Class)

@Service
public class OrderService {
  public void createOrder() {
    // ✅ Transaction works here
    doCreateOrder(); // ❌ Self-call bypasses proxy, transaction fails
  }
  @Transactional
  public void doCreateOrder() { ... }
}

Why: The call goes directly to the target object, not through the proxy.

Fixes:

Split into two separate services

Inject self ( @Autowired OrderService self) and call via self.doCreateOrder() Use AopContext.currentProxy() to get the proxy object

@Service
public class OrderService {
  public void createOrder() {
    ((OrderService) AopContext.currentProxy()).doCreateOrder();
  }
  @Transactional
  public void doCreateOrder() { ... }
}

Scenario 3: Exception Caught and Swallowed

@Transactional
public void transfer(Long from, Long to, BigDecimal amount) {
  try {
    accountMapper.debit(from, amount);
    accountMapper.credit(to, amount);
  } catch (Exception e) {
    log.error("Transfer failed", e);
    // ❌ Swallowing exception prevents transaction manager from seeing it → no rollback
  }
}

Fix: Either don't catch (let exception propagate) or manually trigger rollback:

@Transactional(rollbackFor = Exception.class)
public void transfer(...) {
  try {
    ...
  } catch (Exception e) {
    log.error("Transfer failed", e);
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    throw e;
  }
}

Scenario 4: Wrong Exception Type (Default Rolls Back Only RuntimeException)

@Transactional // Default rollbackFor = {RuntimeException, Error}
public void transfer(...) throws IOException {
  // IOException is checked → won't trigger rollback
  throw new IOException("Connection failed");
}

Fix: Explicitly specify:

@Transactional(rollbackFor = Exception.class)
// or
@Transactional(rollbackFor = {IOException.class, SQLException.class})

Scenario 5: Database Engine Doesn't Support Transactions

-- Table uses MyISAM engine, no transaction support
CREATE TABLE t_account (
  id BIGINT PRIMARY KEY,
  balance DECIMAL(10,2)
) ENGINE=MyISAM;

MySQL 5.5+ defaults to InnoDB, but some ops teams switch to MyISAM for "performance" or legacy systems remain.

Fix:

ALTER TABLE t_account ENGINE=InnoDB;

Scenario 6: Transaction Lost in Multi-Threading

@Transactional
public void process() {
  new Thread(() -> {
    // ❌ New thread cannot access main thread's Connection
    // Spring transactions are bound to ThreadLocal
    userMapper.updateStatus(...);
  }).start();
}

Fix: Move transactional logic to a separate service method with its own transaction:

@Transactional
public void process() {
  CompletableFuture.runAsync(() -> asyncService.updateStatus(...));
}

@Service
public class AsyncService {
  @Transactional(propagation = Propagation.REQUIRES_NEW)
  public void updateStatus(...) { ... }
}

Scenario 7: Wrong Propagation Configuration

@Transactional(propagation = Propagation.SUPPORTS)
public void query() {
  // SUPPORTS: join if exists, otherwise run non-transactionally
  // If no outer transaction, no new transaction is created
}

@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void doSomething() {
  // Suspends current transaction, runs non-transactionally
  // If exception thrown inside, nothing rolls back
}

The article provides a full list of all 7 propagation behaviors:

REQUIRED (default): join existing or create new

REQUIRES_NEW : suspend current, create independent transaction

SUPPORTS : join if exists, otherwise non-transactional

NOT_SUPPORTED : suspend current, run non-transactionally

MANDATORY : must have existing transaction, else throw exception

NEVER : must run without transaction, else throw exception

NESTED : nested transaction, child can roll back independently

Fix: Understand each behavior and choose deliberately.

Scenario 8: @Transactional on Controller Instead of Service (90% Trap)

@RestController
public class OrderController {
  @Autowired OrderService orderService;
  @Transactional // ❌ On Controller
  @PostMapping("/order")
  public Result createOrder(@RequestBody OrderRequest req) {
    orderService.create(req); // Service has its own @Transactional → two independent transactions
    return Result.success();
  }
}

@Service
public class OrderService {
  @Transactional // ✅ Own transaction
  public void create(OrderRequest req) { ... }
}

Why this is dangerous:

Controller transaction only wraps the service call. Service's DAO operations run in Service's own transaction.

Two separate Connections → two independent transactions.

If Controller commits but Service later rolls back, you get "API returns success but data not persisted" — a silent data inconsistency.

Fix: Keep transactions strictly in Service layer:

@Service
public class OrderService {
  @Transactional(rollbackFor = Exception.class)
  public Result createOrder(OrderRequest req) {
    orderMapper.insert(...);
    itemMapper.insert(...);
    logMapper.insert(...);
    return Result.success();
  }
}

@RestController
public class OrderController {
  @PostMapping("/order")
  public Result createOrder(@RequestBody OrderRequest req) {
    // No @Transactional here
    return orderService.createOrder(req);
  }
}

Scenario 9: Non-DB Operations in Transaction Cause Timeout Rollback

@Transactional(timeout = 5) // 5-second timeout
public void process() {
  httpClient.callExternalApi(); // ❌ External call takes 10 seconds
  orderMapper.insert(...);
}

Transaction rolls back after timeout, but external system already processed the request — its side effects cannot be undone.

Fix: Move external calls outside the transaction, or use Saga/compensation:

public void process() {
  ExternalResult result = httpClient.callExternalApi(); // Outside transaction
  doWithTransaction(result);
}

@Transactional
public void doWithTransaction(ExternalResult result) {
  orderMapper.insert(...);
}

Scenario 10: Long Transaction Exhausts Connection Pool

@Transactional
public void batchProcess(List<Order> orders) {
  for (Order order : orders) {
    complexBusinessLogic(order); // 5 seconds each
  }
  // 1000 orders × 5s = 5000s → one connection held for ~1.5 hours
}

Default pool size 8-20; one long transaction blocks a connection, all other requests queue.

Fix: Batch commit using TransactionTemplate:

public void batchProcess(List<Order> orders) {
  Lists.partition(orders, 100).forEach(batch -> {
    transactionTemplate.execute(status -> {
      batch.forEach(order -> complexBusinessLogic(order));
      return null;
    });
  });
}

Scenario 11: Misusing readOnly = true

@Transactional(readOnly = true) // Hint for read-only optimization
public void createOrder(OrderRequest req) {
  orderMapper.insert(req); // ❌ Write in read-only transaction → MySQL may reject or ignore
}

Some ORMs still flush dirty data in readOnly mode, but the connection is read-only so the database rejects writes.

Fix: Never use readOnly = true for write operations.

Scenario 12: Wrong TransactionManager in Multi-DataSource Setup

// Multiple data sources, no explicit TransactionManager specified
@Transactional // ❌ Defaults to first TransactionManager, may be wrong
public void createOrder(...) { ... }

// Correct
@Transactional(transactionManager = "orderTransactionManager")
public void createOrder(...) { ... }

Verification: Inject the specific manager:

@Autowired
@Qualifier("orderTransactionManager")
private PlatformTransactionManager orderTxManager;

III. Quick Debugging Techniques

3.1 Enable Spring Transaction Debug Logs

logging:
  level:
    org.springframework.transaction: DEBUG
    org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG

Look for:

Creating new transaction with name [com.example.OrderService.create]
Opened new Connection
...
Committing JDBC transaction on Connection

If "Creating new transaction" is absent, the AOP proxy never intercepted — transaction never started.

3.2 Manual Verification with TransactionTemplate

@Autowired
private TransactionTemplate transactionTemplate;

public void test() {
  transactionTemplate.execute(status -> {
    // Code here is guaranteed inside a transaction
    orderMapper.insert(...);
    return null;
  });
}

If TransactionTemplate works but @Transactional doesn't, the issue is in the AOP proxy layer.

IV. Visual Summary of All Failure Points

@Transactional call
  │
  ├─ Method non-public → Proxy fails
  ├─ Self-invocation → Bypasses proxy
  ├─ Exception swallowed → No rollback
  ├─ Wrong exception type → No rollback
  ├─ Engine unsupported → Physically no transaction
  ├─ Multi-thread → ThreadLocal broken
  ├─ Propagation misconfigured → Transaction isolation
  ├─ On Controller → Dual transaction conflict ← 90% trap
  ├─ External call timeout → Side effects irreversible
  ├─ Long transaction → Connection pool exhausted
  ├─ readOnly misuse → Write rejected
  └─ Wrong TransactionManager → Wrong connection used

V. Frequency & Severity Summary

@Transactional on Controller — Frequency: ★★★★★, Severity: High (data inconsistency + connection leak)

Self-invocation — Frequency: ★★★★, Severity: High (silent failure, no error)

Exception swallowed — Frequency: ★★★★, Severity: High (data corruption)

Wrong propagation — Frequency: ★★★, Severity: Medium

Long transaction — Frequency: ★★★, Severity: Medium (performance)

Multi-threading — Frequency: ★★, Severity: Medium

readOnly misuse — Frequency: ★★, Severity: Medium

Engine unsupported — Frequency: ★, Severity: Low (rare)

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

DebuggingAOPDatabaseBackend DevelopmentSpringBest Practices@TransactionalTransaction Management
Programmer1970
Written by

Programmer1970

Formerly called 'Code to 35'. Add our main WeChat ID to access a wealth of shared resources (algorithms, interview prep, tech stacks: Java, Python, Go, big data). We mainly share serious development techniques, focusing on output-driven input. Occasionally we post life snippets and gossip. Our aim is to attract precise traffic and test advertising opportunities.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.