Spring Transaction Management - Complete Notes

A practical and interview-focused guide to local database transactions, Spring transaction abstractions, declarative boundaries, propagation, isolation, rollback rules, proxies, savepoints, testing, connection pools, external side effects, and production troubleshooting.

00. The transaction mental model

A transaction is one business state change with one database outcome: every required database operation commits, or the database rolls the operations back.

Imagine transferring money between two accounts. Debiting one account without crediting the other is not a partial success. Both updates belong inside one transaction because the business invariant is that money is neither created nor lost. The transaction boundary should surround the complete invariant, not each individual SQL statement.

A transaction is like editing a document in a private draft. Other users continue to see an allowed committed version while you work. Commit publishes the complete draft. Rollback throws the draft away. The exact visibility rules depend on the database isolation level.

Spring does not replace the database transaction engine. PostgreSQL, a JDBC driver, or another resource manager still performs begin, commit, rollback, locking, and isolation. Spring supplies a consistent boundary model, chooses the appropriate transaction manager, binds resources to the execution context, and applies commit or rollback rules around application methods.

The most useful design rule

Place the transaction at the service operation that represents one business use case. Repository methods perform persistence work, but a service normally knows which operations must succeed together.

01. ACID and local transactions

ACID describes guarantees of a transactional resource, not a promise that every distributed side effect is magically included.

Property Meaning Engineering consequence
Atomicity The transaction commits as a unit or rolls back as a unit. Keep all database changes for one invariant in the same boundary.
Consistency A successful transaction preserves declared and application invariants. Use constraints as the final guard, not service validation alone.
Isolation Concurrent work observes results allowed by the selected isolation level. Choose locking, optimistic checks, or retries for sensitive workflows.
Durability A committed result survives failures according to resource guarantees. Do not report success before commit has completed.

A local transaction coordinates one transactional resource, commonly one relational database. Updating PostgreSQL and calling a payment provider are not one local transaction. A database rollback cannot unsend an HTTP request, email, Kafka message, or object-store write. Distributed work needs explicit patterns discussed later.

JDBC autocommit

A JDBC connection normally starts with autocommit enabled, so each statement becomes its own transaction. A Spring transaction manager temporarily configures and controls the connection for the surrounding boundary. Code using Spring-aware JDBC or JPA infrastructure receives the transaction-bound resource, and Spring restores and releases it afterward.

02. Spring transaction abstractions

Spring separates transaction policy from the technology that implements it.

Type Responsibility
TransactionDefinition Describes propagation, isolation, timeout, read-only status, and name.
TransactionStatus Represents the current transaction and supports rollback-only or savepoint operations.
PlatformTransactionManager Begins, commits, and rolls back imperative transactions.
TransactionTemplate Runs imperative callbacks with programmatic transaction policy.
ReactiveTransactionManager Manages reactive transactions through Reactor context instead of a thread local.
TransactionalOperator Applies programmatic boundaries to reactive publishers.

Common implementations include DataSourceTransactionManager for a JDBC DataSource, JdbcTransactionManager with JDBC exception translation, JpaTransactionManager for JPA, and JtaTransactionManager when a real JTA environment coordinates supported resources. Spring Boot usually auto-configures the manager implied by the available persistence infrastructure.

One abstraction does not mean identical capability

Nested savepoints, isolation choices, suspension, timeouts, and read-only enforcement depend on the transaction manager, driver, and database. Always verify the behavior of the actual stack.

03. Declarative transactions with @Transactional

Declarative management describes transaction policy as metadata while an interceptor performs the boundary work.

A service-level write boundary
@Service
public class TransferService {
    private final AccountRepository accounts;
    private final TransferRepository transfers;

    public TransferService(
            AccountRepository accounts,
            TransferRepository transfers) {
        this.accounts = accounts;
        this.transfers = transfers;
    }

    @Transactional
    public UUID transfer(UUID fromId, UUID toId, BigDecimal amount) {
        Account from = accounts.findForUpdate(fromId)
                .orElseThrow(AccountNotFoundException::new);
        Account to = accounts.findForUpdate(toId)
                .orElseThrow(AccountNotFoundException::new);

        from.debit(amount);
        to.credit(amount);

        Transfer transfer = Transfer.completed(fromId, toId, amount);
        transfers.save(transfer);
        return transfer.id();
    }
}

With proxy-based transaction management, the call enters a Spring proxy. The transaction interceptor resolves the method metadata, asks the selected manager for a transaction, calls the target, then commits or rolls back. The connection or persistence context is usually bound to the current thread for imperative code.

Default @Transactional behavior is REQUIRED propagation, database default isolation, read-write mode, the manager's default timeout, rollback for RuntimeException and Error, and commit for checked exceptions unless a rule says otherwise.

Explicit policy where the use case needs it
@Transactional(
        propagation = Propagation.REQUIRED,
        isolation = Isolation.READ_COMMITTED,
        timeout = 10,
        rollbackFor = PaymentFileException.class)
public Receipt importPaymentFile(Path file) throws PaymentFileException {
    // Parse before the boundary when possible, then persist atomically.
}

Where to place the annotation

  • Prefer public service methods that represent complete application use cases.
  • Use class-level metadata for a sensible default and method-level metadata for exceptions.
  • Avoid placing every repository call in its own independent boundary.
  • Do not put remote calls, user interaction, or slow file processing inside a transaction.
  • Keep domain decisions inside the boundary when they depend on locked or freshly read data.

04. Proxy behavior and self-invocation

In the normal proxy mode, transaction advice runs only when a call crosses the proxy.

A self-invocation bug
@Service
class InvoiceService {

    public void importAll(List<InvoiceCommand> commands) {
        for (InvoiceCommand command : commands) {
            saveOne(command); // direct call on this, no proxy interception
        }
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveOne(InvoiceCommand command) {
        // The annotation is not applied through this direct call.
    }
}

Refactor the transactional operation into another injected bean, make the outer method the correct boundary, or use TransactionTemplate for a loop that intentionally needs independent transactions. Self-injecting a proxy obscures the design and creates circular dependencies. AspectJ weaving can advise self-invocation, but it introduces a different build and runtime model.

Private methods are not useful proxy entry points. Interface-based JDK proxies expose interface methods. Class-based proxies subclass the target and cannot override final methods. Spring Boot commonly uses class-based proxies, but code should not depend on an annotation attached to a call path the proxy cannot intercept.

Thread boundaries also matter

Imperative transaction context is normally thread-bound. Starting a new thread, using @Async, or dispatching work to an executor does not carry the original database transaction into that thread. Give asynchronous work its own explicit boundary.

05. Propagation modes

Propagation answers: what should this method do when its caller already has a transaction?

Mode When a transaction exists When none exists Typical use
REQUIRED Join it. Create one. Default business write.
REQUIRES_NEW Suspend it and create an independent transaction. Create one. Rare independent unit with carefully sized pool.
SUPPORTS Participate. Run without one. Operation that can use either context.
MANDATORY Participate. Throw an exception. Enforce that a higher layer owns the boundary.
NOT_SUPPORTED Suspend it. Run without one. Work that must not hold database resources.
NEVER Throw an exception. Run without one. Assert that a call is non-transactional.
NESTED Create a savepoint in one physical transaction when supported. Behave like REQUIRED. Partial rollback with JDBC savepoint support.

REQUIRED and rollback-only

Each advised REQUIRED method has a logical transaction scope, but joined scopes map to the same physical database transaction. If an inner scope marks that transaction rollback-only and the outer scope later tries to commit, Spring throws UnexpectedRollbackException. This prevents the outer caller from believing a commit happened.

REQUIRES_NEW and connection pressure

REQUIRES_NEW needs an independent physical transaction. The outer transaction keeps its connection while the inner transaction requests another. Under concurrency, a pool sized only for worker threads can be exhausted: every thread holds one connection and waits for a second. Use the mode sparingly, size from measured concurrency, and consider whether the supposed independent work belongs in an outbox instead.

NESTED is not a second commit

NESTED normally maps to a JDBC savepoint. Rolling back the inner scope can undo work after the savepoint while the outer transaction continues. There is still one physical transaction and one final commit. If the outer transaction rolls back, every nested change is lost. Support depends on the manager and resource, so do not assume JPA behaves like direct JDBC.

06. Isolation levels and anomalies

Isolation controls which concurrent outcomes a transaction may observe. Stronger isolation is not a substitute for understanding invariants and retries.

Spring level Main idea PostgreSQL note
DEFAULT Use the database or connection default. Normally READ COMMITTED.
READ_UNCOMMITTED SQL level that may permit dirty reads. Treated as READ COMMITTED.
READ_COMMITTED Each statement sees a snapshot of committed rows at statement start. Non-repeatable observations can occur across statements.
REPEATABLE_READ Use a stable transaction snapshot. Prevents dirty, non-repeatable, and phantom reads but not every serialization anomaly.
SERIALIZABLE Require an outcome equivalent to some serial execution. May abort with a serialization failure, so retry the complete transaction.

A dirty read observes uncommitted data. A non-repeatable read gets different committed values for the same row. A phantom changes the set of rows matching a predicate. A lost update overwrites another result. Write skew occurs when separate rows are changed after reads that jointly enforce an invariant. Standard isolation labels describe minimum phenomena, while real database behavior is vendor-specific.

Isolation applies when a new physical transaction starts

An inner REQUIRED method joins the outer transaction. Its isolation declaration cannot replace characteristics already chosen by the outer boundary. Put the required policy on the use-case entry point.

Safe serialization and deadlock retries

Retry the complete business transaction after a serialization failure or deadlock, not only the final SQL statement. Use a small bounded attempt count, exponential backoff with jitter, and idempotency protections for externally retried requests. Do not retry constraint violations, authentication failures, or arbitrary bugs.

07. Read-only transactions and timeouts

Read-only is a policy hint and possible optimization, not a universal security boundary.

A query-oriented service
@Transactional(readOnly = true)
public OrderSummary getOrder(UUID id) {
    return orders.findSummary(id)
            .orElseThrow(OrderNotFoundException::new);
}

Depending on the manager and provider, read-only may set a JDBC flag, request a read-only database transaction, or optimize persistence-context flushing. It should communicate intent and reduce accidental work, but database privileges remain the reliable authorization control.

A transaction timeout limits how long transactional work may run when the manager and resource support it. It is distinct from connection-acquisition timeout, JDBC query timeout, lock timeout, HTTP timeout, and request timeout. Production systems often need several coordinated limits. Ensure the outer request budget is larger than the database budget plus cleanup time.

08. Rollback rules and exception design

By default, unchecked exceptions and errors trigger rollback; checked exceptions do not.

Declaring a checked rollback rule
@Transactional(rollbackFor = InventoryImportException.class)
public ImportResult importInventory(InputStream source)
        throws InventoryImportException {
    // Any escaped InventoryImportException marks this transaction for rollback.
}

rollbackFor and noRollbackFor accept exception classes and are safer than name-pattern rules. Pattern matching can unintentionally match similarly named exceptions. Keep rules close to a deliberate business decision, and document why a failure should still commit.

Catching an exception changes what the interceptor sees

If application code catches an exception and returns normally, the transaction interceptor cannot infer that rollback is required. Rethrow an appropriate exception, or explicitly call TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() when a rare design truly requires it. Prefer exception-driven rollback because it keeps business code less coupled to Spring.

Once participating work marks a transaction rollback-only, catching the exception does not restore the transaction. Continuing to write can waste work and end in UnexpectedRollbackException. Treat rollback-only as a terminal outcome for that physical transaction.

09. Programmatic transaction management

Use TransactionTemplate when the boundary must be expressed inside an algorithm rather than around a whole method.

Independent batches with a short transaction each
@Component
class ImportCoordinator {
    private final TransactionTemplate transactions;
    private final InvoiceRepository invoices;

    ImportCoordinator(
            PlatformTransactionManager manager,
            InvoiceRepository invoices) {
        this.transactions = new TransactionTemplate(manager);
        this.transactions.setTimeout(15);
        this.invoices = invoices;
    }

    void importBatches(List<List<InvoiceRow>> batches) {
        for (List<InvoiceRow> batch : batches) {
            transactions.executeWithoutResult(status -> {
                invoices.upsertAll(batch);
            });
        }
    }
}

This makes the per-batch boundary explicit without relying on self-invocation. A template is thread-safe for shared use when its configuration is not mutated. Use separate templates for distinct policies. Declarative management remains clearer for most service methods.

10. Long transactions and pool effects

A transaction holds scarce resources and can retain locks or old row versions even while the application is doing no database work.

  • A checked-out connection is unavailable to other requests.
  • Locks can block writers and increase deadlock risk.
  • PostgreSQL snapshots can delay removal of dead tuples and contribute to bloat.
  • A large persistence context consumes memory and makes dirty checking expensive.
  • Failure near the end discards more work and increases retry cost.

Do not open a transaction before parsing a large file, waiting for user input, making an HTTP request, sleeping, or performing CPU-heavy transformation. Prepare data first, then open the shortest boundary that can safely validate current state and write results. For large imports, choose bounded chunks only when partial commit semantics are acceptable.

Connection-pool reasoning

Pool size is not an arbitrary throughput knob. It must reflect database capacity, query latency, concurrent request demand, and the number of connections one call can hold. Instrument active, idle, pending, acquisition time, and transaction duration. A huge pool can move queuing into the database and make latency worse.

11. Transactions, locking, and lost updates

A transaction boundary provides atomicity, but it does not automatically protect every read-modify-write decision from races.

Use a database constraint when the invariant can be expressed declaratively. Use optimistic locking with a version column when collisions are unusual and retry or conflict reporting is acceptable. Use SELECT ... FOR UPDATE or pessimistic JPA locking when a row must be reserved before a decision. Use serializable isolation for multi-row predicate invariants when the workload can handle complete transaction retries.

Optimistic update concept
UPDATE product
SET stock = stock - :quantity,
    version = version + 1
WHERE id = :id
  AND version = :expectedVersion
  AND stock >= :quantity;

Check the affected row count. Zero means the state changed or the invariant failed. Never assume that wrapping an unsafe read followed by an unconditional write in @Transactional alone prevents a lost update.

12. External side effects and the outbox pattern

A database transaction cannot atomically roll back an ordinary network call.

The unsafe sequence "commit order, then publish message" can lose the message if the process crashes after commit. The reverse sequence can publish a message for an order that later rolls back. The transactional outbox solves this by writing both the business row and an outbox row in the same local database transaction.

  1. Handle an idempotent request inside one database transaction.
  2. Write the domain state.
  3. Write an outbox record containing a stable event ID and payload.
  4. Commit both rows atomically.
  5. A separate publisher reads committed outbox rows and sends them.
  6. Mark delivery progress and retry failures.
  7. Consumers deduplicate by event ID because delivery can be repeated.

This provides atomic state plus eventual publication, not magical exactly-once delivery. For a payment API, use an idempotency key and model a saga or compensating action. A compensating action is a new business operation, not a rollback in time.

Transactional events

@TransactionalEventListener can bind an in-process listener to BEFORE_COMMIT, AFTER_COMMIT, AFTER_ROLLBACK, or AFTER_COMPLETION. The default is after commit. If no transaction exists, the listener is skipped unless fallback execution is enabled. Use this for local coordination, not as a durable replacement for an outbox. A process crash can still lose an after-commit action.

13. Testing transaction behavior

Test the committed database outcome, not only the in-memory state inside a test transaction.

Spring test-managed @Transactional tests normally roll back at the end. This keeps tests isolated, but it can hide failures that occur only at flush or commit. Force a flush when verifying constraints, or end the test transaction with TestTransaction and inspect state afterward.

Testing a rollback boundary
@SpringBootTest
class TransferServiceTest {
    @Autowired TransferService service;
    @Autowired JdbcTemplate jdbc;

    @Test
    void failedCreditRollsBackDebit() {
        assertThrows(AccountNotFoundException.class,
                () -> service.transfer(FROM, MISSING, TEN));

        BigDecimal balance = jdbc.queryForObject(
                "select balance from account where id = ?",
                BigDecimal.class,
                FROM);

        assertEquals(STARTING_BALANCE, balance);
    }
}
  • Use a real supported database with Testcontainers for isolation and locking behavior.
  • Run concurrent tests with separate threads and connections for race conditions.
  • Verify rollback rules for checked and unchecked exceptions.
  • Test serialization retries and deadlock handling with bounded attempts.
  • Verify outbox and domain rows appear in the same commit.
  • Avoid preemptive test timeouts that move work to another thread outside the test transaction.

14. Production practices

Good transaction management is visible, bounded, measurable, and aligned with business invariants.

  • Use one clear service boundary for each command or use case.
  • Keep transactions short and remove remote calls from the boundary.
  • Set realistic lock, statement, and transaction timeouts.
  • Order row locks consistently across code paths to reduce deadlocks.
  • Use constraints, idempotency keys, and version checks as durable guards.
  • Record transaction latency, database time, pool wait, retries, and rollback categories.
  • Log a correlation ID and operation name, but never sensitive SQL parameters.
  • Alert on long-running transactions, pool saturation, deadlocks, and serialization failures.
  • Retry only known transient conflicts and cap the retry budget.
  • Document intentional uses of REQUIRES_NEW and NESTED.

Multiple transaction managers

An application can define multiple managers and select one with @Transactional(transactionManager = "ordersTransactionManager"). This does not make two independent databases atomic. If one method talks to both under separate local managers, one may commit while the other fails. Use a carefully justified distributed transaction system or, more commonly, local transactions plus durable messaging and reconciliation.

15. Troubleshooting guide

Symptom Likely cause What to inspect
Annotation appears ignored Self-invocation, non-managed object, or non-interceptable method. Bean creation, call path, proxy type, method visibility.
UnexpectedRollbackException Joined inner work marked the physical transaction rollback-only. Earlier exception and caught failure in the same call chain.
Pool timeouts under load Long boundaries, slow SQL, leaks, or nested REQUIRES_NEW. Active connections, pending count, acquisition and transaction time.
Data remains after a failed operation Exception was caught, checked exception committed, or work used another transaction. Rollback rules, propagation, thread changes, manager selection.
Constraint error appears after method logic JPA flush occurred during commit. Explicit flush point and exception translation.
Test passes but production commit fails Rollback-only test never exercised a real commit. Flush, commit-aware integration test, real database behavior.
Messages missing or duplicated Non-atomic publication or at-least-once delivery. Outbox persistence, publisher retries, consumer deduplication.

16. Common mistakes

  1. Annotating private helpers: proxy advice never sees the direct call.
  2. One transaction per repository call: the business operation loses atomicity.
  3. One transaction for a huge batch: locks, memory, snapshots, and retry cost grow.
  4. Catching every exception: failures can commit or end as unexpected rollback.
  5. Using REQUIRES_NEW for logging: it consumes another connection and may deadlock the pool.
  6. Assuming read-only forbids writes: enforcement depends on the stack.
  7. Making remote calls inside the boundary: locks and connections wait on the network.
  8. Retrying any exception: permanent failures create load and duplicate effects.
  9. Confusing nested with independent: a savepoint does not survive outer rollback.
  10. Testing only under automatic rollback: commit-time behavior remains untested.

17. Worked order design

Consider an API that reserves stock, creates an order, and asks a broker to publish OrderPlaced.

  1. Validate request shape before starting a transaction.
  2. Enter an idempotent placeOrder service boundary.
  3. Insert or find the request's idempotency record.
  4. Reserve stock with a guarded update or explicit lock.
  5. Insert the order and lines.
  6. Insert the outbox event with a stable ID.
  7. Commit and return the created resource.
  8. Publish the outbox record independently with retry.
  9. Let consumers deduplicate the event ID.

A unique constraint on the idempotency key handles concurrent duplicates. A guarded stock update prevents overselling. The local transaction makes the order, reservation, and outbox atomic. The publisher does not hold an order transaction open while waiting for the broker.

18. Practice exercises

  1. Explain why two repository methods with separate autocommit statements can violate an invariant.
  2. Build a transfer service and prove that the debit rolls back when the credit fails.
  3. Reproduce self-invocation with REQUIRES_NEW, then refactor it safely.
  4. Demonstrate UnexpectedRollbackException with joined REQUIRED scopes.
  5. Compare REQUIRES_NEW with NESTED using commit and rollback cases.
  6. Run two PostgreSQL transactions that produce a serialization failure and add a bounded retry.
  7. Measure connection-pool behavior when an outer transaction calls an inner new transaction.
  8. Write a commit-aware integration test using Testcontainers.
  9. Implement an order plus outbox write and an idempotent publisher.
  10. Choose transaction boundaries for a large file import and justify the partial failure semantics.

19. Interview questions and answers

What does @Transactional actually do?

It supplies metadata to Spring transaction advice. Normally a proxy intercepts an external method call, resolves the policy, obtains or joins a transaction through a manager, invokes the target, and commits or rolls back according to the outcome.

Why does self-invocation break transactional behavior?

A call from one method to another on the same object does not cross the proxy. Proxy-based advice therefore does not run for the inner method. Move the boundary, delegate to another bean, or use a programmatic template.

Where should a transaction boundary live?

Usually at the service method representing one complete business use case. That layer understands which reads and writes preserve the invariant together.

What is the difference between REQUIRED and REQUIRES_NEW?

REQUIRED joins an existing transaction or creates one. REQUIRES_NEW suspends an existing transaction and starts an independent physical transaction, normally using another connection.

What is the difference between REQUIRES_NEW and NESTED?

A new transaction can commit independently. A nested scope normally uses a savepoint inside the same physical transaction, so its work is still lost if the outer transaction rolls back.

Why can UnexpectedRollbackException occur?

Inner joined work marked the shared physical transaction rollback-only. The outer scope attempted to commit without realizing that decision, so Spring reports that no commit occurred.

Which exceptions cause rollback by default?

Escaped RuntimeException and Error types. Checked exceptions commit by default unless configured with a rollback rule or a changed global policy.

Does readOnly = true guarantee that no write occurs?

No. It expresses intent and can enable driver, database, or ORM behavior, but exact enforcement is stack-specific. Use database privileges and constraints for security and integrity.

Why are long transactions harmful?

They hold connections and locks, retain snapshots and row versions, grow persistence contexts, amplify failure cost, and increase contention. Keep remote and CPU-heavy work outside.

How should serialization failures be retried?

Retry the complete transaction with a bounded count and jittered backoff. External effects must be idempotent or deferred through a durable pattern.

Can one @Transactional method atomically update two databases?

Not with two unrelated local transaction managers. Atomic coordination requires an appropriate distributed transaction environment, or the design should use local commits, durable messaging, idempotency, compensation, and reconciliation.

How do you publish an event reliably after a database update?

Write the domain state and an outbox row in one local transaction. A separate publisher delivers committed outbox records with retries, and consumers deduplicate stable event IDs.

20. Concise cheat sheet

  • Boundary follows one business invariant.
  • Spring coordinates; the database provides actual transaction guarantees.
  • Default policy is REQUIRED, DEFAULT isolation, read-write, unchecked rollback.
  • Proxy mode needs an external call through a Spring-managed proxy.
  • Self-invocation and new threads bypass the original advice or context.
  • REQUIRED joins; REQUIRES_NEW is independent; NESTED usually means savepoint.
  • REQUIRES_NEW can require a second pool connection.
  • Isolation behavior is database-specific; PostgreSQL maps READ UNCOMMITTED to READ COMMITTED.
  • Retry complete transactions only for known transient concurrency failures.
  • Read-only is intent and possible optimization, not universal write security.
  • Keep transactions short and keep network calls outside.
  • Use constraints, locking, or versions to protect concurrent invariants.
  • Use an outbox for atomic database state plus durable event publication.
  • Test flush and commit behavior against the real database engine.

21. Official references

Last reviewed · July 2026 · part of knowledge-base