💳 Section 11 · Question #17

At What Level Can Transactional Be Used

In Spring Framework, the @Transactional annotation can be placed at three distinct structural levels:


🟢 Junior Level

In Spring Framework, the @Transactional annotation can be placed at three distinct structural levels:

  1. Method Level (The industry gold standard).
  2. Class Level (Applies to all public methods within the class).
  3. Interface Level (Legacy approach, strongly discouraged in modern Spring Boot).

The Core Concept in 30 Seconds

  • Method Level: Delivers maximum granularity and control. Each operation specifies its own propagation, timeout, isolation level, read-only mode, and rollback rules.
  • Class Level: Acts as a sensible baseline template for homogeneous services (e.g., query-only dictionary services where all methods are read-only).
  • Interface Level: ⚠️ Dangerous Trap! In modern Spring Boot, class-based CGLIB proxying is enabled by default. Under CGLIB, annotations placed on interfaces are completely ignored and fail silently.
Where to Place @Transactional in Layered Architecture:

[ Controller / RestController ]  ───► ❌ NEVER (Anti-Pattern! Monopolizes DB connection during HTTP I/O)
             │
             ▼
[ Service Layer ]                ───► ⭐️ STRICTLY HERE (Defines business transactional boundaries)
             │
             ▼
[ Repository Layer ]             ───► ⚠️ Handled by Spring Data; used only for custom @Modifying DML

Precedence Example: Method Overrides Class

@Service
@Transactional(readOnly = true) // 1. Baseline template for all public methods in the class
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    // Inherits readOnly = true from class-level annotation:
    public User findById(Long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("User not found"));
    }

    // 2. Method OVERRIDES class baseline: opens a read-write transaction!
    @Transactional(readOnly = false)
    public void updateUserEmail(Long id, String newEmail) {
        User user = findById(id);
        user.setEmail(newEmail);
        userRepository.save(user);
    }
}

🟡 Middle Level

Annotation Resolution Hierarchy (Resolution Order)

When Spring AOP intercepts a method invocation, AbstractFallbackTransactionAttributeSource inspects transactional metadata according to a strict priority hierarchy from most specific to least specific:

TransactionAttribute Lookup Order:
1. Target class method implementation             [HIGHEST PRIORITY]
   └── If present ──► USE THIS METADATA
2. Target class declaration
   └── If present ──► USE THIS METADATA
3. Interface method declaration
   └── Works ONLY with JDK Dynamic Proxies!
4. Interface declaration                          [LOWEST PRIORITY]
   └── Works ONLY with JDK Dynamic Proxies!

Crucial Rule: An annotation declared on a method always completely overrides any class-level annotation.

Comparison Across Placement Levels

Placement Level Advantages Risks & Disadvantages Recommended Practice
Service Method Maximum precision; explicit read/write separation; custom timeouts Requires more annotations ⭐️ Industry Best Practice
Service Class Reduces boilerplate in pure CRUD services Easy to accidentally execute slow queries in write transactions Acceptable with readOnly = true
Interface Clean interface declarations Silently ignored by CGLIB in Spring Boot ❌ Strictly Avoid
Controller None Starves connection pool during HTTP transfer and JSON parsing ❌ Severe Anti-Pattern
Repository Spring Data defaults to readOnly = true Scoped to a single SQL query; cannot orchestrate business units Only for @Modifying queries

Why @Transactional on Controllers Destroys Highload Systems

Placing @Transactional on a @RestController or @Controller endpoint is a devastating architectural anti-pattern:

  1. Connection Monopolization: A worker thread checks out a database connection from HikariCP before verifying the HTTP request payload and holds it throughout the entire JSON serialization phase.
  2. Slow Client Vulnerability (Slowloris Effect): If a mobile client downloads a 5 MB JSON payload over a weak 3G network over 10–15 seconds, the physical database connection remains locked and idle for those entire 15 seconds.
  3. Connection Pool Starvation: Just 30 to 50 concurrent slow downloads will completely deplete the HikariCP pool, causing immediate ConnectionTimeoutException failures across the entire microservice.

🔴 Senior Level

The Critical “No Merging of Attributes” Trap

One of the most dangerous misconceptions among developers:

In Spring Framework, @Transactional attributes DO NOT merge! A method-level annotation completely REPLACES class-level metadata.

@Service
@Transactional(
    readOnly = true, 
    rollbackFor = Exception.class, 
    timeout = 10
)
public class PaymentProcessingService {

    // 💥 DANGEROUS TRAP!
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processPayment() {
        // The developer expects:
        // - propagation = REQUIRES_NEW
        // - readOnly = false (default)
        // - rollbackFor = Exception.class (inherited from class?? NO!)
        // - timeout = 10 (inherited from class?? NO!)

        // ACTUAL RUNTIME CONFIGURATION:
        // rollbackFor reverts to RuntimeException.class!
        // timeout reverts to -1 (unlimited)!
    }
}

If processPayment() throws a checked exception (PaymentCheckedException), the transaction will commit, because rollbackFor = Exception.class declared on the class was wiped out by the method-level declaration!

CGLIB vs. JDK Dynamic Proxy: Why Interface Annotations Fail

Spring Boot enables class-based proxying by default:

spring.aop.proxy-target-class = true

This forces Spring Boot to use CGLIB (via ByteBuddy), which generates dynamic subclasses (OrderServiceImpl$$SpringCGLIB$$0 extends OrderServiceImpl):

  1. According to the Java Language Specification (JLS §9.6.4.3), annotations on interfaces are never inherited by implementing classes, even if marked with @Inherited.
  2. CGLIB subclasses the implementation class (OrderServiceImpl), but the compiled bytecode of OrderServiceImpl contains no @Transactional annotation (it was declared on the interface).
  3. When the CGLIB proxy reflects on the implementation class, it sees an unannotated class.
  4. Result: No compilation errors or startup warnings are generated, but transactions simply do not open at runtime, running silently in auto-commit mode!

Interaction with Open Session in View (OSIV)

Spring Boot leaves spring.jpa.open-in-view = true enabled by default:

  • An OpenSessionInViewFilter intercepts the HTTP request and opens a Hibernate Session at the servlet boundary.
  • When @Transactional is placed on the service layer, the physical database transaction commits inside the service method.
  • However, the Hibernate session remains open in the controller layer, allowing Jackson during JSON serialization to navigate uninitialized lazy-loaded relationships, triggering silent N+1 query storms.
  • Senior Best Practice: Always set spring.jpa.open-in-view = false in application.yml, and fetch all required associations eagerly in the service layer using JOIN FETCH or JPA Entity Graphs within a @Transactional(readOnly = true) method.

4 Tricky Questions

1. What happens if a repository method marked with @Modifying is called from a service having class-level @Transactional(readOnly = true)?

Answer: The invocation fails at runtime with a database transaction error:

  • In PostgreSQL: org.postgresql.util.PSQLException: ERROR: cannot execute UPDATE in a read-only transaction.
  • In Spring/JPA: InvalidDataAccessApiUsageException: Executing an update/delete query... TransactionRequiredException. Underlying Mechanism: By default propagation rules (REQUIRED), the repository method participates in the existing transaction opened by the service. Because the service configured readOnly = true, the database session was switched to read-only mode (SET TRANSACTION READ ONLY). To permit write mutations, the service method must explicitly override the class setting with @Transactional(readOnly = false).

2. Why did interface-level @Transactional work in legacy Spring Framework 3/4 projects, but fails silently in Spring Boot 2/3?

Answer: Legacy Spring Framework defaulted to standard JDK Dynamic Proxies when a bean implemented at least one interface. JDK Dynamic Proxies are built directly on the interface and inspect interface method metadata, successfully detecting interface annotations. Starting with Spring Boot 2.0+, Spring switched the default to spring.aop.proxy-target-class = true (enforcing CGLIB subclass proxying). Because Java does not inherit interface annotations onto implementing classes, CGLIB subclass inspection cannot see interface annotations, causing transactions to be skipped entirely.


3. Do timeout and rollbackFor attributes merge between class and method if a class declares timeout = 5 and a method declares rollbackFor = Exception.class?

Answer: No, they do not merge. Spring adheres to a full override policy. When an annotation is present on a method, Spring completely ignores class-level metadata. The method will run with:

  • rollbackFor = Exception.class (from the method);
  • timeout = -1 (the default value of @Transactional.timeout(), completely discarding the class-level timeout = 5).

4. What is the fundamental difference between @Transactional(readOnly = true) on the service layer versus relying on Spring Data JPA’s built-in repository read-only transactions?

Answer:

  • At Repository Level: Each Spring Data repository method (SimpleJpaRepository) opens a brief, isolated read-only transaction strictly for the duration of a single SQL SELECT.
  • At Service Level: The read-only transaction spans the entire business workflow, providing three major advantages:
    1. Consistent Snapshot: Multiple consecutive SELECT statements read from a consistent database snapshot (under Repeatable Read or via the Hibernate L1 cache).
    2. Lazy Loading Window: Uninitialized entity relationships can be safely navigated throughout the service method without throwing LazyInitializationException.
    3. Dirty Checking Elimination: Hibernate sets the persistence context FlushMode to MANUAL, completely disabling dirty checking memory scans and boosting performance.

🎯 Interview Cheat Sheet

  • Supported Levels: Method level, Class level, and Interface level.
  • Recommended Standard: Place @Transactional on public methods in the @Service layer.
  • Resolution Order: Class Method $\rightarrow$ Class $\rightarrow$ Interface Method $\rightarrow$ Interface.
  • No Attribute Merging: Method-level annotations completely replace class-level metadata; unspecified attributes reset to defaults rather than inheriting class values.
  • Interface Trap: CGLIB subclass proxies in Spring Boot completely ignore annotations on interfaces.
  • Controller Anti-Pattern: Never annotate controllers; doing so holds database connections open during HTTP transfer and JSON parsing, exhausting the connection pool.
  • OSIV Warning: Disable spring.jpa.open-in-view to prevent unexpected N+1 queries from executing outside service transaction boundaries.