Back to @Transactional Basics
Q4Question 4 of 7

Transactions with and without Spring Boot?

Key Concepts
  • Without Spring: manual Connection.setAutoCommit(false), commit(), rollback(), and close() in a finally block
  • With Spring: @Transactional replaces 30+ lines of boilerplate with one annotation
  • Manual JDBC is error-prone: missing rollback causes partial data, missing close causes connection leaks
  • Spring auto-configures DataSource (HikariCP), PlatformTransactionManager, and JPA EntityManager
  • Spring adds propagation, isolation, timeout, readOnly, and rollbackFor configuration that manual JDBC cannot express
  • The AOP proxy is literally the manual try-commit-catch-rollback-finally block generated and executed reliably every time

Definition

Without Spring you manually manage Connection.setAutoCommit(false), commit(), rollback(), and close() in try-catch-finally, while Spring's @Transactional replaces all of that with one annotation through an AOP proxy.
  • Manual JDBC transaction – you obtain a Connection, call setAutoCommit(false), execute SQL, call commit() on success, rollback() on failure, and close() 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, and EntityManagerFactory from application.yml properties.
  • Connection binding – Spring binds the connection to the current thread via TransactionSynchronizationManager so 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.

JAVA
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();
            }
        }
    }
}
TEXT
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.

JAVA
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) |

JAVA
// 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:

  • HikariDataSource as the connection pool (reads spring.datasource.url, username, password from application.yml).
  • LocalContainerEntityManagerFactoryBean for JPA entity management.
  • JpaTransactionManager as the PlatformTransactionManager.
  • @EnableTransactionManagement which activates the CGLIB proxy infrastructure.

You do not need to create any of these beans manually. The minimum configuration is:

YAML
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

JAVA
// 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

JAVA
// 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

JAVA
// 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
    }
}
JAVA
// 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

JAVA
// WRONG: CGLIB cannot override private methods
@Service
public class FeeService {
    @Transactional
    private void applyFee(String accountId) { }
}
JAVA
// 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.

YAML
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 @Transactional method uses via @Transactional("orderTxManager").
  • For reactive applications (WebFlux), @Transactional works differently: it uses ReactiveTransactionManager and the connection is bound to the reactive context, not a ThreadLocal.
JAVA
package com.bank.config;

// application.yml to enable transaction logging
// logging:
//   level:
//     org.springframework.transaction: TRACE
//     com.zaxxer.hikari: DEBUG
TEXT
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

DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate