8/11/2025

SpringBoot 3.5 JPA Transaction Management

 

Introduction


This is the fourth episode of this SpringBoot series [ Part 1 ].

Spring Boot’s @Transactional abstraction makes database transaction management straightforward for developers. Under the hood, it coordinates JDBC connections, transaction boundaries, and JPA/Hibernate’s persistence context to ensure data consistency. This article covers:

  • How transactions are created, managed, and closed.

  • JDBC connection binding/unbinding.

  • Transaction boundaries supported by Spring Boot + JPA.

  • Connection pooling with HikariCP.

  • Whether JPA creates default transactions for service methods.

  • Transaction isolation levels, comparison, and anomalies explained.

  • Proxy-based transaction triggering.

  • Locking strategies and implications.

  • Foreign key and insert behavior.

  • Example with saveAsynch.

  • Virtual threads (JDK 21) impact.

Transaction Boundaries in Spring Boot + JPA

Spring Boot uses Spring’s PlatformTransactionManager (typically JpaTransactionManager for JPA) to manage transaction boundaries. Common propagation types:

  • REQUIRED (default) — Joins the existing transaction or creates a new one if none exists.

  • REQUIRES_NEW — Suspends the current transaction and starts a new one.

  • MANDATORY — Must run within an existing transaction, otherwise throws.

  • SUPPORTS — Runs within a transaction if one exists, otherwise non-transactional.

  • NOT_SUPPORTED — Suspends any existing transaction.

  • NEVER — Throws if a transaction exists.

  • NESTED — Runs within a nested transaction if supported by the database.

Why REQUIRES_NEW Can Be Problematic

While REQUIRES_NEW has valid use cases (e.g., audit logging regardless of main transaction outcome), overusing it can cause:

  • Connection pool exhaustion — Each REQUIRES_NEW call needs its own physical DB connection, which can quickly deplete the HikariCP pool.

  • Unexpected commits — Work inside REQUIRES_NEW commits independently; if the main transaction rolls back, data written in REQUIRES_NEW remains.

  • Performance penalties — Suspending and resuming transactions incurs overhead and may lead to contention.

  • Complex debugging — Makes reasoning about transactional consistency harder.

Best practice: Use REQUIRES_NEW only when you explicitly need an independent transaction.

Does JPA Create a Default Transaction for Service Methods?

JPA does not automatically start a transaction for every service method. In Spring, transactions start when:

  • The method is annotated with @Transactional.

  • The method is called from outside the bean via a Spring proxy.

Without an active transaction, Spring Data JPA will start a temporary transaction for a single write, or execute reads in autocommit mode depending on the driver. Always define transaction boundaries explicitly at the service layer.

Proxy Limitations

@Transactional on private methods is ignored — proxies intercept only public/protected methods called externally. A transaction you see in logs may come from a repository method like SimpleJpaRepository.saveAll(...) instead.


How Transactions Work in Spring Boot + JPA

  1. Transaction startJpaTransactionManager begins or joins a transaction.

  2. First SQL triggers borrow — Hibernate calls DataSource.getConnection(), HikariCP provides an idle connection.

  3. Bind to transaction — The connection is bound to the current thread in TransactionSynchronizationManager.

  4. Persistence context — Hibernate Session is linked to the transaction.

  5. Work execution — SQL runs via the bound connection.

  6. Commit/Rollback — Hibernate flushes changes, then commits/rolls back.

  7. Unbind — Connection is detached from the transaction context.

  8. Return to pool — HikariCP resets state and marks it idle.

When reusing a connection from the pool, HikariCP validates it and applies the new transaction’s settings.

Transaction Propagation from Application to Database


When a method in your application is annotated with @Transactional, Spring starts a logical transaction context within the application layer. This is managed by the PlatformTransactionManager and controls the transaction boundaries according to your propagation settings.

When the first database operation is executed, Spring:

  1. Logical Transaction Creation (Application) — Creates the transaction context when the method starts.

  2. First DB Call — Obtains a JDBC connection from the pool (e.g., HikariCP) and disables autocommit.

  3. Bind Connection — Associates (binds) the JDBC connection with the current transaction context, thread-bound.

  4. Transaction Propagation — Transaction context object propagate from application layer to DB layer. Inside the DB , operations execute within the same transaction context.

  5. Execution Phase — All queries use this same connection until the transaction ends.

  6. Commit/Rollback — Coordinates with the DB to commit or roll back.

  7. Unbind and Return — Resets the connection to default state and returns it to the pool.

Logical vs Physical Transaction

  • Logical transaction: Exists in the application; defines the transactional scope and propagation behavior.

  • Physical transaction: Exists in the database; enforced via the bound JDBC connection.

Why it matters:
This binding ensures that every DB operation inside the annotated method participates in the same DB-level transaction, maintaining ACID guarantees across both the application and database.


Transaction Isolation Levels — Comparison

Isolation Level

Dirty Reads

Non-Repeatable Reads

Phantom Reads

Concurrency Impact

READ_UNCOMMITTED

✅ Allowed

✅ Allowed

✅ Allowed

Highest concurrency, lowest consistency

READ_COMMITTED

❌ Prevented

✅ Allowed

✅ Allowed

Good balance, default in many RDBMS

REPEATABLE_READ

❌ Prevented

❌ Prevented

✅ Allowed

Lower concurrency, higher consistency

SERIALIZABLE

❌ Prevented

❌ Prevented

❌ Prevented

Lowest concurrency, highest consistency

Isolation Anomalies Explained

  • Dirty Read — Transaction A reads uncommitted changes from Transaction B. If B rolls back, A has read invalid data.

  • Non-Repeatable Read — Transaction A reads the same row twice and gets different values because Transaction B updated it and committed in between.

  • Phantom Read — Transaction A runs a query twice with the same criteria but gets different row sets because Transaction B inserted/deleted matching rows.

@Transactional(isolation = Isolation.REPEATABLE_READ)

public void processPayment(...) { }

Locking: Optimistic vs. Pessimistic

Optimistic Locking

  • Assumes conflicts are rare.

  • Uses a @Version column; Hibernate includes version in UPDATE/DELETE WHERE clause.

  • How Hibernate checks version:

    • No extra SELECT is run during update.

    • Hibernate sends SQL like:
      update payment set amount=?, version=version+1 where id=? and version=?

    • If the update count is 0, it means another transaction modified the row; Hibernate throws StaleObjectStateException.

Payment entity example:

@Version

@Column(name = COMMON_VERSION)

private Long version;

Pessimistic Locking

  • Acquires DB locks until commit.

  • Example:

@Lock(LockModeType.PESSIMISTIC_WRITE)

Optional<Payment> findById(Long id);

  • Can block and deadlock; use with lock timeouts.

Foreign Keys and Inserts Inside a Transaction

When executing inserts inside a transaction:

  • Referential integrity enforcement: Foreign key constraints are checked by the database, typically at flush time or commit. If the referenced parent row does not exist, a foreign key violation is raised.

  • Insert ordering: When using mapped relationships with cascading, Hibernate automatically orders insert statements so parent entities are inserted before children.

  • ID-only assignments: If you only set foreign key IDs without attaching entity references, Hibernate will not manage insert order — you must ensure the referenced row exists beforehand.

  • Deferred constraints: Some databases support deferring foreign key checks until commit; unless configured, expect immediate validation.

  • Atomicity: Within a single transaction, inserting parent and child entities together ensures both are committed or rolled back as a unit.


Example: Relationship-aware insert

@Entity

class Customer {

    @Id @GeneratedValue

    private Long id;

    private String name;

    // getters/setters

}


@Entity

class Order {

    @Id @GeneratedValue

    private Long id;

    @ManyToOne(optional = false)

    @JoinColumn(name = "customer_id")

    private Customer customer;

    private String product;

    // getters/setters

}


@Service

public class OrderService {

    @Transactional

    public void createCustomerAndOrder() {

        Customer c = new Customer();

        c.setName("Alice");

        entityManager.persist(c);


        Order o = new Order();

        o.setCustomer(c); // ensures FK is valid

        o.setProduct("Laptop");

        entityManager.persist(o);

    }

}

In the above, Hibernate will insert Customer first, then Order, satisfying the FK constraint before commit.


Example: ID-only assignment

java

Order order = new Order(); order.setCustomerId(123L); // Just a raw ID, no entity relationship order.setAmount(100); entityManager.persist(order); // Hibernate inserts Order immediately. // If Customer with ID 123 doesn’t exist, foreign key violation at flush/commit.

Connection Pool Implications

  • One active transaction = one JDBC connection.

  • Long transactions or many REQUIRES_NEW calls can exhaust HikariCP.


Sample HikariCP Configuration in Spring Boot

Spring Boot uses HikariCP as the default connection pool. You can configure it in application.yml:

yaml


spring:

  datasource:

    url: jdbc:postgresql://localhost:5432/mydb

    username: myuser

    password: secret

    hikari:

      maximum-pool-size: 20         # Max concurrent connections

      minimum-idle: 5               # Idle connections kept ready

      connection-timeout: 30000     # Wait time (ms) for a free connection

      idle-timeout: 600000          # Max idle time (ms) before connection is released

      max-lifetime: 1700000         # Lifetime (ms) before connection is retired



Key notes:

  • maximum-pool-size: Limit this based on your database’s capacity.

  • connection-timeout: Fail fast if the pool is exhausted.

  • max-lifetime: Keep slightly shorter than the database’s connection timeout to avoid abrupt disconnects.

  • Match pool size to expected concurrent DB transactions, not thread pool size (especially when using virtual threads).


Virtual Threads (JDK 21)

  • Virtual threads allow high concurrency, but DB concurrency is still capped by maximum-pool-size.

  • Excess tasks wait for connections.

Example: Async Transactions with saveAsynch


@Async("appTaskExecutor")

public CompletableFuture<Void> saveAsynch(...) {

    contractService.findAllByContractNumbers(...); // @Transactional(readOnly = true)

    paymentService.saveTrackedPayments(...); // @Transactional

}

  • @Async starts a new thread; no transaction is inherited.

  • Each service method must have its own @Transactional.

Best Practices

  • Service-layer boundaries.

  • Short transactions.

  • Use readOnly=true when possible.

  • Handle optimistic lock exceptions with retries.

  • Avoid @Transactional on private methods.

  • Use REQUIRES_NEW sparingly due to its impact on consistency and connection usage.

Key Takeaways

  • One transaction = one JDBC connection.

  • Hibernate checks optimistic lock via UPDATE row count, not an extra SELECT.

  • Isolation + locking define data consistency/concurrency.

  • Async DB work needs explicit transactions.

  • Virtual threads don’t remove pool limits.

  • Foreign key constraints are enforced within the transaction according to DB rules, affecting insert order and commit behavior.


No comments:

Post a Comment