You’ve seen how @Transactional
allows Spring to automatically manage commits and rollbacks, freeing you from having to code transaction boundaries by hand. But once you start using it, a question arises: Should you declare @Transactional
at the class level or the method level?
This article dives into the pros, cons, and potential pitfalls of each approach. By the end, you’ll know which option fits your project best—or how to mix both approaches effectively.
1) Class-Level @Transactional
: “One Annotation for Many Methods”
1.1) Overview
When you put @Transactional
on a class, every public method in that class inherits the same transaction settings (propagation, isolation, rollback rules, etc.). This is convenient if most methods share identical requirements.
- Pros
- Fewer repetitive annotations. If many methods have the same transaction policy, one annotation at the class scope covers them all.
- Easy updates. If you need to tweak
readOnly
orrollbackFor
settings, you do it in a single place.
- Cons
- Less granularity. If a particular method requires a different propagation or isolation, you must override it with its own
@Transactional
. - Risk of over-applying. Some methods might not need a transaction at all (e.g., simple read operations), but they’ll still end up in the same transaction unless explicitly overridden.
- Less granularity. If a particular method requires a different propagation or isolation, you must override it with its own
Example
@Service
@Transactional // Class-level default
public class OrderService {
public void placeOrder(Order order) {
// Inherits the class’s transaction settings
}
public void cancelOrder(Long orderId) {
// Same inherited settings
}
@Transactional(readOnly = true)
public Order getOrder(Long orderId) {
// Overwrites the class setting
}
}
Here, both placeOrder()
and cancelOrder()
share the default rules, while getOrder()
specifically sets readOnly=true
at the method level.
2) Method-Level @Transactional
: “Tailor Each Method as Needed”
2.1) Overview
By declaring @Transactional
on individual methods, you get precise control over each operation. This is especially valuable if different methods demand different transaction modes (e.g., one is read-only, another needs REQUIRES_NEW
, etc.).
- Pros
- Granular settings. You can specify each method’s propagation, isolation, or rollback rules independently.
- Avoid extra overhead. Methods that don’t need a transaction don’t get one, helping with clarity and performance.
- Cons
- Possible repetition. If you have many methods with the same transaction requirements, you end up declaring
@Transactional
repeatedly. - Slightly more maintenance. Changing a common attribute means editing it in multiple places.
- Possible repetition. If you have many methods with the same transaction requirements, you end up declaring
Example
@Service
public class PaymentService {
@Transactional
public void processPayment(Payment payment) {
// Default transaction (REQUIRED)
}
@Transactional(readOnly = true)
public Payment getPayment(Long id) {
// read-only transaction
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditPayment(Payment payment) {
// always starts a new transaction
}
public void helperMethod() {
// no transaction
}
}
Each method gets a tailored setting without affecting the others.
3) What Happens if You Do Both?
You can annotate the class and also certain methods. In that case, method-level @Transactional
overrides the class-level settings whenever the two differ.
3.1) Priority
Spring’s transaction parsing checks the class-level annotation first, then the method-level. If a method-level annotation has different properties (like readOnly=false
while the class is set to readOnly=true
), the method annotation takes precedence.
@Service
@Transactional(readOnly = true) // entire class is read-only by default
public class OrderService {
@Transactional // overrides to readOnly=false for this method
public void placeOrder(Order order) {
// final transaction setting is readOnly=false here
}
}
4) Watch Out for Conflicts: Propagation Collisions
If the class-level annotation specifies, say,
@Transactional(propagation = Propagation.NEVER)
but a method within that class declares
@Transactional(propagation = Propagation.REQUIRES_NEW)
you’ve got a direct conflict. NEVER means “do not start a transaction at all,” while REQUIRES_NEW says “always start a new transaction.” Because these two demands contradict each other, Spring typically throws an exception (IllegalTransactionStateException
), as it cannot satisfy both simultaneously.
4.1) Example Code Snippet
@Service
@Transactional(propagation = Propagation.NEVER) // Class-level: No transactions allowed
public class PaymentService {
// Method-level: Force a new transaction
// Conflicts with class-level NEVER
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void payInvoice(Invoice invoice) {
// This triggers a direct conflict:
// Class-level says "NEVER create a transaction",
// but method-level demands "Create a new transaction every call".
// Expect IllegalTransactionStateException at runtime.
}
}
When payInvoice()
is invoked:
- The class has
propagation = NEVER
, which instructs Spring not to initiate (or even join) a transaction under any circumstance. - The method has
propagation = REQUIRES_NEW
, which explicitly wants a brand-new transaction each time. - Spring sees two contradictory rules in the same call path, leading to an IllegalTransactionStateException.
4.2) How to Avoid This Collision
- Align your default class-level strategy with your method-level overrides. If you never intend to create a transaction at the class level, consider removing
@Transactional
from the class altogether and annotate only the methods you actually want transactions for. - Alternatively, if most of your methods truly don’t need any transactions, but there’s one outlier that does, it might be better to move that outlier method into a different service class that sets
@Transactional(propagation = REQUIRES_NEW)
without clashing with the original “no transactions” policy.
By remaining consistent with your propagation choices, you avoid puzzling exceptions and maintain clearer, more predictable transaction behavior.
5) When to Choose Which?
- If 80%+ of methods share the same settings
- Use a class-level approach and override the few outliers.
- If nearly every method differs
- Go method-level, giving each operation an exact configuration.
- Mixed approach
- Class-level sets broad defaults (like
readOnly = false, propagation=REQUIRED
), and method-level modifies exceptions (like a method that needsREQUIRES_NEW
orreadOnly=true
).
- Class-level sets broad defaults (like
6) Final Thoughts
- Placing
@Transactional
on the class can be a huge timesaver if most methods truly need the same transaction policy. Just override special cases at the method level. - Declaring
@Transactional
on each method lets you precisely tune transactions for individual behaviors, at the cost of possible repetition. - Method-level overrides class-level whenever there’s a conflict. But if the propagation modes themselves are inherently contradictory (e.g.,
NEVER
vs.REQUIRES_NEW
), expect a runtime error rather than a graceful fallback. - Whichever strategy you pick, keep in mind internal calls (i.e.,
this.someMethod()
) won’t trigger the proxy logic. That’s a separate AOP limitation you must handle if needed.
In short: Use the class-level annotation when uniform transaction handling suits most methods, and method-level for finer control or widely varying requirements. With these guidelines, you’ll keep your transactional code simpler, more consistent, and less prone to unexpected errors.