Question 16 · Section 11

What is @Transactional annotation?

@Transactional is a Spring annotation that allows you to manage transactions declaratively. Instead of manual BEGIN/COMMIT/ROLLBACK, you just add the annotation — Spring does ev...

Language versions: English Russian Ukrainian

🟢 Junior Level

@Transactional is a Spring annotation that allows you to manage transactions declaratively. Instead of manual BEGIN/COMMIT/ROLLBACK, you just add the annotation — Spring does everything automatically.

Simplest example

@Service
public class UserService {

    @Transactional
    public void createUser(User user) {
        userRepository.save(user);
        // Spring automatically does COMMIT on success
        // or ROLLBACK on RuntimeException
    }
}

Analogy

Think of a bank transfer: you either transfer the full amount or not at all. @Transactional guarantees that half a transfer never “hangs” in the database.

Key parameters

Parameter What it does Default value
propagation How transaction relates to others REQUIRED
isolation Isolation level DEFAULT (DB level)
readOnly Read-only optimization false
rollbackFor Which exceptions trigger rollback RuntimeException, Error
timeout Timeout in seconds -1 (no timeout)

When to use

Any method that performs multiple DB operations that must be atomic: money transfers, creating orders with items, updating related entities.

Important rules

  • Works only on public methods
  • Works only when called from outside the class (not via this)
  • By default rolls back only on RuntimeException and Error

🟡 Middle Level

How it works: AOP Proxy mechanism

External call → Proxy → TransactionInterceptor → PlatformTransactionManager
                                                        ↓
                                               Open transaction → Execute method → COMMIT/ROLLBACK
  1. Spring creates a Proxy object around the bean (JDK Dynamic Proxy or CGLIB)
  2. On method call, control goes to TransactionInterceptor
  3. Interceptor consults PlatformTransactionManager, which opens connection and starts transaction
  4. Your method executes
  5. On success — commit, on exception — rollback

Where you can place the annotation

Level Works? Recommendation
Method ✅ Yes Best option — precise control
Class ✅ Yes All public methods are transactional
Interface ⚠️ Depends Only with JDK Dynamic Proxy
Private method ❌ No Proxy doesn’t intercept

Example with parameters

@Transactional(
    propagation = Propagation.REQUIRED,
    isolation = Isolation.READ_COMMITTED,
    timeout = 30,
    readOnly = false,
    rollbackFor = Exception.class,
    noRollbackFor = NotFoundException.class
)
public void processOrder(Order order) { ... }

Common mistakes

Mistake What happens How to fix
@Transactional on private method Annotation ignored Make method public
Calling this.method() inside class Proxy bypassed, no transaction Extract to separate bean or self-injection
Checked Exception without rollbackFor COMMIT instead of ROLLBACK Add rollbackFor = Exception.class
@Transactional on Controller DB connection held during JSON serialization Move to Service layer
Catching and swallowing exception Spring doesn’t see exception → COMMIT Rethrow exception or call setRollbackOnly()

When NOT to use

Situation Why not needed Alternative
Method only reads one record Excessive complexity readOnly = true or no transaction
HTTP call inside method Holds DB connection unnecessarily Move HTTP call outside transaction
Logging/auditing Doesn’t require atomicity with logic REQUIRES_NEW in separate bean
Background message processing May need different strategy Programmatic transactions

Comparison: declarative vs programmatic approach

Approach Pros Cons When to use
@Transactional Clean code, declarative Limited by proxy Standard scenarios
TransactionTemplate Full control, works inside this More boilerplate Conditional transaction logic

🔴 Senior Level

Spring Transaction AOP — Internal Architecture

Proxy Creation Flow

// InfrastructureAdvisorAutoProxyCreator (BeanPostProcessor)
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
    if (isInfrastructureClass(bean.getClass())) return bean;

    if (AnnotationUtils.findAnnotation(bean.getClass(), Transactional.class) != null
        || hasTransactionalMethods(bean.getClass())) {

        ProxyFactory proxyFactory = new ProxyFactory(bean);
        proxyFactory.addAdvice(new TransactionInterceptor(tm, transactionAttributeSource));
        return proxyFactory.getProxy();
    }
    return bean;
}

TransactionInterceptor Execution

public Object invoke(MethodInvocation invocation) throws Throwable {
    Class<?> targetClass = AopProxyUtils.ultimateTargetClass(invocation.getThis());

    TransactionAttribute txAttr = tas.getTransactionAttribute(
        invocation.getMethod(), targetClass);

    PlatformTransactionManager tm = determineTransactionManager(txAttr);
    TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, invocation.getMethod().toString());

    Object retVal;
    try {
        retVal = invocation.proceed();
    } catch (Throwable ex) {
        completeTransactionAfterThrowing(txInfo, ex);
        throw ex;
    } finally {
        cleanupTransactionInfo(txInfo);
    }

    commitTransactionAfterReturning(txInfo);
    return retVal;
}

JDK Dynamic Proxy vs CGLIB

Characteristic JDK Dynamic Proxy CGLIB
Mechanism Implements interfaces Extends class
Creation speed Faster (~5-10μs) Slower (~20-50μs)
Call overhead Low Slightly higher
@Transactional on interface ✅ Works ❌ Ignored
Final methods ✅ Not a problem ❌ Cannot override
Memory Less More (generates class)
// JDK Dynamic Proxy
UserService proxy = (UserService) Proxy.newProxyInstance(
    classLoader,
    new Class[]{UserService.class},
    handler
);

// CGLIB
UserService proxy = (UserService) Enhancer.create(UserService.class, handler);

Spring Boot defaults to CGLIB if there are no interfaces. Can force: @EnableTransactionManagement(proxyTargetClass = true).

Thread-Local Transaction State

public abstract class TransactionSynchronizationManager {
    private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal<>("Transactional resources");
    private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations = new NamedThreadLocal<>("Transaction synchronizations");
    private static final ThreadLocal<String> currentTransactionName = new NamedThreadLocal<>("Current transaction name");
    private static final ThreadLocal<Boolean> currentTransactionReadOnly = new NamedThreadLocal<>("Current transaction read-only status");
    private static final ThreadLocal<Integer> currentTransactionIsolationLevel = new NamedThreadLocal<>("Current transaction isolation level");
    private static final ThreadLocal<Boolean> actualTransactionActive = new NamedThreadLocal<>("Actual transaction active");
}

This is why @Transactional doesn’t work across threads in standard configuration — ThreadLocal is not transferred. @Async methods get a separate transaction context. When using custom TaskExecutor with ThreadLocal propagation or Virtual Threads (Java 21+), context propagation is possible but requires explicit configuration.

Edge Cases (minimum 3)

  1. UnexpectedRollbackException: In a REQUIRED → REQUIRED chain, inner method throws RuntimeException. Outer catches it, but TransactionInterceptor already set rollback-only flag. On commit attempt — UnexpectedRollbackException is thrown.

  2. @Transactional on final method: CGLIB cannot override final method → annotation silently ignored. JDK Proxy also doesn’t intercept final interface methods.

  3. Inheritance and @Transactional: If subclass overrides method without re-adding the annotation — behavior depends on proxy type. With CGLIB, superclass annotation may not be discovered.

  4. Multiple TransactionManager: If application has two PlatformTransactionManager, @Transactional without qualifier uses @Primary. Calling two different methods with different managers = two independent transactions, not a distributed transaction. Need JTA/Atomikos for 2PC. JTA (Java Transaction API) and Atomikos (JTA implementation). 2PC (Two-Phase Commit) — protocol for atomic commit across multiple resources.

Performance Numbers

Operation Time
Proxy invocation overhead ~1-2 μs
Transaction setup (Spring side) ~5-10 μs
Connection acquisition (HikariCP) ~50-200 μs
Typical DB query 1-10 ms

Spring overhead is negligible. DB operations dominate.

Memory Implications

Each active transaction holds:

  • JDBC Connection from pool (~2-4 KB)
  • Hibernate L1 cache (Persistence Context) — grows with number of loaded entities
  • ThreadLocal entries in TransactionSynchronizationManager
  • Undo log in DB (longer transaction = larger)

Long transactions = connection pool exhaustion + bloat in DB.

Thread Safety

@Transactional is not thread-safe by itself. Each thread gets:

  • Separate transaction
  • Separate DB connection
  • Separate Hibernate Session

The service bean itself is a singleton. But transaction state is stored in ThreadLocal, so concurrent calls to the same @Transactional method are isolated.

Production War Story

In production, a service with @Transactional caught a checked BusinessException. Developer didn’t specify rollbackFor. Transaction did COMMIT, but part of data was already written. Client got an error, but half the order remained in the database. Solution: rollbackFor = Exception.class on all write methods + integration tests for rollback behavior.

Why Spring committed: by default, rollback only for RuntimeException + Error. Checked exception (inherits from Exception, not RuntimeException) is treated by TransactionInterceptor as “normal completion” → commit. Even though the method threw an exception, Spring doesn’t see it as a rollback signal — because the exception was caught in a catch block, and rollbackFor was not specified.

Monitoring

Spring Boot Debug Logging:

logging:
  level:
    org.springframework.transaction: DEBUG
    org.springframework.orm.jpa: DEBUG

Actuator Metrics:

@Bean
public TransactionManagerCustomizers transactionManagerCustomizers(MeterRegistry registry) {
    return new TransactionManagerCustomizers(tm -> {
        // Custom metrics
    });
}

Micrometer:

spring.datasource.connections.active
spring.datasource.connections.idle

Highload Best Practices

  1. Minimal transaction scope: Only DB operations. HTTP calls, file I/O — outside.
  2. readOnly for reads: Disables dirty checking (Hibernate compares entities with snapshot on flush; with readOnly = true this mechanism is disabled). Can route to read replica.
  3. Connection pool tuning: HikariCP maximumPoolSize = CPU cores * 2 + disk spindles (rule-of-thumb from HikariCP creator. For SSD, “disk spindles” = 1. Starting point — 10-20, then tune under load).
  4. Avoid long transactions: Split batch operations into chunks.
  5. Use REQUIRES_NEW for side-effects: Logging, notifications — shouldn’t block main transaction.
  6. Consider programmatic transactions: For complex conditional logic, TransactionTemplate is more flexible.

🎯 Interview Cheat Sheet

Must know:

  • @Transactional — declarative transaction management via AOP Proxy (JDK Dynamic Proxy or CGLIB)
  • Works only on public methods and only when called from outside the class (not via this)
  • By default rollback only for RuntimeException and Error, checked exceptions → commit
  • Parameters: propagation (REQUIRED), isolation (DEFAULT), readOnly (false), timeout (-1), rollbackFor
  • State stored in ThreadLocal — doesn’t work across threads (@Async = separate context)
  • @Transactional on interface works only with JDK Dynamic Proxy, ignored with CGLIB

Common follow-up questions:

  • Why doesn’t @Transactional work when called via this? — Proxy bypassed, TransactionInterceptor not involved
  • JDK Proxy vs CGLIB? — JDK: interface-based, CGLIB: subclass-based (final methods not intercepted)
  • What happens with @Transactional on Controller? — DB connection held during JSON serialization — anti-pattern
  • How does UnexpectedRollbackException work? — Inner REQUIRED set rollback-only, outer tries to commit

Red flags (DO NOT say):

  • “@Transactional on private method works” — proxy doesn’t intercept private methods
  • “Checked exception causes rollback” — Spring commits by default
  • “Can put on Controller” — connection held during JSON serialization, pool exhaustion

Related topics:

  • [[13. What is Propagation in Spring]]
  • [[17. At what level can you use @Transactional]]
  • [[19. Which exceptions cause rollback by default]]
  • [[22. What happens when calling @Transactional method from another method of the same class]]