Definition
- Manual JDBC transaction – you obtain a
Connection, callsetAutoCommit(false), execute SQL, callcommit()on success,rollback()on failure, andclose()in a finally block; 30+ lines of fragile boilerplate per operation. - Spring @Transactional – one annotation on the method; the AOP proxy performs the same steps automatically, plus adds HikariCP connection pooling, propagation, isolation, timeout, readOnly, and rollbackFor.
- Auto-configuration – Spring Boot auto-configures
HikariDataSource,JpaTransactionManager, andEntityManagerFactoryfromapplication.ymlproperties. - Connection binding – Spring binds the connection to the current thread via
TransactionSynchronizationManagerso multiple repository calls share the same connection without passing it as a parameter.
At the National Bank of India, manual JDBC is like a teller who must personally open the vault, do the work, and close the vault after every operation. If the teller forgets to close the vault after an error, it stays open all night (a connection leak). Spring's @Transactional is an automatic vault door that opens when the teller's form arrives, lets the teller work, and always closes regardless of success or failure.
Understanding the Concepts
What is a manual JDBC transaction?
A manual JDBC transaction is the raw approach where you manage every step of the connection lifecycle yourself. You obtain a Connection from a DataSource, call setAutoCommit(false) to begin the transaction, execute your SQL statements, call commit() if everything succeeds, call rollback() in the catch block if an exception occurs, and call close() in the finally block to release the connection back to the pool.
package com.bank.service;
import javax.sql.DataSource;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
public class ManualTransferService {
private final DataSource dataSource;
public ManualTransferService(DataSource dataSource) {
this.dataSource = dataSource;
}
public void transfer(String fromId, String toId, BigDecimal amount)
throws SQLException {
Connection conn = null;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false);
PreparedStatement debit = conn.prepareStatement(
"UPDATE account SET balance = balance - ? WHERE id = ?");
debit.setBigDecimal(1, amount);
debit.setString(2, fromId);
debit.executeUpdate();
PreparedStatement credit = conn.prepareStatement(
"UPDATE account SET balance = balance + ? WHERE id = ?");
credit.setBigDecimal(1, amount);
credit.setString(2, toId);
credit.executeUpdate();
conn.commit();
System.out.println("Transfer committed: " + amount + " from "
+ fromId + " to " + toId);
} catch (SQLException e) {
if (conn != null) {
conn.rollback();
System.out.println("Transfer rolled back");
}
throw e;
} finally {
if (conn != null) {
conn.setAutoCommit(true);
conn.close();
}
}
}
}
Transfer committed: 50000 from SAV2001 to SAV3002
What does Spring Boot @Transactional replace?
Spring's @Transactional replaces the entire manual try-commit-catch-rollback-finally block with one annotation. The AOP proxy performs exactly the same steps, but it does them correctly every time. You write only the business logic.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class SpringTransferService {
private final AccountRepository accountRepo;
public SpringTransferService(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
@Transactional(rollbackFor = Exception.class, timeout = 10)
public void transfer(String fromId, String toId, BigDecimal amount) {
Account sender = accountRepo.findByIdForUpdate(fromId);
Account receiver = accountRepo.findByIdForUpdate(toId);
sender.debit(amount);
receiver.credit(amount);
accountRepo.save(sender);
accountRepo.save(receiver);
}
}
The proxy does: get connection from HikariCP, setAutoCommit(false), bind to ThreadLocal, call your method, commit on normal return, rollback on RuntimeException, release connection back to pool.
How do the two approaches compare?
| Aspect | Manual JDBC | Spring @Transactional |
|--------|-------------|-----------------------|
| Begin TX | conn.setAutoCommit(false) | Proxy handles it |
| Commit | Explicit conn.commit() | Proxy on normal return |
| Rollback | Explicit conn.rollback() in catch | Proxy on RuntimeException |
| Close connection | conn.close() in finally | Proxy releases to pool |
| Connection sharing | Pass Connection parameter | ThreadLocal binding |
| Connection pooling | Manual (configure yourself) | HikariCP auto-configured |
| Propagation | Not supported | REQUIRED, REQUIRES_NEW, etc. |
| Isolation control | conn.setTransactionIsolation() | Annotation attribute |
| Timeout | Manual timer | timeout attribute |
| Read-only hint | conn.setReadOnly(true) | readOnly attribute |
| Lines of code | 30+ per operation | 1 annotation |
| Error risk | High (missing close, missing rollback) | Low (proxy always executes) |
// Manual: 35 lines of boilerplate
public void transferManual(...) throws SQLException {
Connection conn = null;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false);
// ... SQL ...
conn.commit();
} catch (SQLException e) {
if (conn != null) conn.rollback();
throw e;
} finally {
if (conn != null) { conn.setAutoCommit(true); conn.close(); }
}
}
// Spring: 1 annotation, 6 lines of business logic
@Transactional(rollbackFor = Exception.class)
public void transferSpring(...) {
Account sender = accountRepo.findByIdForUpdate(fromId);
Account receiver = accountRepo.findByIdForUpdate(toId);
sender.debit(amount);
receiver.credit(amount);
}
What does Spring Boot auto-configure for transactions?
When you add spring-boot-starter-data-jpa to your project, Spring Boot auto-configures:
HikariDataSourceas the connection pool (readsspring.datasource.url,username,passwordfromapplication.yml).LocalContainerEntityManagerFactoryBeanfor JPA entity management.JpaTransactionManageras thePlatformTransactionManager.@EnableTransactionManagementwhich activates the CGLIB proxy infrastructure.
You do not need to create any of these beans manually. The minimum configuration is:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/nbi_core_banking
username: nbi_admin
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect
With this configuration, @Transactional on any @Service method works automatically.
What are the failure modes of manual JDBC?
Manual JDBC has several failure modes that Spring eliminates.
Failure 1: missing rollback leaves partial data
// WRONG: no rollback in catch block
public void transferNoRollback(String fromId, String toId, BigDecimal amount)
throws SQLException {
Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);
try {
debit(conn, fromId, amount); // succeeds
credit(conn, toId, amount); // throws exception
conn.commit();
} catch (SQLException e) {
// MISSING: conn.rollback()
throw e; // debit is still pending, connection may auto-commit on close
} finally {
conn.close();
}
}
If the credit fails, the debit might be committed when the connection closes (depending on the driver's behavior). Rajesh loses Rs.50,000 and Priya never receives it.
Failure 2: missing close leaks the connection
// WRONG: no finally block, exception causes connection leak
public void transferNoClose(String fromId, String toId, BigDecimal amount)
throws SQLException {
Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);
debit(conn, fromId, amount);
credit(conn, toId, amount);
conn.commit();
conn.close(); // never reached if an exception is thrown above
}
After enough leaked connections, the HikariCP pool is exhausted and the entire application hangs waiting for a free connection.
What are common mistakes when moving from manual to Spring?
Mistake 1: wrapping @Transactional in try-catch that swallows the exception
// WRONG: swallowing exception prevents proxy from seeing it
@Transactional
public void transferBad(String fromId, String toId, BigDecimal amount) {
try {
Account sender = accountRepo.findByIdForUpdate(fromId);
Account receiver = accountRepo.findByIdForUpdate(toId);
sender.debit(amount);
receiver.credit(amount);
} catch (Exception e) {
log.error("Transfer failed", e);
// exception is swallowed, proxy sees normal return, commits partial work
}
}
// CORRECT: let exception propagate for rollback
@Transactional(rollbackFor = Exception.class)
public void transferCorrect(String fromId, String toId, BigDecimal amount) {
Account sender = accountRepo.findByIdForUpdate(fromId);
Account receiver = accountRepo.findByIdForUpdate(toId);
sender.debit(amount);
receiver.credit(amount);
// proxy sees RuntimeException and rolls back
}
Mistake 2: thinking @Transactional works on private methods
// WRONG: CGLIB cannot override private methods
@Service
public class FeeService {
@Transactional
private void applyFee(String accountId) { }
}
// CORRECT: must be public
@Service
public class FeeService {
@Transactional
public void applyFee(String accountId) { }
}
What about connection pooling?
Without Spring, you must configure a connection pool manually (HikariCP, DBCP, or C3P0) and integrate it with your transaction code. With Spring Boot, HikariCP is the default pool, auto-configured from spring.datasource.* properties. The proxy obtains and releases connections through the pool automatically.
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
A slow transactional method holds a connection for its entire duration. If your service has 20 pool connections and 20 concurrent requests each take 10 seconds, the 21st request waits until one finishes. This is why you keep transactional methods fast and move non-database work (external API calls, file I/O) outside the transaction boundary.
What should a senior engineer know?
- The proxy IS the manual code. Understanding this demystifies transaction behavior. When you enable
logging.level.org.springframework.transaction=TRACE, you see the proxy doing exactly what you would do manually: begin, commit, or rollback. - In a microservice architecture, each service has its own database and its own
PlatformTransactionManager. Cross-service transactions require the Saga pattern, not@Transactional. - Spring supports multiple transaction managers. If you have two DataSources, you can qualify which manager each
@Transactionalmethod uses via@Transactional("orderTxManager"). - For reactive applications (WebFlux),
@Transactionalworks differently: it usesReactiveTransactionManagerand the connection is bound to the reactive context, not a ThreadLocal.
package com.bank.config;
// application.yml to enable transaction logging
// logging:
// level:
// org.springframework.transaction: TRACE
// com.zaxxer.hikari: DEBUG
DEBUG c.z.h.HikariDataSource - nbi-pool: getConnection() borrowed Connection
TRACE o.s.t.i.TransactionInterceptor - Getting transaction for [com.bank.service.SpringTransferService.transfer]
DEBUG o.s.orm.jpa.JpaTransactionManager - Creating new transaction with name [com.bank.service.SpringTransferService.transfer]
DEBUG o.s.orm.jpa.JpaTransactionManager - Initiating transaction commit
TRACE o.s.t.i.TransactionInterceptor - Completing transaction for [com.bank.service.SpringTransferService.transfer]
DEBUG c.z.h.HikariDataSource - nbi-pool: returned Connection to pool
Visual Overview
Manual JDBC Spring @Transactional
────────── ─────────────────────
conn = getConnection() Proxy:
conn.setAutoCommit(false) getConnection()
try { setAutoCommit(false)
debit(conn, SAV2001, 50000) ┌─────────────────┐
credit(conn, SAV3002, 50000) │ transfer() { │
conn.commit() │ debit(...) │
} catch (e) { │ credit(...) │
conn.rollback() │ } │
} finally { └─────────────────┘
conn.close() commit() or rollback()
} releaseConnection()
Key Takeaway
Without Spring, transaction management is a manual process: obtain a connection, disable auto-commit, run the SQL, commit on success, rollback on failure, and close the connection in a finally block. This is 30+ lines of boilerplate per operation, with multiple failure modes: a missing rollback leaves partial data (Rajesh loses money, Priya never receives it), and a missing close leaks the connection until the pool is exhausted and the application hangs. In the RTGS transfer service at National Bank of India, every operation would need this boilerplate.
Spring's @Transactional replaces all of it with one annotation. The AOP proxy performs exactly the same steps: get a pooled connection from HikariCP, set auto-commit to false, call your method, commit on normal return, rollback on RuntimeException, and release the connection back to the pool. It does this reliably every time, eliminating the human error that manual JDBC invites. On top of the basics, Spring adds propagation, isolation, timeout, readOnly, and rollbackFor configuration that manual JDBC cannot express without building your own framework.
The key insight for seniors is that the proxy IS the manual code, generated and executed by Spring. When you understand this, debugging transaction issues becomes straightforward: enable TRACE logging on org.springframework.transaction, and you see the proxy doing begin, commit, and rollback just like you would in manual JDBC.
Remember one sentence: @Transactional is the manual try-commit-catch-rollback-finally block generated by an AOP proxy, replacing 30+ lines of error-prone JDBC boilerplate with one reliable annotation.
---