Spring Boot

Transactions

Spring Boot auto-configures transaction management, so you can use @Transactional without adding @EnableTransactionManagement. @Transactional declaratively demarcates transaction boundaries — Spring wraps the annotated method in a proxy that opens, commits, or rolls back the transaction through a PlatformTransactionManager. This entry covers @Transactional attributes, propagation, isolation levels, rollback rules, read-only optimisations, programmatic transactions, running code after commit, and the common pitfalls.

@Transactional Basics

@Transactional on a public service method tells Spring to open a transaction before the method runs and commit it on normal return, or roll back on an unchecked exception. Place it on the service layer — not on controllers (the HTTP layer should not own the transaction) and not on Spring Data repository methods (the built-in repository methods are already transactional). Spring Boot enables transaction management automatically, so no extra annotation is needed. The main attributes are propagation, isolation, timeout (in seconds), readOnly, rollbackFor and noRollbackFor, and a transactionManager name when you have more than one data source. Under the default class-based proxies, public, protected and package-private methods are intercepted; private methods are not.
Java
// ── Service layer — correct placement ────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {

    private final OrderRepository    orderRepo;
    private final InventoryService   inventoryService;
    private final PaymentService     paymentService;

    // ── Single unit of work — all-or-nothing ──────────────────────────
    @Transactional(timeout = 30)     // give up and roll back after 30 seconds
    public OrderResponse placeOrder(PlaceOrderRequest request) {
        // 1. Validate and reserve inventory
        inventoryService.reserve(request.items());

        // 2. Persist the order
        Order order = orderRepo.save(Order.from(request));

        // 3. Charge the customer
        paymentService.charge(order);

        // 4. If any step throws a RuntimeException, all steps roll back
        return OrderResponse.from(order);
    }

    // ── Read-only query ────────────────────────────────────────────────
    // A single repository call does not need this (it is already
    // transactional), but readOnly=true is useful for methods that run
    // several queries in one transaction and never write.
    @Transactional(readOnly = true)
    public OrderResponse findById(Long id) {
        return orderRepo.findById(id)
            .map(OrderResponse::from)
            .orElseThrow(() -> new OrderNotFoundException(id));
    }
}

// ── What NOT to do ────────────────────────────────────────────────────

// WRONG — @Transactional on a controller:
@RestController
public class OrderController {
    @Transactional          // ← never here; HTTP layer should not own tx
    @PostMapping("/orders")
    public ResponseEntity<OrderResponse> create(...) { ... }
}

// WRONG — @Transactional on a private method:
@Service
public class UserService {
    @Transactional          // ← ignored; Spring proxy cannot intercept
    private void updateInternal(User user) { ... }
}

// WRONG — @Transactional on a repository method (Spring Data handles this):
public interface OrderRepository extends JpaRepository<Order, Long> {
    @Transactional          // ← redundant for the built-in methods
    Optional<Order> findById(Long id);
}

Propagation

Propagation controls what happens when a @Transactional method is called from within an existing transaction. REQUIRED (the default) joins an existing transaction or creates a new one. REQUIRES_NEW always creates a new independent transaction, suspending the outer one. NESTED creates a savepoint inside the outer transaction. The remaining levels are used for edge cases. Two things to remember: propagation only takes effect when the call goes through the Spring proxy, so the caller must be a different bean; and REQUIRES_NEW needs a second database connection while the first is still held, so heavy use under load can exhaust the connection pool.
Java
@Service
@RequiredArgsConstructor
@Slf4j
public class AuditService {

    private final AuditLogRepository auditLogRepo;

    // ── REQUIRED (default) — join outer tx or create a new one ────────
    @Transactional(propagation = Propagation.REQUIRED)
    public void log(String action, Long entityId) {
        auditLogRepo.save(new AuditLog(action, entityId));
        // If called from within an existing tx → joins it
        // If called standalone               → opens its own tx
    }

    // ── REQUIRES_NEW — always a new independent transaction ───────────
    // Use for audit logs, notifications, or any operation that must
    // succeed or fail independently of the outer transaction.
    // The caller must be ANOTHER bean: a call from inside this class
    // bypasses the proxy and this annotation is ignored.
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logIndependent(String action, Long entityId) {
        auditLogRepo.save(new AuditLog(action, entityId));
        // Outer tx is SUSPENDED while this runs.
        // Commits even if the outer tx rolls back.
        // Rolls back independently of the outer tx.
    }

    // ── NESTED — savepoint inside the outer tx ────────────────────────
    // Roll back to the savepoint on failure without losing the outer tx.
    // Needs a transaction manager that supports savepoints, such as
    // JDBC / JPA with a JDBC connection (not JTA).
    @Transactional(propagation = Propagation.NESTED)
    public void logNested(String action, Long entityId) {
        auditLogRepo.save(new AuditLog(action, entityId));
    }

    // ── SUPPORTS — join tx if one exists, else run non-transactionally ─
    @Transactional(propagation = Propagation.SUPPORTS)
    public List<AuditLog> findAll() {
        return auditLogRepo.findAll();
    }

    // ── NOT_SUPPORTED — suspend any active tx, run non-transactionally ─
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void runWithoutTransaction(Runnable task) {
        task.run();
    }

    // ── NEVER — throw if called within an active transaction ──────────
    @Transactional(propagation = Propagation.NEVER)
    public void mustRunOutsideTransaction() {
        // Throws IllegalTransactionStateException if a tx is active
    }

    // ── MANDATORY — throw if there is no active transaction ───────────
    @Transactional(propagation = Propagation.MANDATORY)
    public void mustRunInsideTransaction() {
        // Throws IllegalTransactionStateException if no tx is active
    }
}

// ── Propagation interaction diagram ───────────────────────────────────
//
//  outer tx active?     REQUIRED      REQUIRES_NEW    NESTED
//  ─────────────────    ──────────    ────────────    ──────────────
//  Yes                  Join outer    New tx          Savepoint
//  No                   New tx        New tx          New tx

Isolation Levels

Isolation controls which changes from concurrent transactions are visible within the current transaction. Higher isolation prevents more anomalies but increases lock contention or the chance of failures. Choose the lowest level that satisfies your consistency requirements. Spring uses the database default unless you set isolation: READ_COMMITTED for PostgreSQL, Oracle and SQL Server, and REPEATABLE_READ for MySQL (InnoDB). Databases also differ in details: PostgreSQL treats READ_UNCOMMITTED as READ_COMMITTED, and its REPEATABLE_READ also prevents phantom reads. The isolation attribute only applies when a transaction is actually started: a method that joins an existing transaction uses the outer transaction's isolation level.
Java
@Service
@RequiredArgsConstructor
public class ReportService {

    private final OrderRepository   orderRepo;
    private final AccountRepository accountRepo;

    // ── READ_UNCOMMITTED — can see dirty reads ────────────────────────
    // Can read rows modified by uncommitted transactions.
    // Rarely correct — only for approximate counts or caches.
    @Transactional(isolation = Isolation.READ_UNCOMMITTED)
    public long approximateOrderCount() {
        return orderRepo.count();
    }

    // ── READ_COMMITTED (default on PostgreSQL, Oracle, SQL Server) ────
    // Sees only committed data; prevents dirty reads.
    // Subject to non-repeatable reads: re-reading the same row within
    // one transaction may return different values if another tx
    // committed between the two reads.
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public OrderSummary summarise(Long orderId) {
        return orderRepo.findById(orderId)
            .map(OrderSummary::from)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
    }

    // ── REPEATABLE_READ (default on MySQL InnoDB) ─────────────────────
    // Prevents dirty reads and non-repeatable reads.
    // A row read twice in the same transaction returns the same values.
    // The standard allows phantom reads at this level (PostgreSQL and
    // InnoDB prevent most of them with snapshots).
    @Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
    public FinancialReport buildReport(LocalDate date) {
        // Both totals come from the same consistent snapshot
        BigDecimal revenue = orderRepo.totalRevenueFor(date);
        BigDecimal refunds = orderRepo.totalRefundsFor(date);
        return new FinancialReport(date, revenue, refunds);
    }

    // ── SERIALIZABLE — full isolation ─────────────────────────────────
    // Prevents dirty reads, non-repeatable reads, and phantom reads.
    // Transactions behave as if they ran one after another — highest
    // consistency, highest contention. The database may abort a
    // transaction with a serialization failure (PostgreSQL SQLSTATE
    // 40001), so callers must be ready to retry.
    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void transferFunds(Long fromId, Long toId,
                              BigDecimal amount) {
        Account from = accountRepo.findById(fromId).orElseThrow();
        Account to   = accountRepo.findById(toId).orElseThrow();
        from.debit(amount);
        to.credit(amount);
        accountRepo.save(from);
        accountRepo.save(to);
    }
    // Often a cheaper alternative to SERIALIZABLE: a @Version column
    // (optimistic locking) or @Lock(LockModeType.PESSIMISTIC_WRITE) on
    // the account rows.
}

// ── Isolation anomaly matrix (SQL standard) ────────────────────────────
//
// Level             Dirty Read   Non-Repeatable   Phantom Read
// ──────────────────────────────────────────────────────────────
// READ_UNCOMMITTED  Possible     Possible         Possible
// READ_COMMITTED    Prevented    Possible         Possible
// REPEATABLE_READ   Prevented    Prevented        Possible
// SERIALIZABLE      Prevented    Prevented        Prevented

Rollback Rules

By default Spring rolls back on RuntimeException and Error but commits on checked exceptions. Override this with rollbackFor and noRollbackFor. Always throw unchecked exceptions from service methods — or explicitly configure rollbackFor for checked exceptions — to ensure the transaction rolls back on failure. Using rollbackFor = Exception.class is a common way to make every exception roll back.
Java
@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentService {

    private final PaymentRepository paymentRepo;
    private final LedgerRepository  ledgerRepo;
    private final GatewayService    externalGateway;
    private final FraudService      fraudService;

    // ── Default: rollback on RuntimeException and Error ───────────────
    @Transactional
    public PaymentResponse process(ProcessPaymentRequest request) {
        Payment payment = paymentRepo.save(Payment.from(request));
        ledgerRepo.record(payment);          // RuntimeException → rollback
        return PaymentResponse.from(payment);
    }

    // ── Rollback on a checked exception ───────────────────────────────
    @Transactional(rollbackFor = PaymentGatewayException.class)
    public PaymentResponse processExternal(ProcessPaymentRequest request)
            throws PaymentGatewayException {
        Payment payment = paymentRepo.save(Payment.from(request));
        externalGateway.charge(payment);     // checked — rolls back
        return PaymentResponse.from(payment);
    }

    // ── Rollback on multiple exception types ──────────────────────────
    @Transactional(rollbackFor = {
        PaymentGatewayException.class,
        InsufficientFundsException.class,
        FraudDetectedException.class
    })
    public void processWithCheckedExceptions(Long paymentId)
            throws PaymentGatewayException,
                   InsufficientFundsException,
                   FraudDetectedException {
        Payment payment = paymentRepo.findById(paymentId).orElseThrow();
        fraudService.check(payment);
        externalGateway.charge(payment);
    }

    // ── Suppress rollback for a specific RuntimeException ─────────────
    // Use when a known business exception should commit the transaction
    // (e.g. recording a failed payment attempt before throwing):
    @Transactional(noRollbackFor = PaymentDeclinedException.class)
    public void recordAndThrowDeclined(Long paymentId) {
        Payment payment = paymentRepo.findById(paymentId).orElseThrow();
        payment.markDeclined();
        paymentRepo.save(payment);          // committed even though we throw
        throw new PaymentDeclinedException("Card declined");
    }

    // ── Mark transaction for rollback programmatically ─────────────────
    @Transactional
    public void processWithManualRollback(Long paymentId) {
        try {
            Payment payment = paymentRepo.findById(paymentId).orElseThrow();
            fraudService.check(payment);
        } catch (SomeRecoverableException ex) {
            // Log and mark for rollback without re-throwing
            log.warn("Marking transaction for rollback: {}", ex.getMessage());
            TransactionAspectSupport.currentTransactionStatus()
                .setRollbackOnly();
        }
    }
}

Read-Only Transactions

@Transactional(readOnly = true) is a hint to the transaction manager, Hibernate and the JDBC driver that no data will be modified. With Hibernate, the session flush mode is set to manual and dirty checking of loaded entities is skipped, which reduces CPU and memory overhead. Some databases and drivers also reject writes on a read-only connection. If you use a routing data source (AbstractRoutingDataSource with a lazy connection proxy), your own routing code can send read-only transactions to a read replica. Treat it as an optimisation and a safety net, not as a security boundary. Note that readOnly = true does not by itself give a consistent snapshot across several queries: under READ_COMMITTED each query can see newer commits. For a stable snapshot also use REPEATABLE_READ.
Java
@Service
@RequiredArgsConstructor
public class ProductQueryService {

    private final ProductRepository productRepo;

    // ── Single entity lookup ───────────────────────────────────────────
    @Transactional(readOnly = true)
    public ProductResponse findById(Long id) {
        return productRepo.findById(id)
            .map(ProductResponse::from)
            .orElseThrow(() -> new ProductNotFoundException(id));
    }

    // ── Paginated list ─────────────────────────────────────────────────
    @Transactional(readOnly = true)
    public Page<ProductResponse> findAll(Pageable pageable) {
        return productRepo.findAll(pageable)
                          .map(ProductResponse::from);
    }

    // ── Projection query ───────────────────────────────────────────────
    @Transactional(readOnly = true)
    public List<ProductSummary> findSummaries() {
        return productRepo.findAllProjectedBy();
    }

    // ── Multi-query report — one connection, one transaction ───────────
    // For numbers that must agree with each other, also use
    // isolation = Isolation.REPEATABLE_READ.
    @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
    public DashboardData buildDashboard() {
        long    total    = productRepo.count();
        long    active   = productRepo.countByStatus(ProductStatus.ACTIVE);
        long    outStock = productRepo.countByStockQuantity(0);
        BigDecimal avgPrice = productRepo.averagePrice();
        return new DashboardData(total, active, outStock, avgPrice);
    }
}

// ── Class-level readOnly with write method override ───────────────────
// Mark the whole service read-only, then override write methods:
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)          // default for all methods
public class CategoryService {

    private final CategoryRepository categoryRepo;

    public List<CategoryResponse> findAll() {       // inherits readOnly=true
        return categoryRepo.findAll()
            .stream().map(CategoryResponse::from).toList();
    }

    public CategoryResponse findById(Long id) {     // inherits readOnly=true
        return categoryRepo.findById(id)
            .map(CategoryResponse::from)
            .orElseThrow(() -> new CategoryNotFoundException(id));
    }

    @Transactional                                  // overrides: readOnly=false
    public CategoryResponse create(CreateCategoryRequest req) {
        return CategoryResponse.from(
            categoryRepo.save(Category.from(req)));
    }

    @Transactional                                  // overrides: readOnly=false
    public void delete(Long id) {
        categoryRepo.deleteById(id);
    }
}

Programmatic Transactions

When declarative @Transactional is not flexible enough — for example, to commit mid-method, to roll back only part of an operation, or to manage transactions in a loop — use TransactionTemplate or PlatformTransactionManager directly. TransactionTemplate is the simpler of the two for most cases. Spring Boot already auto-configures a TransactionTemplate bean with default settings, so you only need to define your own if you want different defaults. A shared template's settings apply everywhere it is injected; create a new TransactionTemplate(txManager) when one operation needs different settings.
Java
@Service
@RequiredArgsConstructor
@Slf4j
public class BatchImportService {

    private final TransactionTemplate        txTemplate;
    private final ProductRepository          productRepo;
    private final PlatformTransactionManager txManager;

    // ── TransactionTemplate — commit per batch ─────────────────────────
    public ImportResult importProducts(List<ProductCsvRow> rows) {
        int imported  = 0;
        int failed    = 0;
        int batchSize = 100;

        // Commit each batch of 100 rows independently
        for (int i = 0; i < rows.size(); i += batchSize) {
            List<ProductCsvRow> batch =
                rows.subList(i, Math.min(i + batchSize, rows.size()));
            try {
                Integer count = txTemplate.execute(status -> {
                    List<Product> products = batch.stream()
                        .map(Product::from)
                        .toList();
                    productRepo.saveAll(products);
                    return products.size();
                });
                imported += count != null ? count : 0;
            } catch (Exception ex) {
                log.warn("Batch failed, skipping: {}", ex.getMessage());
                failed += batch.size();
            }
        }
        return new ImportResult(imported, failed);
    }

    // ── executeWithoutResult + manual rollback ─────────────────────────
    public void processWithPartialRollback(Long orderId) {
        txTemplate.executeWithoutResult(status -> {
            try {
                processOrder(orderId);
            } catch (RecoverableException ex) {
                log.warn("Rolling back order {}: {}", orderId, ex.getMessage());
                status.setRollbackOnly();    // mark for rollback explicitly
            }
        });
    }

    // ── PlatformTransactionManager — full manual control ──────────────
    public void manualTransaction(Runnable work) {
        DefaultTransactionDefinition def = new DefaultTransactionDefinition();
        def.setPropagationBehavior(
            TransactionDefinition.PROPAGATION_REQUIRED);
        def.setIsolationLevel(
            TransactionDefinition.ISOLATION_READ_COMMITTED);
        def.setTimeout(30);                  // 30-second timeout

        TransactionStatus status = txManager.getTransaction(def);
        try {
            work.run();
            txManager.commit(status);
        } catch (RuntimeException | Error ex) {
            txManager.rollback(status);
            throw ex;
        }
    }
}

// ── Optional: your own TransactionTemplate defaults ───────────────────
// Boot already provides one; define yours only to change the defaults.
@Configuration
public class TransactionConfig {

    @Bean
    public TransactionTemplate transactionTemplate(
            PlatformTransactionManager txManager) {
        TransactionTemplate template = new TransactionTemplate(txManager);
        template.setIsolationLevel(
            TransactionDefinition.ISOLATION_READ_COMMITTED);
        template.setTimeout(60);
        return template;
    }
}

Running Code After Commit with @TransactionalEventListener

Some work must happen only after the transaction has committed: sending an email, publishing a message, calling another service. Doing it inside the transaction holds database locks during slow I/O, and if the transaction later rolls back, the email has already been sent. Publish an application event from the transactional method and handle it with @TransactionalEventListener. With the default AFTER_COMMIT phase the listener runs only if the transaction committed. If the listener needs to write to the database, it must start its own transaction with REQUIRES_NEW, because the original one has already completed.
Java
// ── Event ──────────────────────────────────────────────────────────────
public record OrderPlacedEvent(Long orderId, String customerEmail) {}

// ── Publish inside the transaction ────────────────────────────────────
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository         orderRepo;
    private final ApplicationEventPublisher events;

    @Transactional
    public Order placeOrder(PlaceOrderRequest request) {
        Order order = orderRepo.save(Order.from(request));
        // Not delivered to AFTER_COMMIT listeners until the tx commits;
        // dropped if the tx rolls back.
        events.publishEvent(
            new OrderPlacedEvent(order.getId(), request.email()));
        return order;
    }
}

// ── Listener runs after a successful commit ───────────────────────────
@Component
@RequiredArgsConstructor
@Slf4j
public class OrderNotificationListener {

    private final EmailService emailService;
    private final AuditService auditService;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderPlaced(OrderPlacedEvent event) {
        // No transaction lock is held here; slow I/O is safe.
        emailService.sendConfirmation(event.customerEmail());

        // A DB write now needs a NEW transaction (the old one is over):
        auditService.logIndependent("ORDER_PLACED", event.orderId());
    }
}

// Other phases: BEFORE_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION.
// Without an active transaction the event is ignored unless you set
// fallbackExecution = true.

Common Pitfalls

Most transaction bugs in Spring Boot fall into a small set of patterns: self-invocation bypassing the proxy, @Transactional on private methods, swallowing exceptions that should trigger rollback, lazy loading outside a transaction, long transactions holding locks, inner methods marking the outer transaction rollback-only, and work moved to another thread. Knowing these pitfalls prevents hours of debugging.
Java
// ── Pitfall 1: Self-invocation bypasses the proxy ────────────────────
@Service
public class OrderService {

    @Transactional
    public void placeOrder(PlaceOrderRequest req) {
        // ... order logic
    }

    public void placeOrderAndNotify(PlaceOrderRequest req) {
        placeOrder(req);      // ← direct call — proxy NOT invoked
        // @Transactional on placeOrder() is silently ignored here
    }
}

// FIX (preferred) — move the transactional method into another bean:
@Service
@RequiredArgsConstructor
public class OrderFacade {

    private final OrderService orderService;   // injected proxy

    public void placeOrderAndNotify(PlaceOrderRequest req) {
        orderService.placeOrder(req);          // ← goes through the proxy
    }
}
// Workaround — self-injection: a @Lazy field of the same type,
// then call self.placeOrder(req). It works, but splitting the class
// is cleaner.

// ── Pitfall 2: @Transactional on private methods ─────────────────────
@Service
public class UserService {

    @Transactional          // ← silently ignored — proxy cannot intercept
    private void updateInternal(User user) { ... }
}
// FIX — make the method public (or protected / package-private) and
//        call it from another bean.

// ── Pitfall 3: Swallowing the exception ───────────────────────────────
@Transactional
public void process(Long id) {
    try {
        riskyOperation(id);
    } catch (RuntimeException ex) {
        log.error("Failed", ex);
        // ← exception swallowed — Spring never sees it — tx COMMITS
    }
}
// FIX — re-throw, or mark the transaction rollback-only:
@Transactional
public void process(Long id) {
    try {
        riskyOperation(id);
    } catch (RuntimeException ex) {
        log.error("Failed", ex);
        TransactionAspectSupport.currentTransactionStatus()
            .setRollbackOnly();
    }
}

// ── Pitfall 4: UnexpectedRollbackException ────────────────────────────
@Transactional
public void outer() {
    try {
        inner();                 // another bean, @Transactional (REQUIRED)
    } catch (RuntimeException ex) {
        // swallowed — but inner() already marked the SHARED tx
        // rollback-only when the exception left its proxy
    }
    // commit now fails with UnexpectedRollbackException
}
// FIX — give inner() propagation = REQUIRES_NEW (or NESTED) if it may
//        fail independently, or let the exception propagate.

// ── Pitfall 5: LazyInitializationException outside a transaction ──────
@Service
public class OrderService {

    @Transactional(readOnly = true)
    public Order findById(Long id) {
        return orderRepo.findById(id).orElseThrow();
        // Order.items is LAZY — OK inside @Transactional
    }
}

@RestController
public class OrderController {

    @GetMapping("/{id}")
    public OrderResponse findById(@PathVariable Long id) {
        Order order = orderService.findById(id);
        order.getItems().size();   // ← LazyInitializationException!
        // The transaction and session closed when the service returned.
    }
}
// Spring Boot enables Open Session In View by default
// (spring.jpa.open-in-view=true), which hides this error in controllers
// but keeps a database connection open for the whole request. Many
// teams set spring.jpa.open-in-view=false and fix lazy access properly.
// FIX — fetch what you need in the query (JOIN FETCH or an entity
//        graph), map to a DTO inside the service, or use a projection.

// ── Pitfall 6: Long transactions holding locks ────────────────────────
@Transactional
public void processAndSendEmail(Long orderId) {
    Order order = orderRepo.findById(orderId).orElseThrow();
    order.markProcessed();
    orderRepo.save(order);                 // row locked
    emailService.sendConfirmation(order);  // ← slow external call in tx
    // Row stays locked for the entire email round-trip.
}
// FIX — commit first, then send the email. The two steps must be in
//        different beans (see Pitfall 1), for example:
@Service
@RequiredArgsConstructor
public class OrderProcessingService {

    private final OrderRepository orderRepo;

    @Transactional
    public Order markProcessed(Long orderId) {
        Order order = orderRepo.findById(orderId).orElseThrow();
        order.markProcessed();
        return orderRepo.save(order);
    }                                       // tx commits here, lock released
}

@Service
@RequiredArgsConstructor
public class OrderFacade {

    private final OrderProcessingService processing;
    private final EmailService           emailService;

    public void processAndSendEmail(Long orderId) {
        Order order = processing.markProcessed(orderId);
        emailService.sendConfirmation(order);   // outside tx — no lock held
    }
}
// Or publish an event and use @TransactionalEventListener(AFTER_COMMIT).

// ── Pitfall 7: Other threads do not join the transaction ──────────────
@Transactional
public void importAll(List<Row> rows) {
    rows.parallelStream().forEach(repo::save);   // ← other threads: no tx
    CompletableFuture.runAsync(() -> repo.save(x)); // ← also no tx
}
// A transaction is bound to the thread that started it. @Async methods,
// parallel streams and executors run in separate transactions (or none).

// ── Pitfall 8: @Transactional tests always roll back ──────────────────
// A test class annotated @Transactional rolls back after each test.
// That keeps tests isolated, but it also means commit-time behaviour
// (AFTER_COMMIT listeners, database constraints checked on commit,
// REQUIRES_NEW) is never exercised. Test those cases without
// @Transactional on the test and clean up the data yourself.