What is the Transactional Annotation
Annotating a method with @Transactional instructs Spring Framework to: 4. If the method throws an unhandled RuntimeException or Error, automatically execute a ROLLBACK.
🟢 Junior Level
@Transactional is Spring Framework’s premier annotation for declarative transaction management. It relieves developers from writing manual transaction boilerplate (connection.setAutoCommit(false), commit(), rollback()) and managing try-finally cleanup blocks.
The Core Concept in 30 Seconds
Annotating a method with @Transactional instructs Spring Framework to:
- Automatically open a database transaction before the method begins executing.
- Execute the business logic inside the method.
- If the method finishes cleanly without unhandled errors, execute a
COMMIT. - If the method throws an unhandled
RuntimeExceptionorError, automatically execute aROLLBACK.
[ Client Call ]
│
▼
[ Spring AOP Proxy ] ──────────► BEGIN TRANSACTION (checks out connection from pool)
│
▼
[ Service Method Call ] ───────► Executes SQL statements against DB
│
├── Without Errors? ─────► COMMIT (modifications permanently persisted)
│
└── RuntimeException? ───► ROLLBACK (all modifications cleanly undone)
Practical Spring Boot Example: Bank Transfer
@Service
public class BankTransferService {
private final AccountRepository accountRepository;
public BankTransferService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
@Transactional // Guarantees atomic money transfer
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
Account from = accountRepository.findById(fromId)
.orElseThrow(() -> new IllegalArgumentException("Sender account not found"));
Account to = accountRepository.findById(toId)
.orElseThrow(() -> new IllegalArgumentException("Recipient account not found"));
from.debit(amount);
to.credit(amount);
accountRepository.save(from);
// If an unexpected crash or exception occurs here,
// the debit from 'from' is guaranteed to roll back cleanly!
accountRepository.save(to);
}
}
Core Attributes of @Transactional
| Attribute | Purpose | Default Value |
|---|---|---|
propagation |
Defines boundary behavior when interacting with other transactions | Propagation.REQUIRED |
isolation |
Configures database transaction isolation level | Isolation.DEFAULT (Database engine default) |
readOnly |
Performance optimization flag for read-only query streams | false |
timeout |
Maximum allowed execution duration in seconds | -1 (No timeout enforced) |
rollbackFor |
Exception classes that trigger transaction rollback | RuntimeException.class, Error.class |
noRollbackFor |
Exception classes that must NOT trigger rollback | {} (Empty array) |
🟡 Middle Level
Architectural Mechanism: Spring AOP Proxy
Spring manages transactions using the AOP Dynamic Proxy design pattern:
Application Context Startup:
Target Bean (BankTransferService) ──► Wrapped in CGLIB / ByteBuddy Proxy
│
At Runtime Invocations: ▼
proxy.transferMoney() ──────────────► [ TransactionInterceptor ]
│
1. Inspects @Transactional metadata
2. Queries PlatformTransactionManager
3. Inspects ThreadLocal resources
4. Executes target method on real bean
5. Intercepts outcome: Commit or Rollback
Placement Rules: Method vs. Class vs. Interface
- On Method: Most granular and recommended approach. Overrides any class-level defaults for the specific method.
- On Class: Applies transaction defaults to all
publicmethods of that class. - On Interface: ⚠️ Dangerous Anti-Pattern! Works only if legacy JDK Dynamic Proxies are used. If Spring Boot uses its standard CGLIB subclass proxying (
spring.aop.proxy-target-class=true), annotations on interfaces are completely ignored, and no transactions will be created!
Common Developer Mistakes and Anti-Patterns
| Anti-Pattern / Bug | Consequence | Correct Architectural Solution |
|---|---|---|
Self-invocation via this.method() |
Bypasses the AOP Proxy; transaction is not opened | Extract method to a separate @Service bean or use self-injection |
Annotation on private method |
Proxy cannot override private methods; silently ignored | Mark method as public |
Annotation on final method |
CGLIB subclass cannot override final methods; ignored | Remove final modifier from transactional methods |
Checked Exception without rollbackFor |
Thrown IOException or SQLException results in COMMIT |
Always declare @Transactional(rollbackFor = Exception.class) |
Swallowed exception in try-catch |
Hiding the exception causes Spring to execute a COMMIT |
Re-throw exception or call TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() |
@Transactional on @RestController |
Holds DB connection open during JSON serialization and slow I/O | Place @Transactional strictly within the @Service layer |
🔴 Senior Level
Transaction Engine Internals: TransactionInterceptor and TransactionAspectSupport
In Spring’s transaction core, execution interception is handled inside TransactionAspectSupport.invokeWithinTransaction:
// Simplified architecture of Spring Framework's invokeWithinTransaction:
protected Object invokeWithinTransaction(Method method, Class<?> targetClass,
InvocationCallback invocation) throws Throwable {
TransactionAttributeSource tas = getTransactionAttributeSource();
TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
PlatformTransactionManager tm = determineTransactionManager(txAttr);
// 1. Create transaction if required by propagation:
TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);
Object retVal;
try {
// 2. Invoke the target business method on the real bean:
retVal = invocation.proceedWithInvocation();
} catch (Throwable ex) {
// 3. Exception caught: evaluate rollbackFor / noRollbackFor rules:
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
} finally {
// 4. Clean up ThreadLocal state:
cleanupTransactionInfo(txInfo);
}
// 5. Commit on clean return:
commitTransactionAfterReturning(txInfo);
return retVal;
}
CGLIB vs. JDK Dynamic Proxy: Bytecode Generation
Spring Boot 2.x and 3.x use CGLIB proxying via ByteBuddy by default (spring.aop.proxy-target-class = true):
- JDK Dynamic Proxies: Generates proxy classes using
java.lang.reflect.Proxy. Requires target beans to implement interfaces. - CGLIB Proxies: Generates a synthetic subclass at runtime (
BankTransferService$$SpringCGLIB$$0 extends BankTransferService).- Does not require interfaces.
- Limitation with
final: Because the Java Virtual Machine strictly prohibits overridingfinalmethods in subclasses, CGLIB cannot weave its interception hook intofinalmethods. Invoking afinalmethod executes the base method directly, bypassingTransactionInterceptor.
Database Connection Lifecycle: Lazy Connection Acquisition
Many developers assume @Transactional immediately checks out a physical connection from HikariCP upon entering the method:
- By default with Spring Data JPA and Hibernate, connection checkout is lazy (
Lazy Connection Acquisition): a physical socket connection is requested from HikariCP only when the application executes its first SQL statement (Statement.execute()) or accesses an ID sequence generator. - The Isolation Level Exception: If
@Transactional(isolation = Isolation.REPEATABLE_READ)or another non-default isolation level is specified, Spring must check out the connection immediately at the method entry point to executeconnection.setTransactionIsolation(...).
Programmatic Alternative: TransactionTemplate
In high-concurrency microservices, keeping transactions as short as possible is critical. Senior engineers frequently replace declarative @Transactional with TransactionTemplate to avoid holding database connections during external RPC or REST calls:
@Service
public class HighloadOrderService {
private final TransactionTemplate transactionTemplate;
private final OrderRepository orderRepository;
private final ExternalPaymentClient paymentClient;
public HighloadOrderService(TransactionTemplate transactionTemplate,
OrderRepository orderRepository,
ExternalPaymentClient paymentClient) {
this.transactionTemplate = transactionTemplate;
this.orderRepository = orderRepository;
this.paymentClient = paymentClient;
}
public void processOrderWithExternalPayment(Long orderId) {
// Step 1: Heavy external network call (ZERO database connections held!)
PaymentResponse response = paymentClient.executePayment(orderId);
// Step 2: Minimalist database transaction scoped strictly to row update:
transactionTemplate.execute(status -> {
orderRepository.updateStatus(orderId, response.getStatus());
return true;
});
}
}
4 Tricky Questions
1. What happens if a method annotated with @Transactional is marked with the final keyword under standard Spring Boot CGLIB proxying?
Answer:
The application compiles and boots without errors, but no transaction will ever be opened!
Under Spring Boot’s default CGLIB proxying mechanism, Spring dynamically generates a subclass of the target bean. According to JVM specification, final methods cannot be overridden by subclasses. Consequently, CGLIB cannot inject its interceptor logic. When called from an external class, the JVM dispatches execution directly to the original target class implementation, completely bypassing TransactionInterceptor. The code runs in auto-commit mode or throws TransactionRequiredException.
2. Why does Spring’s @Transactional by default roll back on RuntimeException and Error, but commit on checked Exception?
Answer: This behavior originates from the architectural philosophy established by Rod Johnson, reflecting standard Java exception design:
RuntimeException(Unchecked): Represents an unexpected programming fault, hardware failure, or infrastructure corruption (e.g.,NullPointerException,DataAccessException). The system state cannot be trusted, so rolling back is the only safe default.Checked Exception: Represents a predictable, recoverable business alternative (e.g.,InsufficientFundsException,CustomerNotFoundException). The compiler forces the caller to catch and handle the scenario, and Spring assumes the caller may want to commit partial progress or handle the situation gracefully. To enforce rollbacks on all exceptions, developers must explicitly specify@Transactional(rollbackFor = Exception.class).
3. At what precise moment is a physical connection checked out from HikariCP when entering a @Transactional method?
Answer: It depends on the transaction configuration:
- Under
Isolation.DEFAULT: The connection checkout is lazy. Spring defers checking out a physical JDBC connection from HikariCP until the first actual SQL query is issued by Hibernate/JPA. - Under custom
isolation(e.g.,Isolation.REPEATABLE_READ,SERIALIZABLE): Spring must immediately check out a physical connection at the AOP proxy boundary in order to invokeconnection.setTransactionIsolation(...)before executing method instructions.
4. Why is placing @Transactional on a @RestController method considered a catastrophic architectural mistake in Highload systems?
Answer: It leads directly to Connection Pool Exhaustion:
- When
@Transactionalwraps a controller endpoint, the database connection is held open throughout the entire HTTP request lifecycle, including request body parsing, controller execution, and response serialization. - If clients download responses over slow network connections (e.g., mobile networks) or if the controller serializes large JSON payloads, the JDBC connection remains checked out and idle for hundreds of milliseconds or seconds.
- As few as 20–50 concurrent slow clients can monopolize the entire HikariCP pool, starving the rest of the application and causing widespread
ConnectionTimeoutExceptionfailures across all microservice instances.
🎯 Interview Cheat Sheet
- Core Definition: Spring’s declarative transaction demarcation annotation, implemented via AOP Dynamic Proxies (
TransactionInterceptor). - Rollback Rule: By default, rolls back strictly on
RuntimeExceptionandError. Checked exceptions commit unless configured withrollbackFor = Exception.class. - Public Methods Only: AOP proxies only intercept
publicmethods invoked from external beans. - Self-Invocation Failure: Invoking
@Transactionalmethods viathis.method()bypasses the proxy and disables transaction management. - CGLIB vs. Final: CGLIB subclasses cannot override
finalmethods; declaring a@Transactionalmethod asfinaldisables transactions silently. - Placement Guideline: Place
@Transactionalexclusively on the@Servicelayer; never on@RestController. - Lazy Checkout: JDBC connections are acquired lazily upon the first SQL query, unless a custom
isolationlevel is defined.