Spring Framework: Deep Dive
This page covers advanced Spring Framework concepts including the bean lifecycle, AOP, data access patterns, reactive programming, and batch processing.
Spring Bean Lifecycleโ
Understanding the bean lifecycle is crucial for optimizing resource management in large-scale applications.
Lifecycle Phasesโ
Container Start
โ Bean Definition Loading
โ Bean Instantiation
โ Dependency Injection
โ @PostConstruct / InitializingBean.afterPropertiesSet()
โ Custom init-method
โ Bean Ready for Use
โ @PreDestroy / DisposableBean.destroy()
โ Custom destroy-method
โ Bean Destroyed
Lifecycle Callbacksโ
| Callback | Mechanism | When It Runs |
|---|---|---|
@PostConstruct | Annotation | After dependency injection is complete |
InitializingBean.afterPropertiesSet() | Interface | After all properties are set |
Custom init-method | XML/annotation config | After afterPropertiesSet() |
@PreDestroy | Annotation | Before bean is removed from container |
DisposableBean.destroy() | Interface | During container shutdown |
Custom destroy-method | XML/annotation config | After destroy() |
@Component
public class DataSourceManager {
@PostConstruct
public void init() {
// Initialize connection pool
}
@PreDestroy
public void cleanup() {
// Close connections gracefully
}
}
ApplicationContext vs BeanFactoryโ
| Feature | BeanFactory | ApplicationContext |
|---|---|---|
| Bean Instantiation | Lazy (on demand) | Eager (at startup) |
| Event Propagation | No | Yes (ApplicationEvent) |
| AOP Integration | Manual | Built-in |
| Internationalization (i18n) | No | Yes (MessageSource) |
| Web Context Support | No | Yes (WebApplicationContext) |
| Resource Loading | Basic | Advanced (ResourceLoader) |
| Recommended For | Low-memory / embedded systems | Enterprise applications |
// BeanFactory (basic)
BeanFactory factory = new XmlBeanFactory(new ClassPathResource("beans.xml"));
// ApplicationContext (preferred)
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
BeanPostProcessor vs BeanFactoryPostProcessorโ
Two critical extension points that senior engineers must distinguish:
BeanFactoryPostProcessor | BeanPostProcessor | |
|---|---|---|
| When it runs | Before any beans are instantiated | After each bean is instantiated |
| What it modifies | Bean definitions (metadata) | Bean instances |
ApplicationContext.getBean() safe? | No โ triggers premature instantiation | Yes |
| Common use | PropertySourcesPlaceholderConfigurer resolving ${...} | AOP proxying, @Autowired injection |
// BeanFactoryPostProcessor โ modifies bean definitions before instantiation
@Component
public class CustomBeanFactoryPostProcessor implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory factory) {
BeanDefinition def = factory.getBeanDefinition("myService");
def.setScope(BeanDefinition.SCOPE_PROTOTYPE); // Change scope at definition level
}
}
// BeanPostProcessor โ wraps/modifies bean instances after creation
@Component
public class AuditBeanPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof AuditableService) {
return Proxy.newProxyInstance(...) // Wrap in dynamic proxy
}
return bean;
}
}
Interview trap:
BeanPostProcessorbeans are instantiated very early in the context lifecycle, before other beans. Never@Autowirea late-initializing bean (e.g., JPA repositories) into aBeanPostProcessorโ it causes premature initialization and bypasses auto-configuration.
Circular Dependenciesโ
A circular dependency occurs when two or more beans depend on each other:
Bean A โ requires Bean B โ requires Bean A โ deadlock!
Resolution Strategiesโ
| Strategy | How It Works |
|---|---|
| Setter Injection | Allows beans to be instantiated before dependencies are set |
@Lazy Annotation | Defers bean initialization until actually needed, breaking the cycle |
| Redesign Architecture | Introduce an interface or third bean to decouple |
@Component
public class ServiceA {
private ServiceB serviceB;
@Autowired
@Lazy
public void setServiceB(ServiceB serviceB) {
this.serviceB = serviceB;
}
}
Stereotype Annotationsโ
| Annotation | Layer | Purpose |
|---|---|---|
@Component | Generic | Any Spring-managed component |
@Service | Service | Business logic and service tasks |
@Repository | Data Access | Database interaction, exception translation |
@Controller | Presentation | Web request handling (MVC) |
@RestController | Presentation | RESTful web services (@Controller + @ResponseBody) |
@Component,@Service,@Repository, and@Controllerare technically interchangeable โ they all register beans. However, using the correct stereotype improves code clarity and enables layer-specific features (e.g.,@Repositoryadds persistence exception translation).
Data Access: JpaRepository vs CrudRepositoryโ
| Feature | CrudRepository | JpaRepository |
|---|---|---|
| CRUD Operations | Yes | Yes (inherited) |
| Pagination & Sorting | No | Yes |
| Batch Operations | No | Yes (saveAll, deleteInBatch) |
| Flush Persistence Context | No | Yes (flush(), saveAndFlush()) |
| Best For | Simple data access | Full JPA capabilities |
// CrudRepository โ basic CRUD
public interface UserRepository extends CrudRepository<User, Long> {
}
// JpaRepository โ full JPA features
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByStatus(OrderStatus status);
}
@Qualifier vs @Primaryโ
When multiple beans of the same type exist, Spring needs to know which one to inject.
@Configuration
public class DataSourceConfig {
@Bean
@Primary // Default choice when no qualifier specified
public DataSource primaryDataSource() {
return new HikariDataSource(primaryConfig());
}
@Bean
@Qualifier("reporting")
public DataSource reportingDataSource() {
return new HikariDataSource(reportingConfig());
}
}
@Service
public class ReportService {
// Uses the @Qualifier to pick a specific bean
public ReportService(@Qualifier("reporting") DataSource dataSource) {
// ...
}
}
| Annotation | Behavior |
|---|---|
@Primary | Marks a bean as the default when multiple candidates exist |
@Qualifier | Explicitly selects a specific bean by name |
@Transactionalโ
The @Transactional annotation defines the scope of a database transaction. All operations within the annotated method either succeed or fail together.
@Service
public class TransferService {
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
accountRepository.debit(fromId, amount);
accountRepository.credit(toId, amount);
// If credit fails, debit is rolled back
}
}
Key Attributesโ
| Attribute | Purpose | Default |
|---|---|---|
propagation | How transactions relate to each other | REQUIRED |
isolation | Transaction isolation level | Database default |
readOnly | Hint for optimization on read-only operations | false |
rollbackFor | Exceptions that trigger rollback | Unchecked exceptions |
timeout | Maximum time for the transaction | No timeout |
@Transactional Pitfall Patternsโ
These are the most common senior interview and production bug topics:
1. Checked exceptions don't roll back by default
@Transactional
public void processPayment(Order order) throws PaymentException {
paymentRepository.save(order);
paymentGateway.charge(order); // Throws PaymentException (checked)
// โ Transaction COMMITS even though exception was thrown!
// PaymentException is a checked exception โ not rolled back by default
}
// Fix:
@Transactional(rollbackFor = PaymentException.class)
public void processPayment(Order order) throws PaymentException { ... }
2. @Transactional on private or final methods is silently ignored
@Service
public class OrderService {
@Transactional // โ IGNORED โ CGLIB cannot override private/final methods
private void saveOrder(Order order) { ... }
}
// Solution: always put @Transactional on public methods
3. Transaction not applied to new threads
@Transactional
public void processOrders(List<Order> orders) {
orders.parallelStream().forEach(order -> {
orderRepository.save(order); // โ No transaction! Runs in a new thread
});
}
// The transaction context is bound to the current thread via ThreadLocal.
// New threads spawned inside a @Transactional have NO transaction.
4. REQUIRES_NEW doesn't work when called internally (self-invocation)
@Transactional
public void outer() {
inner(); // โ @Transactional(REQUIRES_NEW) on inner() is IGNORED via self-call
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() { ... } // Only works if called from ANOTHER Spring bean
5. readOnly = true is not enforced โ it's a hint
@Transactional(readOnly = true)
public User getUser(Long id) {
User user = userRepository.findById(id).orElseThrow();
user.setName("Modified"); // Hibernate may or may not flush this โ behavior is DB/driver dependent
return user;
// readOnly = true skips the "dirty check" โ Hibernate won't check for changes
// This is a performance optimization, NOT a write-guard
}
Aspect-Oriented Programming (AOP) โ Deep Diveโ
AOP modularizes cross-cutting concerns โ functionality that spans multiple classes like logging, security, and transaction management โ keeping business logic clean.
Core Conceptsโ
| Concept | Definition | Example |
|---|---|---|
| Aspect | Module encapsulating a cross-cutting concern | LoggingAspect, SecurityAspect |
| Join Point | A point in execution where advice can run | Method call, method execution |
| Pointcut | Expression selecting which join points to intercept | execution(* com.example.service..*(..)) |
| Advice | Code that runs at a selected join point | @Before, @After, @Around |
| Target Object | The original bean being proxied | Your OrderService |
| Proxy | The wrapper object that intercepts calls | Spring-generated CGLIB/JDK proxy |
| Weaving | Linking aspects with target objects | Spring does this at startup (proxy-based) |
Spring AOP vs AspectJโ
| Spring AOP | AspectJ | |
|---|---|---|
| Weaving | Proxy-based (runtime) | Bytecode weaving (compile/load time) |
| Join points supported | Method execution only | Fields, constructors, static methods, etc. |
| Self-invocation | โ (bypasses proxy) | โ (weaved into bytecode) |
| Setup | Zero config (built into Spring) | Needs AspectJ compiler / agent |
| Best for | Most enterprise use cases | Performance-critical or non-Spring code |
Key insight: Spring AOP only intercepts Spring-managed bean method calls made through the proxy. Field access, constructors, and
this.method()calls are invisible to it.
Pointcut Expression DSLโ
Mastery of pointcut expressions is essential for writing precise, production-safe aspects:
// execution โ most common: matches method execution
@Pointcut("execution(* com.example.service.*.*(..))")
// execution( [modifier] [return-type] [declaring-type.][method]([params]) )
// * = any return type
// com.example.service.*.*(..) = any method in any class in this package
// (..) = any number of params
// within โ matches all methods within a type/package
@Pointcut("within(com.example.controller..*)") // all classes in controller and subpackages
// @annotation โ matches methods annotated with a specific annotation
@Pointcut("@annotation(com.example.annotation.Auditable)")
// @within โ matches all methods within a class annotated with a given annotation
@Pointcut("@within(org.springframework.stereotype.Service)")
// bean โ matches methods on specific Spring beans (Spring AOP only)
@Pointcut("bean(orderService)")
@Pointcut("bean(*Service)") // all beans ending in "Service"
// args โ matches based on method argument types at runtime
@Pointcut("args(com.example.dto.OrderRequest, ..)")
// Combining expressions
@Pointcut("within(com.example.service..*) && !execution(* *.get*(..)))")
// all service methods except getters
Advice Typesโ
@Aspect
@Component
public class ExampleAspect {
// Runs before the method โ cannot prevent execution (use @Around for that)
@Before("execution(* com.example.service.*.*(..))")
public void beforeAdvice(JoinPoint jp) {
log.info("Calling: {}", jp.getSignature().toShortString());
}
// Runs after normal return โ receives the return value
@AfterReturning(pointcut = "execution(* com.example.service.*.*(..))", returning = "result")
public void afterReturning(JoinPoint jp, Object result) {
log.info("Returned: {}", result);
}
// Runs after an exception is thrown
@AfterThrowing(pointcut = "execution(* com.example.service.*.*(..))", throwing = "ex")
public void afterThrowing(JoinPoint jp, Exception ex) {
log.error("Exception in {}: {}", jp.getSignature(), ex.getMessage());
}
// Runs after method regardless of outcome (like finally)
@After("execution(* com.example.service.*.*(..))")
public void afterAdvice(JoinPoint jp) { }
// Wraps the method โ full control, must call proceed()
@Around("execution(* com.example.service.*.*(..))")
public Object aroundAdvice(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
try {
Object result = pjp.proceed(); // MUST call proceed() to continue
log.info("Duration: {}ms", System.currentTimeMillis() - start);
return result;
} catch (Exception e) {
log.error("Failed after {}ms", System.currentTimeMillis() - start);
throw e; // Always rethrow unless intentionally swallowing
}
}
}
Aspect Ordering (@Order)โ
When multiple aspects apply to the same join point, @Order controls execution order. Lower number = outer (runs first entering, last exiting):
@Order(1) Security Aspect โ @Order(2) Logging Aspect โ @Order(3) Transaction Aspect
โ โ โ
Target Method
โ โ โ
(last out) (second out) (first out)
@Aspect @Component @Order(1)
public class SecurityAspect { ... } // Outermost โ checks auth first
@Aspect @Component @Order(2)
public class LoggingAspect { ... } // Middle โ logs method entry/exit
@Aspect @Component @Order(3)
public class TransactionAspect { ... } // Innermost โ closest to target
In Spring's built-in aspects:
@TransactionalhasInteger.MAX_VALUEorder (innermost).@ValidatedhasInteger.MAX_VALUE - 1. Place your custom aspects with explicit lower numbers to run before them.
AOP Integration 1: Logging and MDC Tracingโ
The most valuable production AOP use case โ attaching trace IDs, user context, and timing to every log line without cluttering service code:
@Aspect
@Component
@Order(2)
public class LoggingAspect {
@Around("@within(org.springframework.stereotype.Service)")
public Object logAndTrace(ProceedingJoinPoint pjp) throws Throwable {
String method = pjp.getSignature().toShortString();
String traceId = UUID.randomUUID().toString().substring(0, 8);
// MDC: attach context to every log line in this thread
MDC.put("traceId", traceId);
MDC.put("method", method);
long start = System.currentTimeMillis();
try {
log.debug("โ {}", method);
Object result = pjp.proceed();
log.debug("โ {} [{}ms]", method, System.currentTimeMillis() - start);
return result;
} catch (Exception e) {
log.error("โ {} failed [{}ms]: {}", method, System.currentTimeMillis() - start, e.getMessage());
throw e;
} finally {
MDC.clear(); // CRITICAL: always clear MDC in finally to avoid leaks in thread pools
}
}
}
Logback pattern to use the MDC context:
<pattern>%d{HH:mm:ss} [%thread] %X{traceId} %-5level %logger - %msg%n</pattern>
Production refinement โ propagate MDC to async threads:
// MDC is ThreadLocal โ it's lost when @Async spawns a new thread
// Fix: use MDC.getCopyOfContextMap() and restore in the async thread
@Async
public void asyncTask() {
Map<String, String> context = MDC.getCopyOfContextMap();
CompletableFuture.runAsync(() -> {
if (context != null) MDC.setContextMap(context);
try { doWork(); } finally { MDC.clear(); }
});
}
AOP Integration 2: Security with @PreAuthorize and Method Securityโ
Spring Security's method-level security (@PreAuthorize, @PostAuthorize, @Secured) is implemented as an AOP aspect:
@Service
public class OrderService {
// SpEL expression evaluated against SecurityContext
@PreAuthorize("hasRole('ORDER_MANAGER') or #order.ownerId == authentication.principal.id")
public void cancelOrder(Order order) { ... }
// @PostAuthorize: runs AFTER method, can inspect return value
@PostAuthorize("returnObject.ownerId == authentication.principal.id")
public Order getOrder(Long orderId) { ... }
// @PostFilter: filters collection return values
@PostFilter("filterObject.status != 'CONFIDENTIAL' or hasRole('ADMIN')")
public List<Order> getAllOrders() { ... }
}
How it works internally:
External call โ MethodSecurityInterceptor (AOP @Around advice)
โ
Evaluates SpEL against SecurityContextHolder.getContext()
โ
AccessDecisionManager โ grants or throws AccessDeniedException
โ
pjp.proceed() โ target method executes
Custom security aspect โ audit sensitive data access:
@Aspect
@Component
@Order(1) // Before transaction opens (so we can audit before any DB write)
public class AuditSecurityAspect {
private final AuditRepository auditRepo;
@Around("@annotation(com.example.annotation.SensitiveOperation)")
public Object auditSensitiveOperation(ProceedingJoinPoint pjp) throws Throwable {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth != null ? auth.getName() : "anonymous";
String operation = pjp.getSignature().toShortString();
AuditEntry entry = AuditEntry.builder()
.username(username)
.operation(operation)
.args(Arrays.toString(pjp.getArgs()))
.timestamp(Instant.now())
.build();
try {
Object result = pjp.proceed();
entry.setOutcome("SUCCESS");
auditRepo.save(entry);
return result;
} catch (AccessDeniedException e) {
entry.setOutcome("ACCESS_DENIED");
auditRepo.save(entry);
throw e;
} catch (Exception e) {
entry.setOutcome("ERROR: " + e.getMessage());
auditRepo.save(entry);
throw e;
}
}
}
Common pitfall: @PreAuthorize on a Spring @Component method works. @PreAuthorize on a controller method called internally (this.method()) does NOT โ same self-invocation problem as @Transactional.
AOP Integration 3: Caching with @Cacheableโ
Spring's @Cacheable is an AOP around-advice that:
- Computes the cache key from SpEL
- Checks the cache โ returns cached value if hit
- Calls the target method on cache miss
- Stores the result in the cache
@Service
public class ProductService {
// key is evaluated from method args using SpEL
@Cacheable(value = "products", key = "#category + '_' + #page", unless = "#result.isEmpty()")
public List<Product> getProductsByCategory(String category, int page) {
return productRepository.findByCategory(category, PageRequest.of(page, 20));
}
// @CacheEvict: removes entries on write operations
@CacheEvict(value = "products", allEntries = true)
@Transactional
public Product updateProduct(Product product) {
return productRepository.save(product);
}
// @CachePut: always updates cache regardless of hit/miss
@CachePut(value = "products", key = "#result.id")
public Product createProduct(Product product) {
return productRepository.save(product);
}
}
Production gotchas:
// โ Self-invocation โ @Cacheable is ignored (same proxy problem)
public List<Product> getAll() {
return getProductsByCategory("electronics", 0); // NOT cached!
}
// โ @Cacheable on private/final method โ silently ignored
@Cacheable("products")
private List<Product> findFromDb() { ... } // CGLIB can't intercept
// โ Cache key collision โ different methods, same key expression
@Cacheable(value = "users", key = "#id") // User cache
@Cacheable(value = "users", key = "#id") // Order cache (different bean, same value!)
// Fix: always use distinct cache value names per entity type
Custom cache aspect for metrics:
@Aspect
@Component
@Order(10) // After Spring's @Cacheable aspect (which has lower order)
public class CacheMetricsAspect {
private final MeterRegistry meterRegistry;
@Around("@annotation(cacheable)")
public Object trackCacheUsage(ProceedingJoinPoint pjp, Cacheable cacheable) throws Throwable {
Timer.Sample sample = Timer.start(meterRegistry);
String cacheName = cacheable.value()[0];
Object result = null;
boolean cacheHit = false;
result = pjp.proceed();
// Spring's cacheable aspect has already run โ if result was cached, method wasn't called
// We'd need to hook CacheManager directly to know hit/miss precisely
sample.stop(meterRegistry.timer("cache.operation", "cache", cacheName));
return result;
}
}
AOP Integration 4: Transactions โ How @Transactional Uses AOPโ
@Transactional is an AOP around-advice implemented by TransactionInterceptor:
External call
โ CGLIB/JDK Proxy (TransactionInterceptor's @Around)
โ TransactionManager.getTransaction() (opens DB connection + begins TX)
โ target method executes (JPA calls, etc.)
โ on success: TransactionManager.commit()
โ on exception: TransactionManager.rollback() (for unchecked + configured exceptions)
Transaction propagation modeled as AOP advice chain:
// Service A @Transactional (REQUIRED) calls Service B @Transactional (REQUIRES_NEW)
// These are separate Spring beans โ TWO separate proxy invocations:
class ServiceA { class ServiceB {
TX-Proxy-A TX-Proxy-B
@Transactional REQUIRED @Transactional REQUIRES_NEW
โ โ
Begin TX-1 Suspend TX-1
โ calls ServiceB.save() Begin TX-2
โ ServiceB proxy intercepts โ execute
โ REQUIRES_NEW โ suspend TX-1 Commit TX-2
Resume TX-1
} โ Return to ServiceA
Thread boundary: transactions don't cross @Async barriers
@Service
public class ReportService {
@Transactional
public void generateReport() {
List<Order> orders = orderRepo.findAll();
// โ @Async spawns new thread โ transaction context is ThreadLocal, NOT transferred
notificationService.sendReport(orders);
// notificationService.sendReport() runs in a thread with NO transaction
}
}
AOP Integration 5: Metrics and Monitoring with Micrometerโ
AOP makes it trivial to add performance metrics without modifying business code:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Timed {
String value() default "";
String[] tags() default {};
}
@Aspect
@Component
public class MetricsAspect {
private final MeterRegistry meterRegistry;
@Around("@annotation(timed)")
public Object recordMetrics(ProceedingJoinPoint pjp, Timed timed) throws Throwable {
String metricName = timed.value().isEmpty()
? pjp.getSignature().getDeclaringTypeName() + "." + pjp.getSignature().getName()
: timed.value();
Timer.Sample sample = Timer.start(meterRegistry);
try {
Object result = pjp.proceed();
sample.stop(meterRegistry.timer(metricName,
"outcome", "success",
"class", pjp.getSignature().getDeclaringType().getSimpleName()));
return result;
} catch (Exception e) {
sample.stop(meterRegistry.timer(metricName,
"outcome", "error",
"exception", e.getClass().getSimpleName()));
meterRegistry.counter(metricName + ".errors",
"exception", e.getClass().getSimpleName()).increment();
throw e;
}
}
}
// Usage on service methods
@Service
public class OrderService {
@Timed("order.placement")
public Order placeOrder(OrderRequest req) { ... }
}
Integrating with Micrometer's @Timed directly:
// Spring Boot Actuator + Micrometer already provides @io.micrometer.core.annotation.Timed
// Enable via TimedAspect bean:
@Bean
public TimedAspect timedAspect(MeterRegistry registry) {
return new TimedAspect(registry);
}
// Then on any Spring bean method:
@Timed(value = "http.requests", extraTags = {"region", "us-east-1"}, percentiles = {0.5, 0.95, 0.99})
public Order placeOrder(OrderRequest req) { ... }
// Metrics available at /actuator/metrics/http.requests
AOP Integration 6: Retry and Circuit Breakerโ
Both Spring Retry and Resilience4j use AOP aspects under the hood:
Spring Retry (@Retryable):
@Service
public class PaymentService {
// AOP intercepts this โ retries on failure with backoff
@Retryable(
retryFor = {TransientDataAccessException.class, RemoteServiceException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2.0, maxDelay = 10000)
)
public PaymentResult charge(PaymentRequest request) {
return paymentGateway.process(request);
}
@Recover // Called when all retries exhausted
public PaymentResult chargeRecover(RemoteServiceException ex, PaymentRequest request) {
log.error("Payment failed after retries: {}", request.getId());
return PaymentResult.failed("service_unavailable");
}
}
Resilience4j with AOP:
@Service
public class InventoryService {
// Multiple AOP aspects applied โ order matters!
// Execution order (inner to outer):
// Bulkhead โ Rate Limiter โ Circuit Breaker โ Retry โ TimeLimiter โ method
@CircuitBreaker(name = "inventory", fallbackMethod = "fallbackInventory")
@Retry(name = "inventory")
@TimeLimiter(name = "inventory")
public CompletableFuture<Integer> checkStock(Long productId) {
return CompletableFuture.supplyAsync(() -> inventoryClient.getStock(productId));
}
// Fallback receives the exception as first param
public CompletableFuture<Integer> fallbackInventory(Long productId, CallNotPermittedException ex) {
log.warn("Circuit open for product {}: using cached stock", productId);
return CompletableFuture.completedFuture(stockCache.getOrDefault(productId, 0));
}
}
Resilience4j is NOT a Spring AOP aspect by default โ it has its own annotation-based AOP that requires resilience4j-spring-boot3 starter. The proxy chain stacks multiple Resilience4j interceptors.
Custom Annotation-Based Aspect Patternโ
The cleanest pattern: define your own annotation, then intercept it with an aspect โ no pointcut expressions in business code:
// 1. Define the annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
int requestsPerSecond() default 100;
String key() default ""; // SpEL for dynamic key (e.g., "#userId")
}
// 2. Binding annotation to advice via parameter binding
@Aspect
@Component
public class RateLimitAspect {
private final RateLimiterRegistry registry = RateLimiterRegistry.ofDefaults();
@Around("@annotation(rateLimit)") // Binds annotation instance to param
public Object enforceRateLimit(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
// Parse SpEL key expression
String key = resolveKey(rateLimit.key(), pjp);
RateLimiter limiter = registry.rateLimiter(key,
RateLimiterConfig.custom()
.limitForPeriod(rateLimit.requestsPerSecond())
.limitRefreshPeriod(Duration.ofSeconds(1))
.build());
return limiter.executeCallable(() -> {
try { return pjp.proceed(); }
catch (Throwable t) { throw new RuntimeException(t); }
});
}
}
// 3. Apply cleanly in service code
@Service
public class ApiService {
@RateLimit(requestsPerSecond = 50, key = "#userId")
public Response handleRequest(Long userId, Request req) { ... }
}
AOP Aspect Ordering โ Spring's Internal Stackโ
When multiple framework annotations combine, the proxy chain from outermost to innermost looks like this in a typical Spring Boot service call:
External HTTP Request
โ Spring Security (FilterChain, method security @PreAuthorize) [Order 1]
โ Logging/Tracing Aspect (MDC setup) [Order 2]
โ Metrics Aspect (@Timed) [Order 3]
โ Retry Aspect (@Retryable) [Order 4]
โ Cache Aspect (@Cacheable) [Order 5]
โ Transaction Aspect (@Transactional) [Order MAX_VALUE-1]
โ Validation Aspect (@Validated) [Order MAX_VALUE]
โ Target Method
โ validation check
โ commit / rollback
โ cache write
โ retry on failure
โ stop timer
โ clear MDC
โ security check
HTTP Response
Understanding this chain is critical for debugging puzzling behavior like:
- Why did my audit log save even though the transaction rolled back? (Audit aspect is outside transaction)
- Why is my cache returning stale data after a failed update? (CacheEvict runs even on exception by default โ use
beforeInvocation = false) - Why isn't my @Retryable working? (It's on a
privatemethod or called internally)
Same issue applies to: @Async, @Cacheable, @Scheduled, and any other Spring AOP annotation.
Spring WebFlux vs Spring MVCโ
| Aspect | Spring MVC | Spring WebFlux |
|---|---|---|
| Programming Model | Synchronous, blocking | Asynchronous, non-blocking |
| Concurrency | Thread-per-request | Event-loop (fewer threads) |
| Built On | Servlet API | Project Reactor |
| Best For | Traditional web apps | High-concurrency, streaming |
| Server | Tomcat, Jetty | Netty, Undertow |
// Spring MVC (blocking)
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userService.findById(id);
}
// Spring WebFlux (reactive)
@GetMapping("/users/{id}")
public Mono<User> getUser(@PathVariable Long id) {
return userService.findById(id);
}
Spring Batchโ
Spring Batch is a framework for processing large volumes of data efficiently โ ideal for data migration, report generation, and scheduled jobs.
Architectureโ
Job
โโโ Step 1
โ โโโ ItemReader โ reads data (DB, file, API)
โ โโโ ItemProcessor โ applies business logic
โ โโโ ItemWriter โ writes processed data
โโโ Step 2
โโโ Tasklet โ single operation step
Key Componentsโ
| Component | Role |
|---|---|
| Job | Defines the entire batch process |
| Step | A single phase within a job |
| ItemReader | Reads input data |
| ItemProcessor | Transforms data |
| ItemWriter | Writes output data |
| JobRepository | Stores metadata about job executions |
Testing: @Mock vs @Spyโ
| Annotation | Behavior | Use Case |
|---|---|---|
@Mock | Fully mocked instance; no real code executes | Isolating dependencies in unit tests |
@Spy | Partial mock wrapping a real instance; real methods execute unless overridden | Testing with some real behavior |
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private PaymentGateway paymentGateway; // Fully mocked
@Spy
private OrderValidator orderValidator; // Real logic, selectively stubbed
@InjectMocks
private OrderService orderService;
@Test
void shouldProcessOrder() {
when(paymentGateway.charge(any())).thenReturn(true);
doReturn(true).when(orderValidator).validate(any()); // Override one method
orderService.process(new Order());
verify(paymentGateway).charge(any());
}
}
Configuration: Annotations vs XMLโ
| Aspect | Annotations | XML |
|---|---|---|
| Readability | Concise, inline with code | Verbose, separate files |
| Maintenance | Easier โ part of the codebase | Harder โ separate from code |
| Flexibility | Requires recompilation for changes | Can be modified without recompilation |
| Complex Config | Can get cluttered | Better for complex wiring |
| Best For | Most modern projects | Legacy systems, external config needs |
Best practice: Use annotations for most configurations. Reserve XML for cases where external configuration without recompilation is required.
Auto-Configuration Conflictsโ
When multiple @AutoConfiguration classes define the same bean, the last one loaded takes precedence. Control ordering with:
| Annotation | Purpose |
|---|---|
@AutoConfigureOrder | Set explicit ordering priority |
@AutoConfigureAfter | Load after a specific auto-configuration |
@AutoConfigureBefore | Load before a specific auto-configuration |
@ConditionalOnMissingBean | Only create bean if it doesn't already exist |
@AutoConfiguration
@AutoConfigureAfter(DataSourceAutoConfiguration.class)
public class CustomDataSourceConfig {
@Bean
@ConditionalOnMissingBean
public DataSource dataSource() {
return new CustomDataSource();
}
}
Advanced Editorial Pass: Deep Dive with Operability Focusโ
Advanced Lensโ
- Internal extension points should be used sparingly and with clear ownership.
- Container behavior must remain explainable to on-call engineers under stress.
- Framework customization is justified only when it reduces net complexity.
Failure Scenariosโ
- Custom post-processors that alter bean semantics unexpectedly.
- Complex proxy stacks that blur transaction and security boundaries.
- Hard-to-reproduce context initialization issues across environments.
Implementation Guidanceโ
- Document every non-default extension with intent and rollback approach.
- Keep AOP and proxy layering transparent in diagnostics.
- Add minimal reproducible tests for every lifecycle customization.
Compare Nextโ
Interview Questionsโ
Q: How do you decide whether a cross-cutting concern belongs in AOP?โ
A: Use AOP for orthogonal policies such as logging, security, metrics, and transactions, not core business branching.
Q: What is the highest-impact proxy pitfall in Spring services?โ
A: Self-invocation bypasses proxies, which can silently disable @Transactional, @Async, and @Cacheable behavior.
Q: Why should bean post-processing be handled carefully?โ
A: Early lifecycle hooks can trigger premature bean creation and unstable startup order.
Q: How do you make transaction boundaries reliable in large codebases?โ
A: Keep transactional entry points explicit, public, and close to use-case orchestration boundaries.
Q: What does senior-level AOP debugging look like?โ
A: Trace advisor order, proxy type, and join-point matching before changing business code.
Q: When should you avoid creating another custom aspect?โ
A: When framework-provided mechanisms already cover the concern with lower complexity.
Q: How do method security and transaction aspects interact operationally?โ
A: Aspect order determines whether access checks happen before resource usage and transaction opening.