Mastering Spring & MSA Transactions – Part 6: Where Exactly to Put @Transactional: Class-Level or Method-Level?

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
    1. Fewer repetitive annotations. If many methods have the same transaction policy, one annotation at the class scope covers them all.
    2. Easy updates. If you need to tweak readOnly or rollbackFor settings, you do it in a single place.
  • Cons
    1. Less granularity. If a particular method requires a different propagation or isolation, you must override it with its own @Transactional.
    2. 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.
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
    1. Granular settings. You can specify each method’s propagation, isolation, or rollback rules independently.
    2. Avoid extra overhead. Methods that don’t need a transaction don’t get one, helping with clarity and performance.
  • Cons
    1. Possible repetition. If you have many methods with the same transaction requirements, you end up declaring @Transactional repeatedly.
    2. Slightly more maintenance. Changing a common attribute means editing it in multiple places.
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:

  1. The class has propagation = NEVER, which instructs Spring not to initiate (or even join) a transaction under any circumstance.
  2. The method has propagation = REQUIRES_NEW, which explicitly wants a brand-new transaction each time.
  3. 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?

  1. If 80%+ of methods share the same settings
    • Use a class-level approach and override the few outliers.
  2. If nearly every method differs
    • Go method-level, giving each operation an exact configuration.
  3. Mixed approach
    • Class-level sets broad defaults (like readOnly = false, propagation=REQUIRED), and method-level modifies exceptions (like a method that needs REQUIRES_NEW or readOnly=true).

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.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top