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 onsomeOtherMethod
will 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.