Most backend developers have put @Transactional on a service method and moved on. It usually works, which is exactly why interviewers like it. A few follow-up questions show quickly whether you know what the annotation does or just where to put it.
Here are seven of those follow-ups, with answers written the way I'd say them out loud. Everything assumes Spring Boot 3.x (Spring Framework 6.x), Jakarta Persistence, Hibernate 6 and Java 17-21.
1. "Why didn't my @Transactional method start a transaction?"
The usual cause is self-invocation.
Spoken answer: "Spring applies @Transactional through a proxy. Callers get the proxy, the proxy opens the transaction, then delegates to the real object. If a method inside the same class calls another annotated method with this., that call never goes through the proxy, so the annotation is ignored."
@Service public class OrderService { private final OrderRepository orderRepository; public OrderService(OrderRepository orderRepository) { this.orderRepository = orderRepository; } public void placeOrders(List<Order> orders) { for (Order order : orders) { saveOrder(order); // same as this.saveOrder(order): no proxy, no transaction } } @Transactional public void saveOrder(Order order) { orderRepository.save(order); } } Fixes, in the order I'd suggest them: move saveOrder into a separate bean and inject it, use TransactionTemplate for programmatic control, or switch to AspectJ weaving if you really need it. Self-injecting the bean with @Lazy also works, but it's a workaround most reviewers won't love.
2. "Can I put @Transactional on a private or final method?"
Spoken answer: "With the default proxy mode, no for private, and no for final. Spring Boot uses class-based (CGLIB) proxies by default, which work by subclassing your bean and overriding methods. A private method can't be overridden, and neither can a final one, so no transaction is applied."
Two details make this answer stand out:
- Since Spring Framework 6.0,
protectedand package-private methods can be transactional with class-based proxies. Before that, onlypublicmethods counted. With interface-based JDK proxies, the method still has to be public and declared on the interface. - A
finalmethod on a proxied bean is a nasty bug. The call runs on the proxy instance itself, whose injected fields are usuallynull, so you can get aNullPointerExceptioninstead of a missing transaction.
3. "My method threw an exception. Why did the data still commit?"
Spoken answer: "By default Spring rolls back only on RuntimeException and Error. A checked exception, like IOException or your own PaymentFailedException extends Exception, does not trigger rollback. The transaction commits whatever was written before the exception."
Fixes:
-
@Transactional(rollbackFor = Exception.class)on the method or class. - Since Spring Framework 6.2 you can change the default globally with
@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS).
A related follow-up: if you catch the exception inside the method and don't rethrow it, Spring never sees it, so it commits. And if an inner REQUIRED method throws a runtime exception that the outer method catches, the shared transaction is already marked rollback-only. The outer commit then fails with UnexpectedRollbackException. That surprises people the first time they see it.
4. "REQUIRED vs REQUIRES_NEW: when would you use each?"
Spoken answer: "REQUIRED is the default. If a transaction already exists, the method joins it; if not, it starts one. Everything commits or rolls back together. REQUIRES_NEW suspends the current transaction and starts an independent one, which commits or rolls back on its own."
A real use case: recording a failed payment attempt. You want the audit row saved even when the payment transaction rolls back.
@Service public class PaymentAuditService { private final PaymentAuditRepository auditRepository; public PaymentAuditService(PaymentAuditRepository auditRepository) { this.auditRepository = auditRepository; } @Transactional(propagation = Propagation.REQUIRES_NEW) public void recordFailure(Long orderId, String reason) { auditRepository.save(new PaymentAudit(orderId, reason)); } } PaymentService.charge() runs in its own REQUIRED transaction, calls paymentAuditService.recordFailure(...) (a different bean, so the proxy applies), then throws. The charge rolls back, and the audit row stays.
The catch worth mentioning: REQUIRES_NEW needs a second database connection while the first one is suspended. Under load, heavy use of it can exhaust a small connection pool.
5. "What does readOnly = true actually do?"
Spoken answer: "It's a hint, and with Hibernate it has real effects. Spring sets the Hibernate session to manual flush mode and marks it read-only, so Hibernate skips dirty checking and doesn't keep snapshots of loaded entities. That saves memory and CPU on big reads. By default Spring also passes the read-only flag to the JDBC connection, and some drivers and databases use it for optimisations, or a routing data source can send those calls to a read replica."
What it doesn't do: it isn't a security guarantee. Whether a write fails depends on the driver and the database, so don't rely on it to block writes. Use it on query methods so you get the performance benefit, and keep writes in methods without it.
6. "Two users update the same row at once. What happens?"
This is the lost update problem. Both transactions read balance 1,000, one adds 200, the other subtracts 100, and whichever commits last wins. One change disappears.
Spoken answer: "@Transactional uses Isolation.DEFAULT, which means the database's default: READ COMMITTED on PostgreSQL and Oracle, REPEATABLE READ on MySQL InnoDB. READ COMMITTED does not stop a read-modify-write lost update. You can raise isolation, but in a JPA application the common fix is optimistic locking with a version column."
@Entity public class Account { @Id private Long id; private BigDecimal balance; @Version private Long version; public void deposit(BigDecimal amount) { this.balance = this.balance.add(amount); } } Hibernate adds WHERE version = ? to the update. If another transaction changed the row first, zero rows are updated and Spring throws ObjectOptimisticLockingFailureException. You then retry or return a conflict to the client. When conflicts are frequent, like a hot counter, pessimistic locking with @Lock(LockModeType.PESSIMISTIC_WRITE) on a repository query can be the better choice.
7. "Why do I get LazyInitializationException, and why doesn't it happen in my controller?"
Spoken answer: "A lazy association is loaded only when you touch it, and that needs an open Hibernate session. If you access order.getItems() after the transaction and session are closed, for example in a mapper or a scheduled job, you get LazyInitializationException."
It often doesn't show up in controllers because Spring Boot enables Open Session in View by default (spring.jpa.open-in-view=true). The session stays open for the whole web request, so lazy loading works in the controller and during JSON serialisation. Boot logs a warning about it at startup for a reason: it hides N+1 queries and holds a connection for the whole request.
Better fixes: fetch what you need inside the service with JOIN FETCH, @EntityGraph, or a DTO projection, and set spring.jpa.open-in-view=false so problems show up in tests instead of production.
public interface OrderRepository extends JpaRepository<Order, Long> { @EntityGraph(attributePaths = "items") Optional<Order> findWithItemsById(Long id); } Quick revision checklist
- Self-invocation skips the proxy. Call through another bean or use
TransactionTemplate. - Private and final methods aren't proxied. Protected and package-private work on class-based proxies since Spring 6.0.
- Default rollback is unchecked exceptions and errors only. Use
rollbackFor, orrollbackOn = ALL_EXCEPTIONSon Spring 6.2+. - A caught inner exception can still mark the transaction rollback-only and give you
UnexpectedRollbackException. -
REQUIRES_NEWis for work that must survive the outer rollback, like audit logs. It costs an extra connection. -
readOnly = trueturns off Hibernate dirty checking and flushing. It's not a write lock. - Default isolation comes from the database. Use
@Versionagainst lost updates. - Fetch lazy data inside the transaction and consider turning off open-in-view.
Which of these has bitten you in a real project? I'd like to hear about it in the comments.
If you're preparing for Java backend interviews, my Java & Spring Boot Interview Prep Guide has 240 questions like these (Core Java, Spring Boot 3, JPA, microservices, system design). Get it here: https://topmate.io/anantghadi/2384289 (India, ₹299) or https://payhip.com/b/pNo2T (international, $7). There's also a free 8-page sample on my Topmate: https://topmate.io/anantghadi
This article was written with AI assistance.

