Skip to content

@Transactional & transaction management

Mid / Senior

How @Transactional works, when changes actually reach the database, rollback rules, propagation, readOnly, and the classic pitfalls.

Created 5 October 2026 · Updated 23 October 2026

12 concepts · detail hidden

  1. 01How @Transactional works under the hood
    1. The bean is wrapped in a proxy (see Proxies & AOP) whose interceptor is TransactionInterceptor.
    2. On call, the interceptor reads the method’s transaction attributes (propagation, isolation, readOnly, timeout, rollback rules) and asks the PlatformTransactionManager to begin or join a transaction — JpaTransactionManager for JPA/Hibernate, DataSourceTransactionManager for plain JDBC.
    3. The transaction manager takes a connection from the pool, calls setAutoCommit(false), and binds the connection / EntityManager to the current thread via TransactionSynchronizationManager (a ThreadLocal). That’s how a repository deep in the call stack finds “the current transaction” without it being passed around.
    4. Your method runs.
    5. On normal return → commit (for JPA: flush the persistence context, then commit the JDBC connection). On an exception matching the rollback rules → rollback. The exception is then rethrown.

    Consequence of step 3: a transaction is bound to one thread. Work handed to @Async, CompletableFuture.supplyAsync, a parallel stream or a new thread does not participate in the caller’s transaction.

    #c-how-transactional-works-under-the-hood
  2. 02`@Transactional` on a class vs on a method — override, not merge

    On the class it applies to every eligible method; a method-level annotation overrides the class-level one entirely (attributes aren’t merged). Common pattern: class-level @Transactional(readOnly = true) with method-level @Transactional on writes.

    #c-transactional-on-a-class-vs-on-a-method-override-not-merge
  3. 03When entities are actually saved — dirty checking and commit

    Inside a transaction, every entity loaded through the EntityManager is managed: the persistence context remembers a snapshot of its state. At flush (by default just before commit) Hibernate compares each managed entity against its snapshot and issues UPDATEs for the changed ones. So you don’t need to call save() on an entity you loaded and modified in the same transaction:

    @Transactional
    public void rename(long id, String name) {
        Customer c = customers.findById(id).orElseThrow();
        c.setName(name);          // no save() — UPDATE is issued at commit by dirty checking
    }
    

    Timing details interviewers like:

    • save()/persist() of a new entity usually only schedules the INSERT; SQL is sent at flush (exception: GenerationType.IDENTITY must insert immediately to learn the id).
    • Flush also happens before a query whose result could depend on pending changes, and when you call flush() / saveAndFlush() explicitly.
    • Because SQL is sent late, constraint violations surface at flush/commit — after your method body has returned but while still inside the proxy, so they land outside your own try/catch. A unique-key violation or optimistic-lock failure then reaches the caller as a DataIntegrityViolationException / ObjectOptimisticLockingFailureException. Call flush() (or use saveAndFlush()) inside the method if you need to surface and handle it there.
    • Without a transaction, Spring Data repository methods run in their own short transactions; an entity you get back is detached once that method returns, and modifying it changes nothing in the database unless you save() (merge) it again.
    #c-when-entities-are-actually-saved-dirty-checking-and-commit
  4. 04Rollback rules — checked vs unchecked exceptions

    By default Spring rolls back on RuntimeException and Error only. A checked exception commits the transaction (an EJB-era convention: checked = “expected business outcome”).

    @Transactional(rollbackFor = Exception.class)          // roll back on checked too
    @Transactional(noRollbackFor = NotificationFailed.class) // commit despite this one
    

    Also:

    • Catching the exception inside the method means the interceptor never sees it → commit. If you catch but still want a rollback, rethrow or call TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().
    • The rule applies at the boundary of the proxied method, not wherever the exception was thrown.
    #c-rollback-rules-checked-vs-unchecked-exceptions
  5. 05UnexpectedRollbackException — the rollback-only trap
    @Transactional
    public void placeOrder() {
        orders.save(order);
        try {
            loyalty.addPoints(customer);  // also @Transactional (REQUIRED) → joins this tx, throws
        } catch (RuntimeException e) {
            log.warn("points failed, continuing");   // we swallow it…
        }
    }   // …commit → UnexpectedRollbackException: Transaction silently rolled back
    

    When addPoints threw, its interceptor saw a RuntimeException and — since it only joined the outer transaction and can’t roll it back alone — marked the shared transaction rollback-only. The outer method then tries to commit, Spring finds the flag, rolls back and throws UnexpectedRollbackException. Fix: run the optional part in REQUIRES_NEW (its own transaction, so its failure doesn’t poison the outer one), make it not throw, or let the whole thing fail.

    #c-unexpectedrollbackexception-the-rollback-only-trap
  6. 06Propagation levels

    What happens when a @Transactional method is called and a transaction may already exist:

    Propagation Existing transaction No transaction
    REQUIRED (default) join it start new
    REQUIRES_NEW suspend it, start an independent one start new
    NESTED savepoint inside it; inner rollback → back to savepoint only start new
    SUPPORTS join it run without
    MANDATORY join it throw
    NOT_SUPPORTED suspend it, run without run without
    NEVER throw run without

    Interview points:

    • REQUIRES_NEW commits independently of the outer transaction — typical for audit logs or “record the failed attempt even if the main work rolls back”. It needs a second connection while the outer one is suspended — under load, nested REQUIRES_NEW calls can exhaust the connection pool and deadlock the application.
    • NESTED uses JDBC savepoints: one physical transaction, partial rollback. Supported by DataSourceTransactionManager; generally not with JPA/Hibernate.
    • Which method commits? With REQUIRED, only the outermost transactional method’s proxy begins and commits; inner methods just join, so nothing is committed when an inner method returns. With REQUIRES_NEW, the inner method commits on its own return.
    #c-propagation-levels
  7. 07Isolation, timeout and readOnly attributes
    • isolation — READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE, … sets the JDBC connection’s isolation level for this transaction; DEFAULT uses the database’s default. Only applied when the transaction starts — an inner method joining an existing transaction can’t change it. (See the isolation subtopic for what each level prevents.)
    • timeout (seconds) — the transaction is rolled back if it runs longer; Spring also applies it as a JDBC query timeout.
    • readOnly = true — a hint with real effects under Hibernate: the session flush mode is set to MANUAL (no flush, no dirty checking at commit) and entities are loaded read-only (no snapshots kept → less memory and CPU). The JDBC connection is marked read-only, which some drivers/databases optimise, and a routing DataSource can use it to send the query to a read replica. It does not reliably prevent writes: modifications to loaded entities are silently not written (no flush, no dirty checking), but an explicit native UPDATE/INSERT may still run, depending on the database and driver. It’s an optimisation hint, not access control — don’t rely on it as a security control.

    A common pattern: @Transactional(readOnly = true) on the service class, overridden with plain @Transactional on the write methods.

    #c-isolation-timeout-and-readonly-attributes
  8. 08@Transactional pitfalls checklist
    1. Self-invocation — this.otherMethod() bypasses the proxy (see Proxies & AOP).
    2. Private / final methods — not intercepted.
    3. Checked exceptions commit unless rollbackFor is set.
    4. Swallowed exceptions — caught inside the method → commit.
    5. New threads / @Async — don’t inherit the transaction.
    6. Wrong annotation — jakarta.transaction.Transactional also works in Spring but lacks readOnly/propagation names; prefer org.springframework.transaction.annotation.Transactional.
    7. Bean not managed by Spring — new MyService() has no proxy.
    8. Long transactions doing remote calls — an HTTP call inside a transaction holds a DB connection and row locks for its whole duration. Do I/O outside, keep transactions short.
    9. Multiple transaction managers — with two data sources you must specify @Transactional("ordersTransactionManager"), otherwise the primary one is used and the other database runs in auto-commit.
    10. @Transactional on interfaces — works with JDK proxies, but annotate the concrete class (or its methods) to be safe with CGLIB.
    #c-transactional-pitfalls-checklist
  9. 09Where `@Transactional` belongs — the service layer, not controller or repository

    Put it on the service layer: a transaction should match one business use case, which typically spans several repository calls. Repository-level transactions (Spring Data’s defaults) are too fine-grained for multi-step operations, and controller-level transactions stretch across HTTP concerns and serialization.

    #c-where-transactional-belongs-the-service-layer-not-controller
  10. 10Doing work after commit — @TransactionalEventListener

    Sending an email or publishing a Kafka message inside the transaction is wrong in both directions: if the transaction later rolls back, the email was sent for an order that doesn’t exist; if you send after the method returns, you’re outside the service boundary.

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderPlaced(OrderPlaced e) { mailer.sendConfirmation(e.orderId()); }
    

    The event is published during the transaction but the listener runs only after a successful commit (other phases: BEFORE_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION). Note that at AFTER_COMMIT the transaction resources are still bound but the transaction is finished — writes in the listener need REQUIRES_NEW. For guaranteed delivery even if the app crashes right after commit, use the transactional outbox pattern (write the event to an outbox table in the same transaction; relay it asynchronously).

    #c-doing-work-after-commit-transactionaleventlistener
  11. 11Programmatic transactions — TransactionTemplate

    When you need a transaction boundary narrower than a method, inside a loop, or inside the same class (avoiding self-invocation):

    transactionTemplate.executeWithoutResult(status -> {
        repo.save(entity);
        if (invalid) status.setRollbackOnly();
    });
    

    TransactionTemplate uses the same PlatformTransactionManager and the same propagation / isolation settings, just without a proxy.

    #c-programmatic-transactions-transactiontemplate
  12. 12@Transactional in tests

    In Spring tests (@DataJpaTest is itself @Transactional; or @Transactional on a @SpringBootTest class), each test method runs in a transaction that is rolled back at the end — a clean database per test. The trap: because nothing is ever committed (and often never flushed), tests can pass while production fails — constraint violations, lazy-loading outside a transaction, and AFTER_COMMIT listeners never fire. Call entityManager.flush() in tests that assert on persistence, and use @Commit or non-transactional tests where commit behaviour matters.

    #c-transactional-in-tests