Spring @Transactional: Class vs Method — A Critical Design Choice
TL;DR
– Class-level: A default policy for consistency, but can add unnecessary overhead to pure reads.
– Method-level: An explicit contract for precision, but it’s easy to forget on new write methods.
– The Battle-Tested Combo: Use @Transactional(readOnly = true) at the class scope, and override only the handful of write methods.
Most developers see @Transactional as a magic spell for data integrity. But a deeper understanding reveals it’s a powerful policy enforced by a proxy. This perspective immediately leads to a critical design question that is often overlooked: where should this policy be declared?
This is not a simple coding convention. This decision directly impacts your system’s performance, maintainability, and default safety posture. Choosing incorrectly can lead to unintended read-only operations failing or unnecessary transactional overhead on simple query methods.
To make the right decision, we must first understand the fundamental trade-offs between these two philosophies.
Why Placement Really Matters
@Transactional is AOP-powered policy. Spring wraps the target method call with a proxy that:
- Opens / commits (or rolls back) a database transaction.
- Applies propagation & isolation rules.
- Flushes the persistence context.
Where the proxy gets attached defines when the boundary starts. That single decision shows up as latency, a larger or smaller failure surface, and the default read/write posture of your service.
|
Impact 193_03c44e-a8> |
Class-Level 193_34c92a-d2> |
Method-Level 193_8782bd-25> |
|---|---|---|
|
Latency 193_12b76a-6c> |
Every public call incurs Tx overhead 193_3ce0be-86> |
Reads are cheap; only write methods pay the price. 193_0bbc72-77> |
|
Safety 193_c593ad-21> |
Enforces a uniform policy. 193_c914d9-b6> |
Risk of “forgot-to-annotate” on new mutating methods. 193_179c80-1d> |
|
Tuning 193_258518-1f> |
One place to tweak |
Per-method |
The Two Philosophies: ‘Default Policy’ vs. ‘Explicit Contract’
The core difference lies in how you define the transactional boundaries for your service.
The “Default Policy” Approach: Class-Level @Transactional
Applying @Transactional at the class level establishes a baseline transactional behavior for every public method. It prioritizes consistency and simplicity.
UserService
// By default, all public methods in this class will be transactional.
@Service
@Transactional
@RequiredArgsConstructor
public class UserService {
// ...
public void createUser(User user) { /* ... */ }
public User getUserById(Long id) { /* ... */ }
}
Here, both methods inherit the same policy. The risk? Simple query methods like getUserById are wrapped in a transaction they may not need, introducing a tangible, albeit small, overhead.
The “Explicit Contract” Approach: Method-Level @Transactional
Applying the annotation to individual methods provides fine-grained, explicit control. Each annotation is a deliberate contract for that specific unit of work.
OrderService
@Service
@RequiredArgsConstructor
public class OrderService {
// ...
@Transactional
public void placeOrder(Order order) { /* ... */ }
public Order getOrderById(Long id) { /* ... */ }
}
Here, only placeOrder is guaranteed to be atomic. This approach values precision and performance, but carries the risk that a developer might forget to add the annotation to a new method that modifies data, leading to silent data corruption.
The Power of Synthesis: Combining Both Philosophies
The true power lies in combining both approaches. A class-level annotation sets the safest possible default, and method-level annotations provide explicit overrides. This allows for a powerful pattern: setting a restrictive, safe default for the class, and then loosening those restrictions only for methods that truly require it.
ProductService
// Default policy for this service: all transactions are read-only.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductService {
// ...
public List<Product> getAllProducts() {
// Inherits the class-level readOnly=true policy.
return productRepository.findAll();
}
// This method requires a write transaction, so we override the default.
@Transactional // Defaults to readOnly=false, overriding the class-level setting.
public void addProduct(Product product) {
productRepository.save(product);
}
}
This hybrid approach establishes a “principle of least privilege” at the class level, making the code safer and more performant by default.
The Hidden Pitfalls: When The Proxy Betrays You
Even with a perfect understanding of these patterns, there are infamous “gotchas” that trap even experienced developers. They both stem from how Spring’s AOP proxies work.
- The Self-Invocation Trap: A method calling another public method on
this(e.g.,this.someOtherMethod()) will bypass the proxy. The annotation onsomeOtherMethodwill be completely ignored, as the call is a direct Java method call, not a proxied one, and no new transaction boundary will be created. - The Propagation Precedence: A method-level annotation’s propagation setting always wins. If a class is
@Transactional, a method inside it annotated with@Transactional(propagation = REQUIRES_NEW)will suspend the existing transaction and start an entirely new, independent one.
Takeaway
Where you put @Transactional isn’t syntactic sugar; it’s the contract that shields your data layer. Pick a safe default like readOnly=true at the class level, document the exceptions, and always override for write methods or special propagation needs. Do that, and you’ll sleep better during on-call.

Enjoyed this article? Take the next step.
Future-Proof Your Java Career With Spring AI
The age of AI is here, but your Java & Spring experience isn’t obsolete—it’s your greatest asset.
This is the definitive guide for enterprise developers to stop being just coders and become the AI Orchestrators of the future.