Transaction Boundaries and Common Pitfalls

Transaction Boundaries

A transaction boundary is the point where a transaction starts and where it ends. In Spring, boundaries are defined by @Transactional on service methods.

HTTP Request
  └── Controller (no transaction)
        └── @Transactional Service method ← transaction OPENS here
              ├── Repository call 1
              ├── Repository call 2
              └── method returns ← transaction COMMITS here

Everything inside the @Transactional method runs in the same database transaction. The persistence context (first-level cache) lives for the duration of that transaction. When the transaction commits, the persistence context is flushed and closed.


Pitfall 1: LazyInitializationException

This is the most common JPA error. It happens when you access a lazy association outside an active persistence context:

@Entity
public class Order {
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items; // lazy by default
}
@Service
public class OrderService {
    @Transactional(readOnly = true)
    public Order findOrder(Long id) {
        return orderRepository.findById(id).orElseThrow();
    } // ← transaction CLOSES here, persistence context gone
}
@RestController
public class OrderController {
    public OrderDto getOrder(@PathVariable Long id) {
        Order order = orderService.findOrder(id);
        // Transaction closed! Persistence context gone!
        order.getItems().size(); // ← LazyInitializationException
    }
}

Solutions

Option 1: Fetch what you need inside the transaction

@Transactional(readOnly = true)
public Order findOrderWithItems(Long id) {
    Order order = orderRepository.findById(id).orElseThrow();
    Hibernate.initialize(order.getItems()); // force-load inside tx
    return order;
}

Option 2: Use JOIN FETCH

@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findWithItems(@Param("id") Long id);

Option 3: Map to a DTO inside the transaction (best practice)

@Transactional(readOnly = true)
public OrderDto findOrder(Long id) {
    Order order = orderRepository.findWithItems(id).orElseThrow();
    return OrderDto.from(order); // access lazy fields here, still in tx
}

Return DTOs from service methods instead of entities — this eliminates the lazy-loading-outside-transaction problem entirely.


Pitfall 2: Open Session in View (OSIV)

Spring Boot enables the Open Session in View (OSIV) anti-pattern by default:

# application.properties — Spring Boot default
spring.jpa.open-in-view=true  # ← this is the default

OSIV keeps the persistence context (and a database connection) open for the entire HTTP request — including the controller and view rendering. This allows lazy associations to be accessed in templates and controllers without LazyInitializationException.

Why OSIV is harmful in production:

  1. Database connection held for the entire request — from before your controller executes to after your view renders. Long-running views (Thymeleaf templates, JSON serialization) hold a connection for seconds.
  2. Connection pool exhaustion — under load, all pool connections are occupied, new requests queue.
  3. Silent N+1 queries — lazy-loaded associations silently fire individual queries during JSON serialization, far from the service where they’re hard to detect.
  4. Spring Boot logs a warning when OSIV is enabled with a connection pool:
spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning

Disable OSIV:

spring.jpa.open-in-view=false

Then fix LazyInitializationException properly by fetching all needed data inside service transactions and returning DTOs.


Pitfall 3: Transaction-less Repository Calls

Spring Data JPA repository methods have @Transactional on them by default — but only at the repository layer. When you call a repository method from a non-transactional context, a short transaction opens just for that call and immediately closes.

@Service
// No @Transactional here
public class BadOrderService {

    public void processOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        // Transaction from findById is already closed here
        // order is now a DETACHED entity

        order.setStatus(OrderStatus.PROCESSING);
        orderRepository.save(order); // opens another transaction for save
        // but changes between these two calls are in a detached entity
    }
}

The order is detached between the two repository calls. Any changes to it are tracked by nothing — save() on a detached entity may or may not behave as expected depending on whether the entity has an ID.

Fix: Always wrap multi-step repository operations in a @Transactional service method:

@Service
@Transactional
public class OrderService {

    public void processOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        // order is MANAGED — persistence context is tracking it
        order.setStatus(OrderStatus.PROCESSING);
        // No explicit save needed — dirty checking will persist the change
    }
}

Pitfall 4: Modifying an Entity Without a Transaction

Hibernate only tracks entities that are in the managed state within an active persistence context. Without a transaction, there is no persistence context:

// No @Transactional
public void updateProductName(Long id, String name) {
    Product p = productRepository.findById(id).orElseThrow(); // detached after this
    p.setName(name);
    // Nothing persists this change! No transaction, no flush.
    // The name change is silently lost.
}

Fix: add @Transactional or call productRepository.save(p) explicitly.


Pitfall 5: Exception Swallowing Prevents Rollback

@Transactional
public void createOrder(OrderRequest req) {
    try {
        orderRepository.save(buildOrder(req));
        inventoryService.deduct(req.getProductId(), req.getQuantity()); // may throw
    } catch (Exception e) {
        log.error("Error creating order", e); // swallow the exception
        // Transaction is NOT rolled back — it commits with partial data!
    }
}

Spring only rolls back if the exception propagates out of the @Transactional boundary. Catching and swallowing exceptions inside a @Transactional method lets the transaction commit even after a failure.

Fix:

@Transactional
public void createOrder(OrderRequest req) {
    try {
        orderRepository.save(buildOrder(req));
        inventoryService.deduct(req.getProductId(), req.getQuantity());
    } catch (InsufficientStockException e) {
        log.warn("Stock issue for order", e);
        throw e; // re-throw to trigger rollback
    }
}

Or mark the transaction for rollback explicitly:

catch (Exception e) {
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    log.error("Marking transaction for rollback", e);
}

Pitfall 6: Long Transactions Holding Connections

@Transactional
public void processAllOrders() {
    List<Order> allOrders = orderRepository.findAll(); // may be millions
    for (Order order : allOrders) {
        sendEmailNotification(order); // slow HTTP call inside transaction
        order.setNotificationSent(true);
    }
    // Transaction may be open for minutes — holding a connection
}

This holds a database connection for the entire duration of all email sends. Under concurrency, this exhausts the connection pool.

Fix: separate data access from I/O:

@Transactional(readOnly = true)
public List<Long> findUnnotifiedOrderIds() {
    return orderRepository.findUnnotifiedOrderIds();
}

@Transactional
public void markNotified(Long orderId) {
    orderRepository.findById(orderId).ifPresent(o -> o.setNotificationSent(true));
}

// In a scheduler or batch job:
public void processNotifications() {
    List<Long> ids = findUnnotifiedOrderIds(); // short tx
    for (Long id : ids) {
        emailService.send(id);          // no tx, may be slow
        markNotified(id);               // short tx per order
    }
}

Each database transaction is now short. Email sending happens outside any transaction.


Pitfall 7: Rollback Exceptions with @Transactional

Spring rolls back on RuntimeException by default — but some runtime exceptions you may want to handle without rollback:

@Transactional
public void reserveProduct(Long productId, int quantity) {
    try {
        inventoryService.reserve(productId, quantity);
    } catch (ConcurrencyFailureException e) {
        // OptimisticLock exception — we want to retry, not rollback
        throw e; // but throwing it WILL cause rollback
    }
}

Fix: use noRollbackFor or handle the retry at a higher level without @Transactional:

@Transactional(noRollbackFor = ConcurrencyFailureException.class)
public void reserveProduct(Long productId, int quantity) {
    // If ConcurrencyFailureException fires, transaction still commits
    inventoryService.reserve(productId, quantity);
}

Pitfall 8: Nested @Transactional with REQUIRED Propagation

@Service
public class ParentService {
    @Transactional
    public void parent() {
        doWork();
        childService.child(); // REQUIRED — joins the same tx
    }
}

@Service
public class ChildService {
    @Transactional // REQUIRED — joins parent tx
    public void child() {
        // If this throws, the ENTIRE transaction (parent + child) rolls back
        throw new RuntimeException();
    }
}

With REQUIRED propagation, both methods share one transaction. If the inner method throws, the outer transaction is also rolled back — even if the outer method catches the exception:

@Transactional
public void parent() {
    doWork();
    try {
        childService.child(); // throws RuntimeException
    } catch (RuntimeException e) {
        log.error("child failed, continuing...");
        // WRONG: the transaction is ALREADY marked for rollback
        // Committing it will throw UnexpectedRollbackException
    }
}

Spring marks the shared transaction as rollback-only the moment a RuntimeException propagates through a @Transactional boundary.

Fix: if you need the inner operation to fail independently, use REQUIRES_NEW:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void child() {
    // separate transaction — rolls back independently
    throw new RuntimeException();
}

Transaction Checklist

CheckWhat to verify
Service layer has @TransactionalMulti-step operations are in one transaction
OSIV disabledspring.jpa.open-in-view=false
DTOs returned from servicesNo lazy loading in controllers
Long I/O outside transactionsEmail, HTTP calls not inside @Transactional
Exceptions not swallowedRollback-triggering exceptions propagate out
readOnly = true on readsDirty checking skipped for read-only methods
Checked exceptions coveredrollbackFor configured where needed
Self-invocation avoidedCross-cutting @Transactional in separate beans

Summary

  • The persistence context closes when the transaction commits — any lazy loading after that throws LazyInitializationException. Fix by fetching data inside the transaction and returning DTOs.
  • Disable OSIV (spring.jpa.open-in-view=false) in production — it silently holds database connections.
  • Always put multi-step repository calls inside a @Transactional service method — the entity stays managed, dirty checking works, and you need no explicit save() call.
  • Swallowing exceptions inside @Transactional prevents rollback — always re-throw or mark rollback explicitly.
  • Keep transactions short — move slow I/O (email, HTTP) outside @Transactional boundaries.
  • With REQUIRED propagation, an inner exception marks the shared transaction for rollback — use REQUIRES_NEW when inner operations must be independent.

Next: Article 24 covers dirty checking, flush modes, and the first-level cache — understanding when and how Hibernate decides to send SQL to the database.