At what level can you use @Transactional?
The @Transactional annotation can be placed on a method, class, or interface. But not all options are equally good.
π’ Junior Level
The @Transactional annotation can be placed on a method, class, or interface. But not all options are equally good.
Method level (recommended)
The most precise approach. You control each method individually. Why: each method can have its own propagation, isolation, readOnly. This gives maximum flexibility without side effects on neighboring methods.
@Service
public class UserService {
@Transactional
public void createUser(User user) {
userRepository.save(user);
}
@Transactional(readOnly = true)
public User findById(Long id) {
return userRepository.findById(id).orElse(null);
}
}
Class level
All public methods of the class become transactional. Why: convenient when the entire class is a service with a single responsibility (only CRUD). But if there are non-transactional methods β unnecessary overhead on every call.
@Service
@Transactional // All public methods are transactional
public class UserService {
public void createUser(User user) { ... }
public User findById(Long id) { ... }
}
Interface level (not recommended)
@Transactional on an interface works only with JDK Dynamic Proxy. If Spring uses CGLIB (default for classes without interfaces) β the annotation on the interface is ignored. Therefore best practice is to put @Transactional on the class or method.
public interface UserService {
@Transactional // Works only with JDK Dynamic Proxy
User findById(Long id);
}
Where NOT to use
On controller β anti-pattern. Controller is responsible for HTTP, transaction for data integrity. Mixing leads to holding a DB connection during JSON serialization.
Golden rule
@Transactional β on the service layer (Business Logic Layer). Not on Controller, not on Repository (except custom modifying queries).
When NOT to put @Transactional on class level
- There are non-transactional methods β unnecessary overhead on every call
- Different propagation/isolation β have to override on each method, which is confusing
- Class contains both read and write methods β readOnly has to be set on each read method
π‘ Middle Level
Annotation priority
If annotation is on both class and method β method wins.
@Service
@Transactional(readOnly = true) // Default for all methods
public class UserService {
public User findById(Long id) { ... } // readOnly = true (inherits)
@Transactional // Overrides β readOnly = false, REQUIRED
public void updateUser(User user) { ... }
}
Repository level
Spring Data repositories are already marked with @Transactional(readOnly = true).
public interface UserRepository extends JpaRepository<User, Long> {
// findById, findAll β already transactional (readOnly)
@Modifying
@Transactional // Required for UPDATE/DELETE
@Query("UPDATE User u SET u.active = false WHERE u.id = :id")
void deactivate(@Param("id") Long id);
}
Important: If you call a @Modifying method from a service with @Transactional(readOnly = true) β you get TransactionRequiredException, because the service parameter overrides the repository parameter.
Why @Transactional on Controller is anti-pattern
@RestController
@Transactional // BAD!
public class UserController {
@PostMapping("/users")
public User create(@RequestBody User user) {
User saved = userService.create(user);
return saved; // JSON serialization inside transaction!
}
}
Problems:
- DB connection held during JSON serialization
- Lazy-loading triggers N+1 queries in controller layer
- Under load β connection pool exhaustion
Level comparison
| Level | Precision | Flexibility | When to use |
|---|---|---|---|
| Method | High | High | Always recommended |
| Class | Medium | Medium | When all methods same type (only CRUD) |
| Interface | Low | Low | β Not recommended |
| Controller | β | β | β Anti-pattern |
Common mistakes
| Mistake | What happens | How to fix |
|---|---|---|
@Transactional on interface + CGLIB |
Annotation not visible, no transaction | Put on implementation class |
@Modifying inside readOnly service |
TransactionRequiredException |
Override readOnly = false |
Class-level readOnly = true without override |
Write methods also readOnly | Explicitly annotate write methods |
@Transactional on final method |
CGLIB cannot override β annotation ignored | Remove final |
When NOT to use
| Situation | Why | Alternative |
|---|---|---|
| Simple SELECT by ID | Excessive | No @Transactional or readOnly = true |
| HTTP call in method | Holds DB connection during network I/O | Move HTTP outside transaction |
| Background processing | Can be asynchronous | @Async with separate transaction |
| Caching | Doesnβt require atomicity | No transaction |
π΄ Senior Level
Annotation Resolution Order
// AbstractFallbackTransactionAttributeSource
protected TransactionAttribute findTransactionAttribute(Method method, Class<?> targetClass) {
// 1. Check method first (highest priority)
TransactionAttribute attr = determineTransactionAttribute(method);
if (attr != null) return attr;
// 2. Check method's declaring class
attr = determineTransactionAttribute(method.getDeclaringClass());
if (attr != null) return attr;
// 3. Check bridge methods (interface vs implementation)
if (ClassUtils.isUserLevelMethod(method, targetClass)) {
Method specificMethod = ClassUtils.getMostSpecificMethod(method, targetClass);
attr = determineTransactionAttribute(specificMethod);
if (attr != null) return attr;
attr = determineTransactionAttribute(specificMethod.getDeclaringClass());
}
return null;
}
Key principle: Method-level annotation ALWAYS overrides class-level. Most specific match wins.
Proxy Type Impact on Annotation Detection
JDK Dynamic Proxy (interface-based)
public interface UserService {
@Transactional
User findById(Long id);
}
@Service
public class UserServiceImpl implements UserService {
// No @Transactional β WORKS, proxy on interface
public User findById(Long id) { ... }
}
CGLIB Proxy (class-based)
public interface UserService {
@Transactional // IGNORED with CGLIB!
User findById(Long id);
}
@Service
public class UserServiceImpl implements UserService {
// No @Transactional β DOESN'T WORK with CGLIB
public User findById(Long id) { ... }
}
CGLIB creates a subclass of UserServiceImpl, not a proxy of the interface. Interface annotation is not visible.
Solution: Always put @Transactional on implementation class/method, never on interfaces.
OSIV (Open Session in View) β Deep Analysis
# application.yml β true by default!
spring:
jpa:
open-in-view: true
With OSIV:
Request β Filter opens Session β Controller β Service β View render β Filter closes Session
Problems:
- N+1 queries: Lazy loading during JSON serialization triggers additional queries
- Connection held: Connection occupied while view renders
- Hidden performance issues: Lazy loading masks inefficient fetching
Solution β disable OSIV:
spring:
jpa:
open-in-view: false
@Transactional(readOnly = true)
public UserDTO getUserWithDetails(Long id) {
User user = userRepository.findByIdWithDetails(id); // Eager fetch
return UserDTO.from(user); // Fully loaded entity
}
Edge Cases (minimum 3)
-
Bridge methods: Java generates bridge methods with generics. Spring must correctly resolve
@Transactionalfrom bridge method to target method. Some Spring versions had bugs with@Transactionalon generic methods. -
Inheritance with overriding: If
BaseService.save()has@Transactional(timeout = 10), andDerivedService.save()β no annotation, behavior depends on proxy type. CGLIB may not find annotation in superClass under certain conditions. -
Multiple
@Transactionalon same method: If method inherited@Transactionalfrom both interface and class β only one applies (order undefined). Spring takes the first found. -
@Transactionalon@Configurationclasses: Bean creation methods inside@Configurationclasses are not automatically wrapped in transactions.@Transactionalon@Beanmethod has no effect β bean created outside transaction context.
Layer Architecture
βββββββββββββββββββββββββββββββββββββββββββ
β Controller Layer β
β - HTTP request/response β
β - Validation β
β - NO @Transactional β
βββββββββββββββββββββββββββββββββββββββββββ€
β Service Layer β
β - Business logic β
β - @Transactional HERE β β
β - Transaction boundaries defined here β
βββββββββββββββββββββββββββββββββββββββββββ€
β Repository Layer β
β - Data access β
β - Spring Data auto-transactional β
β - Only @Transactional for @Modifying β
βββββββββββββββββββββββββββββββββββββββββββ
Rule: Transaction boundaries should match business use-case boundaries, not technical operations.
Performance Numbers
| Operation | Time |
|---|---|
| Annotation resolution (Spring cache) | ~0.1 ΞΌs |
| Proxy invocation | ~1-2 ΞΌs |
| Transaction setup | ~5-10 ΞΌs |
| Connection acquisition (HikariCP) | ~50-200 ΞΌs |
Memory Implications
- Class-level
@Transactionalcreates proxy for all public methods - Each method resolves
TransactionAttribute(cached) - ThreadLocal entries: ~1 KB per active transaction
- Hibernate L1 cache: grows linearly with loaded entities
Thread Safety
The annotation itself is immutable and thread-safe. Transaction state stored in ThreadLocal β each thread isolated. Singleton bean safe under concurrent calls.
Production War Story
Microservices project: @Transactional placed on UserService interface. In dev β JDK Dynamic Proxy worked (interface existed). In production β CGLIB (Spring Boot auto-config). Result: in production transactions didnβt open, data committed without rollback on errors. Bug surfaced only under load. Fix: move @Transactional to implementation class.
Monitoring
Debug logging:
logging:
level:
org.springframework.transaction: DEBUG
org.springframework.aop: TRACE
Actuator:
GET /actuator/beans # Shows which beans are wrapped in proxy
Programmatically:
AopUtils.isAopProxy(bean) // true if proxy created
AopUtils.isCglibProxy(bean) // true if CGLIB
Highload Best Practices
- Method-level granularity: Precise control β only write methods with
@Transactional, read methods withreadOnly = true. - Disable OSIV in production:
spring.jpa.open-in-view: false. - Never put
@Transactionalon interfaces: CGLIB β silent failure. - Avoid class-level
@Transactionalin large services β some methods may not need transaction. - Consider connection pool sizing:
@Transactionalholds connection for entire method duration. Long methods = pool exhaustion. - Use
@Transactional(readOnly = true)on query services: Can route to read replica viaAbstractRoutingDataSource.
π― Interview Cheat Sheet
Must know:
- @Transactional placed on method (recommended), class, or interface
- Method wins over class β method-level annotation always overrides class-level
- Golden rule: @Transactional on service layer, NOT on Controller or Repository
- @Transactional on interface works only with JDK Dynamic Proxy, ignored with CGLIB
- Spring Data repositories already have @Transactional(readOnly = true) β @Modifying requires override
- OSIV (Open Session in View) true by default β causes N+1 queries, recommended to disable
Common follow-up questions:
- Why @Transactional on Controller is anti-pattern? β Connection held during JSON serialization, lazy loading N+1
- What is OSIV and why disable it? β Session open until render view, masks N+1, holds connection
- When is class-level @Transactional justified? β When all methods same type (only CRUD)
- What happens with @Modifying method inside readOnly service? β TransactionRequiredException
Red flags (DO NOT say):
- β@Transactional on interface always worksβ β CGLIB silently ignores
- βController with @Transactional β normal practiceβ β connection pool exhaustion risk
- βOSIV = goodβ β masks performance issues, N+1 queries
Related topics:
- [[16. What is @Transactional annotation]]
- [[21. What is readonly transaction]]
- [[22. What happens when calling @Transactional method from another method of the same class]]
- [[13. What is Propagation in Spring]]