@Transactional Basics

Wrap a unit of work in one database transaction with @Transactional, and learn the proxy rules that decide whether it applies at all.

Syntax@Transactional public Result operation(Args args) { /* reads and writes commit together */ } @Transactional(timeout = 10, isolation = Isolation.READ_COMMITTED)

A transaction makes several database changes succeed or fail together. Moving money between two accounts is the classic case: the withdrawal and the deposit must both be written, or neither. In Spring you ask for this by putting @Transactional (from org.springframework.transaction.annotation) on a method of a Spring bean.

Spring Boot already configures transaction management when a transaction manager is present. With spring-boot-starter-data-jpa that is a JpaTransactionManager, so you do not need @EnableTransactionManagement.

What actually happens

Spring wraps the bean in a proxy. When a caller invokes an annotated method through the proxy, it opens a transaction, runs your method, then commits if the method returns normally or rolls back if it throws an unchecked exception. Inside the method every repository call uses the same connection and the same JPA persistence context, so entities you load are managed: change a field and Hibernate writes the UPDATE at commit, without a save() call.

@Transactional
public void transfer(long fromId, long toId, BigDecimal amount) {
    Account from = accounts.findById(fromId).orElseThrow();
    Account to = accounts.findById(toId).orElseThrow();
    from.withdraw(amount);
    to.deposit(amount);
}

The proxy rules people trip over

  • Self-invocation skips the proxy. Calling this.transfer(...) from another method of the same class goes straight to the object, so no transaction starts. Move the transactional method to another bean, or put the annotation on the public entry point.
  • Private and final methods are never intercepted. Annotate the method the outside world calls.
  • Checked exceptions commit by default. Only RuntimeException and Error roll back unless you add rollback rules.
  • Catching the exception inside the method hides it from the proxy, so the transaction commits whatever was done before the failure.

Where it belongs

Put @Transactional on service methods that represent one business operation, not on controllers. A controller method that calls two services should not hold a database connection while it serialises JSON. Spring Data repository methods are already transactional on their own, which is why a single save() works without any annotation, but two repository calls from a non-transactional service run in two separate transactions.

Annotating the class applies its settings to every method the proxy can intercept, which with Spring Boot's class-based proxies means every non-private, non-final method, not only public ones; a method-level annotation overrides it. Keep transactions short: a transaction that calls a slow remote API holds a pooled connection and row locks for the whole call, and under load the pool runs dry.

Turn off open session in view with spring.jpa.open-in-view=false. It is on by default and keeps the persistence context open for the whole web request, which hides lazy-loading mistakes until production traffic exposes them as extra queries.

Example

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.math.BigDecimal;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Entity
class Account {
    @Id
    private Long id;
    private BigDecimal balance;

    protected Account() {
    }

    void withdraw(BigDecimal amount) {
        if (balance.compareTo(amount) < 0) {
            throw new InsufficientFundsException(id);
        }
        balance = balance.subtract(amount);
    }

    void deposit(BigDecimal amount) {
        balance = balance.add(amount);
    }
}

class InsufficientFundsException extends RuntimeException {
    InsufficientFundsException(Long accountId) {
        super("Account " + accountId + " has insufficient funds");
    }
}

interface AccountRepository extends JpaRepository<Account, Long> {
}

@Service
public class TransferService {

    private final AccountRepository accounts;

    TransferService(AccountRepository accounts) {
        this.accounts = accounts;
    }

    @Transactional
    public void transfer(long fromId, long toId, BigDecimal amount) {
        Account from = accounts.findById(fromId).orElseThrow();
        Account to = accounts.findById(toId).orElseThrow();
        from.withdraw(amount);  // throws InsufficientFundsException: nothing is written
        to.deposit(amount);
        // No save() needed: both accounts are managed and Hibernate flushes them at commit.
    }
}

When to use it

  • Debit one account and credit another so that a failure half way leaves both balances unchanged.
  • Create a user and their default settings rows in one step, so a signup never leaves an orphaned user.
  • Find out why an update was lost: the method was called from inside its own class and ran without a transaction.

More examples

Self-invocation and how to fix it

The broken version of this code has <code>writeBatch</code> as a method of <code>ImportService</code> itself, so <code>importAll</code> calls it on <code>this</code> and Spring never sees the call. Moving the transactional method into a second bean is the plainest fix and also makes the unit of work obvious to the next reader. Each batch now commits or rolls back on its own, so a bad row in batch 40 does not undo batches 1 to 39.

Example · java
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
class ImportWriter {

    @Transactional
    public void writeBatch(java.util.List<String> rows) {
        // inserts for one batch; rolled back together if any row fails
    }
}

@Service
public class ImportService {

    private final ImportWriter writer;

    ImportService(ImportWriter writer) {
        this.writer = writer;
    }

    public void importAll(java.util.List<java.util.List<String>> batches) {
        for (var batch : batches) {
            // Goes through ImportWriter's proxy, so each batch gets its own transaction.
            writer.writeBatch(batch);
            // Calling a @Transactional method on 'this' here would bypass the proxy
            // and run without any transaction at all.
        }
    }
}

Programmatic transactions with TransactionTemplate

<code>TransactionTemplate</code> draws the transaction boundary in code instead of on a method. Use it when only part of a method should be transactional, or when the boundary depends on runtime data. It has none of the proxy pitfalls because nothing is intercepted: the lambda runs inside the transaction and an unchecked exception from it rolls everything back. <code>executeWithoutResult</code> is the variant for blocks that return nothing.

Example · java
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;

@Service
public class SignupService {

    private final TransactionTemplate tx;
    private final JdbcClient jdbc;

    SignupService(PlatformTransactionManager txManager, JdbcClient jdbc) {
        this.tx = new TransactionTemplate(txManager);
        this.tx.setTimeout(5);
        this.jdbc = jdbc;
    }

    public long register(String email, String plan) {
        // Validation and other slow work stays outside the transaction.
        String normalised = email.trim().toLowerCase();

        Long userId = tx.execute(status -> {
            long id = jdbc.sql("insert into users(email) values (:email) returning id")
                    .param("email", normalised)
                    .query(Long.class)
                    .single();
            jdbc.sql("insert into subscriptions(user_id, plan) values (:id, :plan)")
                    .param("id", id)
                    .param("plan", plan)
                    .update();
            return id;
        });
        return userId;
    }
}

Configuration worth setting on day one

Setting <code>spring.jpa.open-in-view</code> explicitly also silences the warning Spring Boot logs at startup when it is left at its default. The default timeout stops a stuck transaction holding locks forever. The debug logger prints a line for every transaction it creates or joins, which is the quickest way to prove whether a method actually runs in one.

Example · properties
# Do not keep the persistence context open for the whole web request.
spring.jpa.open-in-view=false

# Upper bound for any transaction that does not set its own timeout.
spring.transaction.default-timeout=30s

# Log when transactions start, join, commit and roll back (development only).
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG

Discussion

  • Be the first to comment on this lesson.