Back to @Transactional Basics
Q3Question 3 of 7

Class-level vs Method-level @Transactional?

Key Concepts
  • Class-level @Transactional applies as the default for ALL public methods of the bean
  • Method-level @Transactional overrides the class-level settings for that specific method
  • Spring resolves precedence through AnnotationTransactionAttributeSource: method first, then class fallback
  • Best practice: @Transactional(readOnly = true) on class, writable @Transactional on write methods
  • New read methods are safe by default because they inherit the class-level readOnly flag
  • Only public methods are proxied; class-level annotation does not affect private or package-private methods

Definition

Class-level @Transactional sets the default for all public methods, and method-level @Transactional overrides it for that specific method.
  • Class-level @Transactional – applies to every public method in the bean as the default transaction configuration; any method without its own annotation inherits these settings.
  • Method-level @Transactional – overrides the class-level settings for that one method; Spring's AnnotationTransactionAttributeSource checks the method first and falls back to the class.
  • Precedence rule – method-level always wins over class-level; there is no merging of attributes.
  • Best practice – set readOnly = true at the class level so all query methods skip dirty checking and route to replicas by default, then override with a writable @Transactional on the few methods that write data.

At the National Bank of India, the branch door has a sign: "All counters are read-only enquiry by default." This is the class-level annotation. Counter number 3 has its own sign: "This counter handles transfers and deposits." That is the method-level override. If a counter has no sign of its own, it follows the door sign. If it has its own sign, the door sign does not apply to it.

Understanding the Concepts

What is class-level @Transactional?

When @Transactional is placed on a class, Spring applies it as the default transaction configuration for every public method in that bean. Every method call through the proxy inherits the class-level attributes: propagation, isolation, readOnly, timeout, and rollbackFor.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;

@Service
@Transactional(readOnly = true)
public class AccountInquiryService {

    private final AccountRepository accountRepo;

    public AccountInquiryService(AccountRepository accountRepo) {
        this.accountRepo = accountRepo;
    }

    // Inherits readOnly = true from class
    public BigDecimal getBalance(String accountId) {
        return accountRepo.findById(accountId).getBalance();
    }

    // Inherits readOnly = true from class
    public List<Account> getByType(String type) {
        return accountRepo.findByType(type);
    }

    // Inherits readOnly = true from class
    public long countActive() {
        return accountRepo.countByStatus("ACTIVE");
    }
}

All three methods run in a read-only transaction without any annotation on them individually.

What is method-level @Transactional?

When @Transactional is placed on a specific method, it sets the transaction configuration for that method only. If there is also a class-level annotation, the method-level settings completely replace the class-level settings for that method. There is no merging of individual attributes.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class DepositService {

    private final AccountRepository accountRepo;

    public DepositService(AccountRepository accountRepo) {
        this.accountRepo = accountRepo;
    }

    @Transactional(rollbackFor = Exception.class, timeout = 10)
    public void deposit(String accountId, BigDecimal amount) {
        Account account = accountRepo.findByIdForUpdate(accountId);
        account.credit(amount);
        accountRepo.save(account);
    }
}

How does precedence work?

Spring resolves which annotation applies through AnnotationTransactionAttributeSource. It first checks the method for a @Transactional annotation. If found, it uses the method-level settings. If not found, it falls back to the class-level annotation. There is no merging: if the method-level annotation specifies only rollbackFor, the other attributes (like readOnly, timeout) take their default values, not the class-level values.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;

@Service
@Transactional(readOnly = true, timeout = 30)
public class AccountService {

    private final AccountRepository accountRepo;

    public AccountService(AccountRepository accountRepo) {
        this.accountRepo = accountRepo;
    }

    // Uses class-level: readOnly=true, timeout=30
    public BigDecimal getBalance(String accountId) {
        return accountRepo.findById(accountId).getBalance();
    }

    // Method-level overrides EVERYTHING: readOnly=false (default), timeout=-1 (default)
    @Transactional(rollbackFor = Exception.class)
    public void withdraw(String accountId, BigDecimal amount) {
        Account account = accountRepo.findByIdForUpdate(accountId);
        account.debit(amount);
        accountRepo.save(account);
    }

    // Uses class-level: readOnly=true, timeout=30
    public List<Account> getHighBalance(BigDecimal threshold) {
        return accountRepo.findByBalanceGreaterThan(threshold);
    }
}

Notice that withdraw specifies only rollbackFor. It does not inherit the class-level timeout = 30 because method-level replaces the entire class-level annotation. If you want the timeout on the write method too, you must specify it explicitly: @Transactional(rollbackFor = Exception.class, timeout = 30).

What is the best practice pattern?

The recommended pattern is @Transactional(readOnly = true) at the class level and writable @Transactional on the specific methods that modify data. This pattern has three advantages:

  • Every read method automatically skips Hibernate dirty checking and can be routed to a read replica.
  • A developer who adds a new read method does not need to remember the annotation; it is safe by default.
  • Write methods are explicitly marked, making the code easy to review.
JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;

@Service
@Transactional(readOnly = true)
public class SavingsAccountService {

    private final AccountRepository accountRepo;

    public SavingsAccountService(AccountRepository accountRepo) {
        this.accountRepo = accountRepo;
    }

    // Read methods: inherit class-level readOnly=true
    public BigDecimal getBalance(String accountId) {
        return accountRepo.findById(accountId).getBalance();
    }

    public List<Account> findByCustomer(String customerId) {
        return accountRepo.findByCustomerId(customerId);
    }

    public long countSavingsAccounts() {
        return accountRepo.countByType("SAVINGS");
    }

    // Write methods: explicit override
    @Transactional(rollbackFor = Exception.class)
    public void deposit(String accountId, BigDecimal amount) {
        Account account = accountRepo.findByIdForUpdate(accountId);
        account.credit(amount);
        accountRepo.save(account);
    }

    @Transactional(rollbackFor = Exception.class, timeout = 10)
    public void withdraw(String accountId, BigDecimal amount) {
        Account account = accountRepo.findByIdForUpdate(accountId);
        if (account.getBalance().compareTo(amount) < 0) {
            throw new InsufficientFundsException(
                "Account " + accountId + " has insufficient balance");
        }
        account.debit(amount);
        accountRepo.save(account);
    }
}

What about inheritance and interfaces?

A class-level @Transactional on a parent class does not automatically apply to a subclass. The subclass must redeclare the annotation. Similarly, @Transactional on an interface does not reliably propagate to implementations under CGLIB proxying (Spring Boot default). Always place the annotation on the concrete class.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

// Parent class with class-level annotation
@Transactional(readOnly = true)
public abstract class BaseAccountService {
    // read methods here
}

// WRONG: subclass does not inherit the parent's class-level annotation reliably
@Service
public class CurrentAccountService extends BaseAccountService {
    // methods here run WITHOUT readOnly unless re-annotated
}

// CORRECT: redeclare on the subclass
@Service
@Transactional(readOnly = true)
public class CurrentAccountServiceFixed extends BaseAccountService {
    // methods here correctly inherit readOnly = true
}

What about private and package-private methods?

Class-level @Transactional applies only to public methods because CGLIB proxying works by overriding methods in a subclass, and only public methods can be overridden across packages. Private, protected, and package-private methods are not proxied, so the class-level annotation has no effect on them.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
public class ReportService {

    // Public: proxied, readOnly = true applies
    public String generateMonthlyReport(String accountId) {
        return buildReport(accountId);
    }

    // Private: NOT proxied, no transaction
    private String buildReport(String accountId) {
        // This runs without a transaction context even though
        // the class has @Transactional(readOnly = true)
        return "report";
    }
}

What are common mistakes?

Mistake 1: assuming method-level inherits class-level attributes

JAVA
// WRONG assumption: timeout=30 is NOT inherited by the method-level annotation
@Service
@Transactional(readOnly = true, timeout = 30)
public class LedgerService {

    // Developer expects timeout=30 here, but method-level replaces everything
    @Transactional(rollbackFor = Exception.class)
    public void postEntry(String accountId, BigDecimal amount) {
        // timeout is -1 (default), NOT 30
    }
}
JAVA
// CORRECT: specify all needed attributes at method level
@Service
@Transactional(readOnly = true, timeout = 30)
public class LedgerService {

    @Transactional(rollbackFor = Exception.class, timeout = 30)
    public void postEntry(String accountId, BigDecimal amount) {
        // timeout is now 30 as intended
    }
}

Mistake 2: class-level writable when most methods are reads

JAVA
// WRONG: every method is writable, no read optimization
@Service
@Transactional
public class AccountServiceBad {
    public BigDecimal getBalance(String id) { ... }  // writable TX for a read
    public List<Account> findAll() { ... }            // writable TX for a read
    public void deposit(String id, BigDecimal amt) { ... } // correctly writable
}
JAVA
// CORRECT: default to readOnly, override for writes
@Service
@Transactional(readOnly = true)
public class AccountServiceGood {
    public BigDecimal getBalance(String id) { ... }  // readOnly, optimized
    public List<Account> findAll() { ... }            // readOnly, optimized

    @Transactional(rollbackFor = Exception.class)
    public void deposit(String id, BigDecimal amt) { ... } // writable
}

What should a senior engineer know?

  • The "no merging" rule is the most common source of bugs. When a method-level annotation overrides the class level, it replaces the entire transaction definition, not just the specified attributes.
  • Spring uses AnnotationTransactionAttributeSource.computeTransactionAttribute() to resolve precedence. Set logging.level.org.springframework.transaction.interceptor=TRACE to see which annotation Spring picks for each call.
  • In a service with many read methods and few writes, the class-level readOnly pattern reduces annotation noise and ensures new methods are safe by default.
  • The class-level readOnly flag works with AbstractRoutingDataSource to route all read methods to the replica automatically, reducing primary database load at National Bank of India.
JAVA
// Enable TRACE logging to see which TX attributes Spring picks
// application.yml
// logging:
//   level:
//     org.springframework.transaction.interceptor: TRACE
TEXT
TRACE o.s.t.i.TransactionInterceptor - Getting transaction for [com.bank.service.SavingsAccountService.getBalance]
TRACE o.s.t.i.TransactionInterceptor - Don't need to create transaction for [com.bank.service.SavingsAccountService.getBalance]: read-only

Visual Overview

DIAGRAM
  @Service
  @Transactional(readOnly = true)    ← Class-level default
  public class AccountService {
  ┌──────────────────────────────────────────────────┐
  │                                                    │
  │  getBalance()     → readOnly=true  (from class)   │
  │  findByCustomer() → readOnly=true  (from class)   │
  │  countActive()    → readOnly=true  (from class)   │
  │                                                    │
  │  @Transactional(rollbackFor=Exception.class)       │
  │  deposit()        → readOnly=false (method wins)   │
  │                                                    │
  │  @Transactional(rollbackFor=Exception.class)       │
  │  withdraw()       → readOnly=false (method wins)   │
  │                                                    │
  └──────────────────────────────────────────────────┘

Key Takeaway

Class-level @Transactional applies as the default for all public methods in the service. Method-level @Transactional overrides the class-level settings for that specific method. The override is complete, not a merge: if the method-level annotation specifies only rollbackFor, the other attributes like readOnly and timeout take their default values, not the class-level values.

The best practice at National Bank of India is to set @Transactional(readOnly = true) at the class level because most service methods are read operations like getBalance, getByType, and countActive. The few methods that modify data, like deposit and withdraw, get a method-level @Transactional that overrides back to writable. This ensures that all read methods automatically skip Hibernate dirty checking, can be routed to a read replica, and do not accidentally perform writes. If Rajesh's team adds a new read method and forgets to annotate it, it is safe by default because the class-level readOnly applies.

The key nuance to remember is the no-merging rule. Spring does not combine class-level and method-level attributes. A method-level annotation replaces the entire class-level definition. If the class sets timeout to 30 and the method only sets rollbackFor, the method's timeout is -1 (the default), not 30. Always specify all needed attributes at the method level when overriding.

Remember one sentence: class-level @Transactional is the default for all public methods, method-level overrides it completely (no merging), and the best pattern is readOnly on the class with writable overrides on write methods.

---

Share
Use ← → arrow keys to navigate