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:
- 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.
- Connection pool exhaustion — under load, all pool connections are occupied, new requests queue.
- Silent N+1 queries — lazy-loaded associations silently fire individual queries during JSON serialization, far from the service where they’re hard to detect.
- 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
| Check | What to verify |
|---|---|
Service layer has @Transactional | Multi-step operations are in one transaction |
| OSIV disabled | spring.jpa.open-in-view=false |
| DTOs returned from services | No lazy loading in controllers |
| Long I/O outside transactions | Email, HTTP calls not inside @Transactional |
| Exceptions not swallowed | Rollback-triggering exceptions propagate out |
readOnly = true on reads | Dirty checking skipped for read-only methods |
| Checked exceptions covered | rollbackFor configured where needed |
| Self-invocation avoided | Cross-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
@Transactionalservice method — the entity stays managed, dirty checking works, and you need no explicitsave()call. - Swallowing exceptions inside
@Transactionalprevents rollback — always re-throw or mark rollback explicitly. - Keep transactions short — move slow I/O (email, HTTP) outside
@Transactionalboundaries. - With
REQUIREDpropagation, an inner exception marks the shared transaction for rollback — useREQUIRES_NEWwhen 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.