Definition
- 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
AnnotationTransactionAttributeSourcechecks 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 = trueat the class level so all query methods skip dirty checking and route to replicas by default, then override with a writable@Transactionalon 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.
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.
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.
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.
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.
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.
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
// 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
}
}
// 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
// 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
}
// 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. Setlogging.level.org.springframework.transaction.interceptor=TRACEto 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
AbstractRoutingDataSourceto route all read methods to the replica automatically, reducing primary database load at National Bank of India.
// Enable TRACE logging to see which TX attributes Spring picks
// application.yml
// logging:
// level:
// org.springframework.transaction.interceptor: TRACE
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
@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.
---