Skip to main content

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โ€‹

CallbackMechanismWhen It Runs
@PostConstructAnnotationAfter dependency injection is complete
InitializingBean.afterPropertiesSet()InterfaceAfter all properties are set
Custom init-methodXML/annotation configAfter afterPropertiesSet()
@PreDestroyAnnotationBefore bean is removed from container
DisposableBean.destroy()InterfaceDuring container shutdown
Custom destroy-methodXML/annotation configAfter destroy()
@Component
public class DataSourceManager {

@PostConstruct
public void init() {
// Initialize connection pool
}

@PreDestroy
public void cleanup() {
// Close connections gracefully
}
}

ApplicationContext vs BeanFactoryโ€‹

FeatureBeanFactoryApplicationContext
Bean InstantiationLazy (on demand)Eager (at startup)
Event PropagationNoYes (ApplicationEvent)
AOP IntegrationManualBuilt-in
Internationalization (i18n)NoYes (MessageSource)
Web Context SupportNoYes (WebApplicationContext)
Resource LoadingBasicAdvanced (ResourceLoader)
Recommended ForLow-memory / embedded systemsEnterprise 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:

BeanFactoryPostProcessorBeanPostProcessor
When it runsBefore any beans are instantiatedAfter each bean is instantiated
What it modifiesBean definitions (metadata)Bean instances
ApplicationContext.getBean() safe?No โ€” triggers premature instantiationYes
Common usePropertySourcesPlaceholderConfigurer 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: BeanPostProcessor beans are instantiated very early in the context lifecycle, before other beans. Never @Autowire a late-initializing bean (e.g., JPA repositories) into a BeanPostProcessor โ€” 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โ€‹

StrategyHow It Works
Setter InjectionAllows beans to be instantiated before dependencies are set
@Lazy AnnotationDefers bean initialization until actually needed, breaking the cycle
Redesign ArchitectureIntroduce 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โ€‹

AnnotationLayerPurpose
@ComponentGenericAny Spring-managed component
@ServiceServiceBusiness logic and service tasks
@RepositoryData AccessDatabase interaction, exception translation
@ControllerPresentationWeb request handling (MVC)
@RestControllerPresentationRESTful web services (@Controller + @ResponseBody)

@Component, @Service, @Repository, and @Controller are technically interchangeable โ€” they all register beans. However, using the correct stereotype improves code clarity and enables layer-specific features (e.g., @Repository adds persistence exception translation).


Data Access: JpaRepository vs CrudRepositoryโ€‹

FeatureCrudRepositoryJpaRepository
CRUD OperationsYesYes (inherited)
Pagination & SortingNoYes
Batch OperationsNoYes (saveAll, deleteInBatch)
Flush Persistence ContextNoYes (flush(), saveAndFlush())
Best ForSimple data accessFull 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) {
// ...
}
}
AnnotationBehavior
@PrimaryMarks a bean as the default when multiple candidates exist
@QualifierExplicitly 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โ€‹

AttributePurposeDefault
propagationHow transactions relate to each otherREQUIRED
isolationTransaction isolation levelDatabase default
readOnlyHint for optimization on read-only operationsfalse
rollbackForExceptions that trigger rollbackUnchecked exceptions
timeoutMaximum time for the transactionNo 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โ€‹

ConceptDefinitionExample
AspectModule encapsulating a cross-cutting concernLoggingAspect, SecurityAspect
Join PointA point in execution where advice can runMethod call, method execution
PointcutExpression selecting which join points to interceptexecution(* com.example.service..*(..))
AdviceCode that runs at a selected join point@Before, @After, @Around
Target ObjectThe original bean being proxiedYour OrderService
ProxyThe wrapper object that intercepts callsSpring-generated CGLIB/JDK proxy
WeavingLinking aspects with target objectsSpring does this at startup (proxy-based)

Spring AOP vs AspectJโ€‹

Spring AOPAspectJ
WeavingProxy-based (runtime)Bytecode weaving (compile/load time)
Join points supportedMethod execution onlyFields, constructors, static methods, etc.
Self-invocationโŒ (bypasses proxy)โœ… (weaved into bytecode)
SetupZero config (built into Spring)Needs AspectJ compiler / agent
Best forMost enterprise use casesPerformance-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: @Transactional has Integer.MAX_VALUE order (innermost). @Validated has Integer.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:

  1. Computes the cache key from SpEL
  2. Checks the cache โ€” returns cached value if hit
  3. Calls the target method on cache miss
  4. 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 private method or called internally)

Same issue applies to: @Async, @Cacheable, @Scheduled, and any other Spring AOP annotation.


Spring WebFlux vs Spring MVCโ€‹

AspectSpring MVCSpring WebFlux
Programming ModelSynchronous, blockingAsynchronous, non-blocking
ConcurrencyThread-per-requestEvent-loop (fewer threads)
Built OnServlet APIProject Reactor
Best ForTraditional web appsHigh-concurrency, streaming
ServerTomcat, JettyNetty, 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โ€‹

ComponentRole
JobDefines the entire batch process
StepA single phase within a job
ItemReaderReads input data
ItemProcessorTransforms data
ItemWriterWrites output data
JobRepositoryStores metadata about job executions

Testing: @Mock vs @Spyโ€‹

AnnotationBehaviorUse Case
@MockFully mocked instance; no real code executesIsolating dependencies in unit tests
@SpyPartial mock wrapping a real instance; real methods execute unless overriddenTesting 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โ€‹

AspectAnnotationsXML
ReadabilityConcise, inline with codeVerbose, separate files
MaintenanceEasier โ€” part of the codebaseHarder โ€” separate from code
FlexibilityRequires recompilation for changesCan be modified without recompilation
Complex ConfigCan get clutteredBetter for complex wiring
Best ForMost modern projectsLegacy 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:

AnnotationPurpose
@AutoConfigureOrderSet explicit ordering priority
@AutoConfigureAfterLoad after a specific auto-configuration
@AutoConfigureBeforeLoad before a specific auto-configuration
@ConditionalOnMissingBeanOnly 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โ€‹

  1. Document every non-default extension with intent and rollback approach.
  2. Keep AOP and proxy layering transparent in diagnostics.
  3. 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.