Transactions: @Transactional, Isolation & Propagation

A month after our checkout service launched, support forwarded a ticket that read like a riddle: "I was charged, but my order never shipped." The payment gateway showed a captured charge for $149.99. Our database showed an order row stuck in PENDING — with inventory reserved for an order we would have to cancel and refund by hand. The money was real; the database disagreed with the gateway about what had happened.

The cause was a method that did three writes — save the order, reserve inventory, charge the card — with no transaction around them. The charge succeeded, a downstream call threw, and each write had already committed on its own. The fix wasn't better error handling. It was declaring the whole method one atomic unit of work. That declaration is @Transactional — and this post is about what that annotation actually does, the proxy that makes it work, and the three ways it surprises people in production.

ACID in one paragraph

A transaction is a contract with four clauses, remembered as ACID. Atomicity: the whole unit of work commits, or nothing does — no half-written checkouts. Consistency: the database moves from one valid state to another, every constraint still holding. Isolation: concurrent transactions don't see each other's half-finished work. Durability: once committed, the data survives a crash. @Transactional is how you ask Spring for that contract around a method — and the rest of this post is the fine print.

The worked example: a checkout that must not half-succeed

Here is the checkout from the opening story, rebuilt the right way. Two entities, two repositories (the repository mechanics are covered in this track's "Spring Data JPA: Entities, Repositories & Relationships" post — here we just use them), and one service method that owns the whole unit of work:

package com.javamakeuse.tx;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String customerEmail;
    private BigDecimal totalAmount;
    private String status;

    protected Order() { }

    public Order(String customerEmail, BigDecimal totalAmount) {
        this.customerEmail = customerEmail;
        this.totalAmount = totalAmount;
        this.status = "PENDING";
    }

    public Long getId() { return id; }
    public String getCustomerEmail() { return customerEmail; }
    public BigDecimal getTotalAmount() { return totalAmount; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
}
package com.javamakeuse.tx;

import java.math.BigDecimal;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CheckoutService {

    private final OrderRepository orders;
    private final InventoryService inventory;
    private final PaymentService payments;
    private final AuditService audit;

    public CheckoutService(OrderRepository orders, InventoryService inventory,
                           PaymentService payments, AuditService audit) {
        this.orders = orders;
        this.inventory = inventory;
        this.payments = payments;
        this.audit = audit;
    }

    @Transactional
    public Order placeOrder(String email, BigDecimal amount) {
        Order order = orders.save(new Order(email, amount));
        inventory.reserve(order);
        payments.charge(order);
        order.setStatus("CONFIRMED");
        audit.log("ORDER_PLACED", "order=" + order.getId());
        return order;
    }
}

With that single annotation, the five steps above become atomic: if payments.charge() throws, the saved order and the inventory reservation are rolled back together. No ghost charges, no orphaned reservations. But how does an annotation do that? It doesn't — a proxy does.

Decision rule: put @Transactional on the service method that owns the whole unit of work — never on repositories (too small: each call would be its own transaction) and never on controllers (too far from the domain logic, and it drags web concerns into transaction boundaries).

The proxy: why the annotation works at all

When Spring sees @Transactional on your bean, it doesn't hand other beans your object. It hands them a proxy — a stand-in that looks like your service, intercepts every call, and wraps it in transaction handling:

Your code checkout .placeOrder(...) Proxy (built by Spring) 1. begin transaction binds a DB connection, marks the unit of work 2. call Target bean CheckoutService your actual code — knows nothing about transactions 3a. success → commit all writes become permanent 3b. exception → rollback every write is undone The annotation is a note on the method. The proxy is the machine that reads it.

Two consequences fall straight out of this picture, and both bite people:

  • Only public methods on Spring beans get proxied. A private method with @Transactional is decoration — no proxy intercepts it, so no transaction starts.
  • Self-invocation bypasses the proxy. When a method inside the bean calls another method of the same bean, the call never leaves the target object — the proxy never sees it. This is the most common @Transactional bug in production code, and it gets its own section below.

Propagation: REQUIRED joins, REQUIRES_NEW suspends

Propagation answers one question: this method runs inside a transaction already — what should it do? The two answers you'll actually use:

  • REQUIRED (the default) — join the existing transaction. Every repository call inside placeOrder above joins the one transaction the proxy started. One commit, one rollback, all or nothing.
  • REQUIRES_NEW — suspend the current transaction and start a brand-new, independent one. It commits or rolls back on its own, regardless of what happens to the outer transaction.

The classic use for REQUIRES_NEW is the audit log. When a checkout fails and rolls back, you still want a record that the attempt happened — for support, for fraud review, for the "charged but nothing shipped" ticket. Here is the audit service from the example above:

package com.javamakeuse.tx;

import java.time.Instant;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AuditService {

    private final AuditRepository audits;

    public AuditService(AuditRepository audits) {
        this.audits = audits;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void log(String action, String detail) {
        audits.save(new AuditLog(action, detail, Instant.now()));
    }
}

Because audit.log() is called on a separate bean, the call crosses that bean's proxy, and the proxy honors REQUIRES_NEW. Watch what happens when the payment fails after the audit row is written (illustrative output — exact log lines vary, the commit/rollback behavior doesn't):

placeOrder threw: PaymentFailedException (a runtime exception)
orders table:     0 rows   ← outer transaction rolled back
audit_log table:  1 row    ← REQUIRES_NEW committed independently
REQUIRED (default) TX-1: save order → audit.log joins → payment throws rolls back one transaction — the audit row dies with the order REQUIRES_NEW TX-1: save order suspended TX-2: audit commits TX-1: resumes… payment throws → rolls back two transactions — the audit row survives the failed checkout REQUIRES_NEW means a separate commit — and a separate failure mode. Use it for audit/outbox rows, not for business writes that must stay atomic.

Decision rule: reach for REQUIRES_NEW only when the inner work must outlive the outer transaction — audit logs, outbox rows, notification records. If the inner write is part of the same business outcome (order + order lines + inventory), it belongs in the same transaction with plain REQUIRED.

Self-invocation: the proxy's blind spot

Now the trap the proxy diagram warned about. A refund flow needs the refund marker committed even if the surrounding reconciliation batch later fails — so someone marks it REQUIRES_NEW. It works in code review. It silently does nothing in production:

package com.javamakeuse.tx;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class RefundService {

    private final OrderRepository orders;
    private final PaymentService payments;

    public RefundService(OrderRepository orders, PaymentService payments) {
        this.orders = orders;
        this.payments = payments;
    }

    @Transactional
    public void refundOrder(Long orderId) {
        Order order = orders.findById(orderId).orElseThrow();
        payments.refund(order);
        // BUG: this is a direct self-call. The proxy is bypassed,
        // so REQUIRES_NEW on markRefunded is silently ignored.
        markRefunded(order);
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void markRefunded(Order order) {
        order.setStatus("REFUNDED");
        orders.save(order);
    }
}

this.markRefunded(order) never leaves the target object, so the proxy never intercepts it, so no new transaction starts — the annotation is decoration. The refund marker joins the outer transaction, and a later rollback wipes it. No exception, no warning, no log line. The fix is structural: move the REQUIRES_NEW method to a separate bean so the call crosses a proxy boundary:

package com.javamakeuse.tx;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class RefundServiceFixed {

    private final OrderRepository orders;
    private final PaymentService payments;
    private final RefundAuditService refundAudit;

    public RefundServiceFixed(OrderRepository orders, PaymentService payments,
                              RefundAuditService refundAudit) {
        this.orders = orders;
        this.payments = payments;
        this.refundAudit = refundAudit;
    }

    @Transactional
    public void refundOrder(Long orderId) {
        Order order = orders.findById(orderId).orElseThrow();
        payments.refund(order);
        // FIX: separate bean → the call crosses the proxy,
        // so REQUIRES_NEW is honored.
        refundAudit.markRefunded(order);
    }
}

(The alternative fix — injecting the bean into itself so the injected reference is the proxy — works but reads like a riddle to the next developer. A separate bean says what it means.)

Decision rule: if a @Transactional method is ever called from inside its own bean, the annotation is silently ignored. When propagation or isolation matters on an inner step, that step belongs on a separate bean.

Isolation levels and the three anomalies

Isolation controls what one transaction may see of another's in-flight work. The SQL standard names four levels; each one forbids more anomalies than the last:

Isolation levelDirty readNon-repeatable readPhantom read
READ_UNCOMMITTEDpossiblepossiblepossible
READ_COMMITTEDpreventedpossiblepossible
REPEATABLE_READpreventedpreventedpossible
SERIALIZABLEpreventedpreventedprevented

What the anomalies look like in our checkout domain:

  • Dirty read — a refund job updates an order's status to REFUNDED but hasn't committed; the admin dashboard reads the uncommitted row and shows "refunded" to the customer; the job then rolls back. The customer saw money that never moved.
  • Non-repeatable read — the nightly reconciliation reads an order total of $149.99, does some work, reads it again, and now it's $129.99 because a discount adjustment committed in between. The report doesn't balance and nobody can reproduce it.
  • Phantom read — an inventory check counts 4 rows for a SKU and decides 4 units are available; a concurrent restock insert commits a 5th row between the count and the deduction; the deduction oversells. The row wasn't updated — a new row appeared, which is why REPEATABLE_READ doesn't prevent it.

Most databases default to READ_COMMITTED (PostgreSQL, Oracle, SQL Server) — dirty reads are already impossible. You raise the level only for the operation that needs it, never globally:

@Transactional(isolation = Isolation.SERIALIZABLE)
public void deduct(String sku, int quantity) {
    // read the stock row, check it, write the new count —
    // SERIALIZABLE forbids phantom inserts mid-flight
}

Decision rule: keep the default isolation unless you can name the anomaly you're preventing. Higher isolation buys correctness with lock contention and serialization failures — a SERIALIZABLE method under load will throw and retry, which is a cost you should choose deliberately, not inherit.

Rollback rules: runtime vs checked exceptions

The last piece of fine print: not every exception rolls back. By default, Spring rolls back on unchecked exceptions (RuntimeException and Error) and commits on checked exceptions. That default surprises everyone exactly once:

Exception typeDefault behavior
RuntimeException (e.g. PaymentFailedException)rolls back
Errorrolls back
Checked Exception (e.g. PaymentDeclinedException)commits — usually not what you want

If your payment gateway client throws a checked exception, opt it into rollback explicitly:

// A checked exception does NOT roll back by default —
// rollbackFor opts it back in.
@Transactional(rollbackFor = PaymentDeclinedException.class)
public Order placeOrderStrict(String email, BigDecimal amount)
        throws PaymentDeclinedException {
    Order order = orders.save(new Order(email, amount));
    payments.chargeStrict(order);   // throws checked PaymentDeclinedException
    order.setStatus("CONFIRMED");
    return order;
}

The mirror attribute, noRollbackFor, opts an unchecked exception out of rollback — useful when the exception is an expected business outcome (say, DuplicateOrderException where you still want the idempotency record committed).

Decision rule: make domain exceptions unchecked (extend RuntimeException) unless a caller genuinely must handle them — then the default does the right thing with no attributes to remember. Reserve rollbackFor for checked exceptions from libraries you don't control.

What's next

The checkout is consistent now — every order is all-or-nothing. But anyone on the internet can call it. The next post in this track, "Spring Security Basics: Auth for REST APIs", puts a lock on the door: the modern SecurityFilterChain bean, BCrypt passwords, and JWT tokens for real APIs.

Field check before you move on: take any service you own that writes an audit or log row inside a business transaction. Give the audit write REQUIRES_NEW on a separate bean, then write a test that throws after the audit call and asserts the audit row survived the rollback. Then — as the real lesson — change the call to a self-invocation and watch the test fail. That failing test is the proxy lesson, executable.

Continue: Java Learning Roadmap 2026

Comments

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number