skip to content

A third-party library calls dataSource.getConnection() itself and its writes aren't rolling back with your @Transactional method. What's happening and how do you fix it?

level: principalimportance: should knowfreq 32%

answer

  1. raw getConnection -> new Connection, auto-commit ON
  2. JdbcTemplate uses DataSourceUtils; library doesn't
  3. TransactionAwareDataSourceProxy routes via DataSourceUtils
  4. proxy close() is no-op inside tx
  5. TM/JdbcTemplate on RAW ds, proxy only to legacy code

basics

~20 s

The library opens its own Connection instead of the thread-bound one, so it runs outside your transaction with auto-commit on. Wrap the real DataSource in TransactionAwareDataSourceProxy and give the library that, so its getConnection() returns the transactional Connection.

solid answer

~40 s

DataSourceTransactionManager only makes writes transactional when they run on the Connection it bound to the thread. Spring's own JdbcTemplate fetches that Connection through DataSourceUtils.getConnection, but a third-party library calling dataSource.getConnection() directly gets a fresh Connection from the pool with auto-commit on — its statements commit immediately and are invisible to your rollback. The clean fix is TransactionAwareDataSourceProxy: wrap the physical DataSource in it and hand the proxy to the library. Its getConnection() returns a proxy Connection that transparently delegates to DataSourceUtils, so it resolves to the thread-bound transactional Connection when one exists and a normal one otherwise; close() is also made a no-op inside a transaction. Keep Spring's JdbcTemplate/transaction manager wired to the *raw* DataSource, and expose only the wrapped proxy to code that insists on calling getConnection itself.

code

java · 26 lines
java
@Configuration
public class DataConfig {

    @Bean
    public DataSource dataSource() {           // the real pooled DataSource
        return buildHikari();
    }

    // Transaction manager and Spring's JdbcTemplate target the RAW DataSource.
    @Bean
    public DataSourceTransactionManager txManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }

    @Bean
    public JdbcTemplate jdbcTemplate(DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }

    // Only the legacy library gets the transaction-aware proxy.
    @Bean
    public LegacyReportEngine reportEngine(DataSource dataSource) {
        DataSource txAware = new TransactionAwareDataSourceProxy(dataSource);
        return new LegacyReportEngine(txAware); // its getConnection() now joins the tx
    }
}

go deeper

for a junior

Recognize that a library opening its own Connection isn't in your transaction.

for a middle

Name TransactionAwareDataSourceProxy as the fix and that JdbcTemplate uses DataSourceUtils.

for a senior

Wire it correctly (proxy to legacy, raw to TM) and explain the no-op close semantics.

for a principal

Judge whether the library should be enrolled at all, weigh connection-hold/lock cost, and know XA is needed for multi-resource atomicity.

## Root cause: two ways to get a Connection Under `DataSourceTransactionManager`, exactly one `Connection` is bound to the thread (auto-commit off) and registered in `TransactionSynchronizationManager` keyed by the `DataSource`. There are then two very different ways code obtains a Connection: - **Transaction-aware path**: `DataSourceUtils.getConnection(ds)` → checks the thread, returns the *bound* Connection. This is what `JdbcTemplate`, `NamedParameterJdbcTemplate`, MyBatis' Spring integration, and Hibernate's Spring-managed connection provider all use. These participate in the transaction. - **Raw path**: `dataSource.getConnection()` → asks the pool for *any* Connection. Fresh Connections come with **auto-commit = true**, so each statement commits on its own and is neither part of your transaction nor subject to your rollback. A third-party library that manages its own JDBC (some batch tools, reporting engines, legacy DAOs) uses the raw path — hence the symptom: your `@Transactional` rolls back, but the library's inserts persist. ## The fix: TransactionAwareDataSourceProxy `org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy` wraps a target `DataSource`. Its `getConnection()` returns a **proxy `Connection`** whose method calls are routed through `DataSourceUtils`: - If a transactional Connection is bound to the thread, the proxy delegates every statement to it → the library now runs *inside* your transaction. - `close()` on the proxy becomes a no-op while a transaction is active (the real close happens when the transaction manager commits/rolls back), so the library's `try/finally { con.close(); }` doesn't prematurely release the shared Connection. - Outside any transaction it behaves like a plain DataSource (new Connection, real close). ### Critical wiring rule Give the **proxy** only to the legacy/third-party code. Wire your `DataSourceTransactionManager` (and ideally your own `JdbcTemplate`) to the **raw/target** DataSource. Reason: the transaction manager must operate on the underlying physical DataSource so the binding key and the actual `commit()`/`rollback()` land on the real Connection. Spring's docs explicitly note TransactionAwareDataSourceProxy should be the *outermost* wrapper handed to code that can't be made Spring-aware, and that Spring-managed components (JdbcTemplate, the transaction manager) should target the native DataSource directly. (If you point the transaction manager at the proxy, you can get double-wrapping and subtle bugs.) ## Other escape hatches to recognize (same root cause) - **Different DataSource bean**: the binding is keyed by DataSource identity; a JdbcTemplate on another DataSource won't join. - **New thread** (`@Async`, executor, reactive scheduler): ThreadLocal binding does not propagate; work runs in no/new transaction. - **Self-invocation / final/private methods**: the proxy that starts the transaction is bypassed, so nothing is ever bound. - **New Connection from `getConnection()` for a needed *separate* transaction**: sometimes this is *desired* (e.g., writing an audit row that must survive a rollback) — then leaving it on the raw DataSource, or using propagation `REQUIRES_NEW`, is the right design, not a bug. ## Design judgment (principal-level) Before reaching for the proxy, ask whether the library *should* be in your transaction. If it must be atomic with your writes → `TransactionAwareDataSourceProxy` (single DataSource) or, across resources, XA/JTA. If it is genuinely independent (fire-and-forget logging) → leave it raw or give it its own DataSource. Avoid making everything transaction-aware reflexively; it couples the library's connection lifecycle to your transaction and can hold the shared Connection (and its locks) longer than expected, hurting throughput.

  • Why should the transaction manager point at the raw DataSource, not the proxy?
    The manager must bind and commit the underlying physical Connection. Pointing it at the proxy risks double-wrapping and lifecycle confusion. The proxy is meant only for code that calls getConnection() itself and can't use DataSourceUtils.
  • When is it correct to leave code on the raw DataSource so it escapes the transaction?
    When the write must be independent of the outer transaction — e.g. audit/log rows that must persist even if business logic rolls back. Then a separate Connection (or propagation REQUIRES_NEW) is the intended design.
  • Does TransactionAwareDataSourceProxy help across two databases?
    No. It only makes code join the single thread-bound transaction for that one DataSource. Cross-database atomicity still needs JTA/XA.

saying these in an interview costs you the question

  • Claiming any getConnection() automatically joins the current transaction.
  • Wiring the transaction manager to the TransactionAwareDataSourceProxy instead of the raw DataSource.
  • Assuming the proxy provides cross-resource (multi-DB) atomicity.
  • Reflexively wrapping everything transaction-aware without considering held-Connection/lock duration.

context