Definition
- CGLIB proxy -- Spring creates a subclass of your bean that overrides public methods to add transactional or async behavior; private methods cannot be overridden, so they are never intercepted.
- Self-invocation -- when a method inside a bean calls another method on the same bean using
this, the call goes directly to the real object, bypassing the proxy wrapper. - ThreadLocal context loss -- SecurityContext, MDC (logging correlation), and TransactionContext are stored in ThreadLocal, which is tied to the current thread; @Async runs on a different thread that starts with empty ThreadLocal.
- TaskDecorator -- a callback that wraps each Runnable submitted to a ThreadPoolTaskExecutor, allowing you to copy context from the caller thread to the worker thread.
- JIT compiler -- the JVM's Just-In-Time compiler that detects hot methods and compiles them from bytecode to native machine code for faster execution.
- Spring Cloud Function -- a framework that lets you write business logic as a plain Java Function and deploy it to AWS Lambda, Azure Functions, or Google Cloud Functions.
The CCTV camera at National Bank of India sits at the entrance of each department. When Priya (a customer) walks through the entrance to see a teller, the camera records the visit. But if one teller calls a colleague at the same desk (self-invocation), the camera does not see it because the call never passes through the entrance. The fix: send the colleague to a different department (separate @Service bean) so the call passes through that department's camera (proxy).
Understanding the Concepts
What is the Spring AOP proxy and why does it matter?
Spring does not modify your code. Instead, it wraps your bean in a proxy -- an invisible wrapper object that sits between the caller and your real bean. When another bean calls your @Transactional method, the call first hits the proxy. The proxy opens a transaction, calls your real method, and then commits or rolls back. Without the proxy in the middle, no transaction management happens.
Spring creates proxies using CGLIB (the default), which generates a subclass of your bean at runtime. The subclass overrides public methods to add the transactional behavior. This is why Spring AOP only works on public methods called from outside the bean.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
// What Spring actually creates at runtime (simplified)
// SettlementService$$SpringCGLIB$$0 extends SettlementService {
// @Override
// public void settle(String tradeId) {
// begin transaction
// super.settle(tradeId) // calls your real method
// commit transaction
// }
// }
@Service
public class SettlementService {
@Transactional
public void settle(String tradeId) {
// proxy intercepts this call and manages the transaction
}
}
Call flow with proxy:
Controller -> Proxy.settle() -> begin TX -> Real.settle() -> commit TX
Call flow without proxy (self-invocation):
Real.process() -> this.settle() -> no TX management
Why does @Transactional on a private method do nothing?
Java does not allow a subclass to override a private method. Since CGLIB creates a subclass, it cannot override private methods. The @Transactional annotation is silently ignored. Spring does not warn you, and no transaction is started. This is one of the most dangerous bugs in a banking application because partial data is committed without rollback.
package com.bank.service;
import com.bank.model.Settlement;
import com.bank.repository.SettlementRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BrokenSettlementService {
private final SettlementRepository repo;
public BrokenSettlementService(SettlementRepository repo) {
this.repo = repo;
}
public void process(Settlement s) {
persist(s); // self-invocation + private = no proxy, no transaction
}
@Transactional // SILENTLY IGNORED: private method
private void persist(Settlement s) {
repo.save(s.getLeg1());
repo.save(s.getLeg2()); // if this throws, leg1 is NOT rolled back
}
}
Expected: both legs saved or both rolled back.
Actual: leg1 saved, leg2 fails, leg1 stays in database. Data corruption.
Why does self-invocation bypass the proxy even for public methods?
Even if the method is public, calling it from within the same bean via this bypasses the proxy. When Rajesh calls this.persist(s), the call goes directly to the real object, not through the proxy wrapper. The proxy only intercepts calls that come from outside the bean.
The same trap applies to @Async and @Cacheable -- any annotation that relies on the proxy is ignored when called via this.
package com.bank.service;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class BrokenNotificationService {
public void onTransferComplete(String accountId) {
sendAlert(accountId); // self-invocation: runs synchronously, not async
}
@Async // IGNORED because called via this
public void sendAlert(String accountId) {
// This was supposed to run on a background thread
// but it runs on the request thread, blocking the response
emailGateway.send(accountId, "Transfer completed for account " + accountId);
}
}
Expected: sendAlert runs on a background thread from the async executor.
Actual: sendAlert runs synchronously on the request thread.
How do you fix the self-invocation problem?
The clean, preferred fix is to extract the annotated method into a separate @Service bean. This way, the call becomes a genuine external call that passes through the new bean's proxy.
package com.bank.service;
import com.bank.model.Settlement;
import org.springframework.stereotype.Service;
@Service
public class SettlementService {
private final SettlementPersister persister; // separate bean
public SettlementService(SettlementPersister persister) {
this.persister = persister;
}
public void process(Settlement s) {
persister.persist(s); // external call -> proxy intercepts -> transaction starts
}
}
package com.bank.service;
import com.bank.model.Settlement;
import com.bank.repository.SettlementRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SettlementPersister {
private final SettlementRepository repo;
public SettlementPersister(SettlementRepository repo) {
this.repo = repo;
}
@Transactional
public void persist(Settlement s) {
repo.save(s.getLeg1());
repo.save(s.getLeg2()); // if this throws, both legs roll back correctly
}
}
An alternative fix is using AopContext.currentProxy() to get a reference to the proxy and call through it. This requires exposeProxy = true in the configuration and is considered less clean.
package com.bank.service;
import org.springframework.aop.framework.AopContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SettlementServiceWithProxy {
public void process(String tradeId) {
// Get the proxy and call through it
((SettlementServiceWithProxy) AopContext.currentProxy()).settle(tradeId);
}
@Transactional
public void settle(String tradeId) {
// now the proxy intercepts this call
}
}
Fix comparison:
- Separate bean (preferred): clean separation, testable, no AOP magic.
- AopContext.currentProxy(): works but couples your code to Spring AOP internals.
- TransactionTemplate (programmatic): wraps code in a transaction without annotations.
How do you propagate context to @Async threads?
SecurityContext, MDC (Mapped Diagnostic Context for logging correlation), and TransactionContext are stored in ThreadLocal. When work jumps to a background thread via @Async, the new thread starts with empty ThreadLocal. The user's identity, the trace ID, and the transaction are all lost.
The fix is a TaskDecorator that copies context from the caller thread to the worker thread.
package com.bank.config;
import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;
import java.util.Map;
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) {
MDC.setContextMap(contextMap); // restore MDC on worker thread
}
runnable.run();
} finally {
MDC.clear(); // clean up after task completes
}
};
}
}
package com.bank.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.security.task.DelegatingSecurityContextAsyncTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncContextConfig {
@Bean(name = "asyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setTaskDecorator(new MdcTaskDecorator()); // propagate MDC
executor.initialize();
// Wrap with DelegatingSecurityContext to propagate SecurityContext
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
}
}
Without TaskDecorator:
Request thread: traceId=abc123, user=rajesh
Async thread: traceId=null, user=null (context lost)
With TaskDecorator:
Request thread: traceId=abc123, user=rajesh
Async thread: traceId=abc123, user=rajesh (context propagated)
What is JIT compilation and how does it affect performance?
JIT stands for Just-In-Time compilation. The JVM starts by interpreting bytecode, which is slow. When it detects that a method is called frequently (a hot method), the JIT compiler converts that method from bytecode to native machine code so future calls run at hardware speed.
HotSpot JVM uses two compilers: C1 compiles quickly with basic optimizations (good for startup), and C2 compiles slowly but produces highly optimized native code (good for peak throughput). Tiered compilation (the default) uses both: methods start on C1 and get promoted to C2 when they become hot enough.
package com.bank.service;
import java.math.BigDecimal;
import java.math.RoundingMode;
public class InterestCalculator {
// This method is called millions of times during batch processing
// JIT detects it as a hot method and compiles it to native code
public BigDecimal calculateDailyInterest(BigDecimal principal, BigDecimal annualRate) {
return principal.multiply(annualRate)
.divide(BigDecimal.valueOf(365), 6, RoundingMode.HALF_UP);
}
}
# See which methods the JIT compiler compiles
java -XX:+PrintCompilation -jar nbi-account-service.jar
# Example output
# 1234 3 com.bank.service.InterestCalculator::calculateDailyInterest (42 bytes)
# tier 3 = C1, tier 4 = C2
How do sessions work in stateless REST APIs?
Traditional web applications use HttpSession on the server. The server creates a session object, stores user data in it, and sends a session ID cookie to the browser. Every subsequent request carries the cookie, and the server looks up the session.
Stateless REST APIs do not use sessions at all. Instead, authentication is handled by JWT (JSON Web Token). The client sends the JWT in the Authorization header with every request. The server validates the token's signature and extracts the user identity from the claims. No server-side state is stored between requests.
package com.bank.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class StatelessConfig {
@Bean
public SecurityFilterChain chain(HttpSecurity http) throws Exception {
http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> {}));
return http.build();
}
}
Session-based (stateful):
Browser sends: Cookie: JSESSIONID=abc123
Server looks up session in memory or Redis.
JWT-based (stateless):
Client sends: Authorization: Bearer eyJhbGciOi...
Server validates signature, no session lookup needed.
What are common mistakes with proxy-based annotations?
The most dangerous mistake is putting @Transactional on a private method and assuming it works. Spring does not warn you. The second most common mistake is calling an annotated method from within the same bean.
// WRONG: @Transactional on private method -- silently ignored
@Service
public class PaymentService {
@Transactional
private void debit(String accountId, BigDecimal amount) {
// No transaction. Partial writes possible.
}
}
// WRONG: self-invocation bypasses proxy
@Service
public class PaymentService {
public void processPayment(String id) {
this.debit(id, amount); // proxy not involved
}
@Transactional
public void debit(String id, BigDecimal amount) { /* ... */ }
}
// CORRECT: separate bean, public method, external call
@Service
public class PaymentService {
private final PaymentExecutor executor;
public PaymentService(PaymentExecutor executor) {
this.executor = executor;
}
public void processPayment(String id, BigDecimal amount) {
executor.debit(id, amount); // external call -> proxy intercepts
}
}
@Service
public class PaymentExecutor {
@Transactional
public void debit(String accountId, BigDecimal amount) {
// Transaction is active. Rollback works correctly.
}
}
Another common mistake is forgetting @EnableAsync on the configuration class. Without it, all @Async annotations are completely ignored and methods run synchronously.
How do you migrate to serverless with Spring Cloud Function?
Spring Cloud Function lets Rajesh write banking logic as a plain Java Function and deploy the same code to AWS Lambda, Azure Functions, or Google Cloud Functions. The function takes an input and returns an output, with no web server.
package com.bank.function;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.function.Function;
@Configuration
public class LoanEmiFunction {
@Bean
public Function<LoanRequest, EmiResponse> calculateEmi() {
return request -> {
BigDecimal monthlyRate = request.getAnnualRate()
.divide(BigDecimal.valueOf(1200), 10, RoundingMode.HALF_UP);
int months = request.getTenureMonths();
// EMI = P * r * (1+r)^n / ((1+r)^n - 1)
BigDecimal factor = monthlyRate.add(BigDecimal.ONE).pow(months);
BigDecimal emi = request.getPrincipal()
.multiply(monthlyRate)
.multiply(factor)
.divide(factor.subtract(BigDecimal.ONE), 2, RoundingMode.HALF_UP);
return new EmiResponse(request.getAccountId(), emi);
};
}
}
AWS Lambda input:
{ "accountId": "SAV2001", "principal": 1000000, "annualRate": 8.5, "tenureMonths": 60 }
Lambda output:
{ "accountId": "SAV2001", "emi": 20517.83 }
What should a senior engineer know?
- AspectJ weaving vs CGLIB proxies. CGLIB proxies only intercept external calls to public methods. AspectJ load-time weaving can intercept private methods and self-invocations because it modifies the bytecode directly. Use it when proxy limitations are a real problem, but it adds complexity.
- TransactionTemplate as an alternative. When proxy-based @Transactional is awkward, use TransactionTemplate for programmatic transaction management. It wraps a block of code in a transaction without annotations.
- Never use SimpleAsyncTaskExecutor in production. The default @Async executor creates a new thread for every task with no upper limit. Configure a bounded ThreadPoolTaskExecutor with core, max, and queue capacity.
- @TransactionalEventListener. Use
@TransactionalEventListener(phase = AFTER_COMMIT)to fire events only after the transaction commits. This prevents sending notifications for transactions that might roll back. - JIT warmup in production. A freshly started JVM interprets bytecode for the first few thousand calls. In latency-sensitive banking services, use JVM warmup scripts or class data sharing (CDS) to reduce cold-start latency.
What are common interview follow-up questions?
- Does @Transactional work on a protected method? (Yes with CGLIB, because CGLIB can override protected methods. But Spring documentation recommends using only public methods.)
- What happens if @Async is called from another @Async method? (It works correctly as long as the call goes through the proxy -- meaning it is an external call to a different bean.)
- How do you test that a transaction actually rolls back? (Write an integration test that triggers an exception inside the @Transactional method and assert that no data was persisted.)
- What is the difference between CGLIB and JDK dynamic proxies? (JDK proxies require an interface; CGLIB creates a subclass. Spring Boot defaults to CGLIB since Spring Boot 2.0.)
Visual Overview
Proxy Interception Flow ═══════════════════════ External call (works): ┌────────────┐ ┌─────────────────┐ ┌───────────────┐ │ Controller │───►│ Proxy │───►│ Real Bean │ │ │ │ begin TX │ │ @Transactional│ │ │ │ call real method│ │ method runs │ │ │ │ commit TX │ │ │ └────────────┘ └─────────────────┘ └───────────────┘ Self-invocation (broken): ┌─────────────────┐ ┌───────────────┐ │ Proxy │ │ Real Bean │ │ (not involved) │ │ process() │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ this.persist()│ │ │ │ (no TX!) │ └─────────────────┘ └───────────────┘ Fix: Separate Bean ┌────────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐ │ Controller │───►│ Proxy A │───►│ Service │───►│ Proxy B │ │ │ │ │ │ process()│ │ begin TX │ │ │ │ │ │ │ │ persist()│ │ │ │ │ │ │ │ commit │ └────────────┘ └─────────┘ └──────────┘ └──────────┘
Key Takeaway
Spring AOP has two critical pitfalls that silently corrupt data. First, @Transactional on a private method is ignored because CGLIB creates a subclass proxy, and Java does not allow subclasses to override private methods. Second, calling a @Transactional or @Async method from within the same class via this bypasses the proxy because the call goes directly to the real object. At National Bank of India, Rajesh fixes both by extracting the annotated method into a separate @Service bean so every call passes through the proxy.
The third pitfall is ThreadLocal context loss. SecurityContext, MDC, and TransactionContext do not transfer to @Async worker threads. Rajesh registers an MdcTaskDecorator on the ThreadPoolTaskExecutor that captures the MDC map from the request thread and restores it on the worker thread. For SecurityContext, he wraps the executor with DelegatingSecurityContextAsyncTaskExecutor. The async notification runs on a dedicated bounded pool -- never the default SimpleAsyncTaskExecutor, which creates unlimited threads.
Remember one sentence: the proxy sits at the entrance, so call through a separate bean, never through this, and carry ThreadLocal context with a TaskDecorator.
---