Advanced · Architect

Recognize the Patterns Behind Spring Annotations

Connect proxies, strategies, templates, factories, and adapters to the behavior annotations activate.

Primary source: Spring Framework 6.2 Core Technologies: The IoC Container

40 min

Lesson 2 of 3

1d streak

The Architectural Question

Annotations read like compiler features; recognizing the Proxy, Strategy, Template Method, and Factory machinery underneath explains their limits before they bite.

What this guide proves

  • Know when a call crosses a proxy boundary
  • Prefer composition and explicit strategies in application code
  • Debug generated behavior from the underlying pattern

The Spring IoC Core: BeanPostProcessor & BeanDefinitionRegistry

To master Spring Boot, one must understand how the container constructs, configures, and proxies beans. The Spring Bean lifecycle exposes two primary extension points used by framework developers and custom starters:

1. BeanFactoryPostProcessor: Operates on bean metadata (BeanDefinition) BEFORE any bean instances are created. Examples include ConfigurationClassPostProcessor (which parses @Configuration and @Bean methods).

2. BeanPostProcessor (BPP): Operates on actual bean instances immediately before and after their initialization callback (e.g. afterPropertiesSet or @PostConstruct). This is the exact mechanism Spring uses for @Autowired injection, @Transactional proxying, and @Cacheable wrapping.

src/main/java/com/example/infra/AuditLoggingBeanPostProcessor.java
package com.example.infra;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.stereotype.Component;

@Component
public class AuditLoggingBeanPostProcessor implements BeanPostProcessor {

    private static final Logger log = LoggerFactory.getLogger(AuditLoggingBeanPostProcessor.class);

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        // Invoked BEFORE @PostConstruct or InitializingBean.afterPropertiesSet()
        return bean;
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        // Invoked AFTER bean is fully initialized. Ideal place to return dynamic proxy!
        if (bean.getClass().isAnnotationPresent(AuditMonitored.class)) {
            log.info("Registered audit monitoring proxy for bean: {}", beanName);
            // Return CGLIB or Dynamic Proxy wrapping bean
        }
        return bean;
    }
}

Never Inject High-Level Beans into a BeanPostProcessor

BeanPostProcessors are instantiated at the very beginning of the ApplicationContext lifecycle. If your BPP injects a service that requires JPA or security, that service will be instantiated prematurely, bypassing all subsequent BPPs and losing proxy behavior!

Custom AOP Interceptors: Building Enterprise Decorators

Spring AOP allows developers to extract cross-cutting concerns (rate limiting, distributed locking, audit trails) cleanly into custom annotations and aspects.

src/main/java/com/example/infra/aop/ExecutionTimeAspect.java
package com.example.infra.aop;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class ExecutionTimeAspect {

    private static final Logger log = LoggerFactory.getLogger(ExecutionTimeAspect.class);

    @Around("@annotation(logExecutionTime)")
    public Object logTime(ProceedingJoinPoint joinPoint, LogExecutionTime logExecutionTime) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            return joinPoint.proceed();
        } finally {
            long duration = System.currentTimeMillis() - start;
            if (duration > logExecutionTime.slowThresholdMs()) {
                log.warn("SLOW METHOD EXECUTION: {}.{}() took {}ms",
                    joinPoint.getSignature().getDeclaringTypeName(),
                    joinPoint.getSignature().getName(),
                    duration);
            }
        }
    }
}

The Strategy Behind Custom Spring Boot Starters

Spring Boot auto-configuration is driven by condition evaluation. When authoring internal shared libraries or starters across microservices, use Spring condition annotations to allow consuming services to override library defaults gracefully:

Condition AnnotationEvaluation LogicUse Case
@ConditionalOnMissingBeanTriggers only if no matching bean definition exists in contextProvides default fallback bean that user can override
@ConditionalOnPropertyChecks presence or value of an application.yml propertyEnables/disables starter features via config
@ConditionalOnClassChecks if specific class is present on the JVM classpathConfigures integrations only when library is added to pom.xml

Tracing Container Startup and Condition Evaluations

Inspect internal condition evaluations and startup timings using Spring Boot debug logging:

snippet.bash
# 1. Run Spring Boot with condition evaluation report enabled
./mvnw spring-boot:run -Dspring-boot.run.arguments="--debug"

# 2. Inspect ConditionEvaluationReport in console output:
# ============================
# CONDITIONS EVALUATION REPORT
# ============================
# Positive matches:
# -----------------
# AuditLoggingAutoConfiguration matched:
#    - @ConditionalOnProperty (app.audit.enabled) found property 'true'
#
# Negative matches:
# -----------------
# RedisAuditStoreAutoConfiguration:
#    - Did not match: @ConditionalOnClass did not find org.springframework.data.redis.core.RedisTemplate

Active Recall Checkpoint

Why does @ConditionalOnMissingBean allow applications to easily customize shared starter beans?

Hint: Consider how Spring Boot supplies default PasswordEncoder or ObjectMapper beans.

Show answer & explanation

If the application defines its own @Bean, Spring skips the auto-configuration method, letting the user bean take precedence without throwing DuplicateBeanDefinitionException.

Runtime Architecture & Flow

Dependency direction

Boundaries follow ownership

Architecture is the set of boundaries that keeps important decisions easy to test and expensive details replaceable.

Know when a call crosses a proxy boundary

B1

Prefer composition and explicit strategies in application code

B2

Debug generated behavior from the underlying pattern

B3

Read these as design constraints. The arrows belong in the implementation only after the dependency rule is chosen.

Working Implementation

Code Walkthrough

Trace `@Transactional`, repository creation, and message conversion to their supporting patterns.

InternalPatternsBppLab.java
package com.example.internals;

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.List;

@Configuration(proxyBeanMethods = false)
public class InternalPatternsBppLab {

    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface AuditedComponent {}

    @Component
    public static class AuditTrackingBeanPostProcessor implements BeanPostProcessor {

        private final List<String> auditedBeans = new ArrayList<>();

        @Override
        public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
            if (bean.getClass().isAnnotationPresent(AuditedComponent.class)) {
                auditedBeans.add(beanName);
            }
            return bean;
        }

        public List<String> getAuditedBeans() {
            return List.copyOf(auditedBeans);
        }
    }

    public record AuditNotifier(String channel) {}

    // Allows downstream consumers to override default notifier without duplicate bean collisions
    @Bean
    @ConditionalOnMissingBean
    public AuditNotifier defaultAuditNotifier() {
        return new AuditNotifier("console");
    }
}
BeanPostProcessor operates on bean instances after initialization, enabling custom annotation-driven wrapping and proxying.
@ConditionalOnMissingBean provides default fallback beans that consuming applications can override seamlessly.
Avoid injecting managed services into early BeanPostProcessor beans to prevent premature initialization bugs.

Synthesis & Production Verification

Production Acceptance Criteria

  • Identify the pattern behind @Transactional, repository proxies, and HttpMessageConverter selection
  • Detect proxy crossings in stack traces while debugging
  • Prefer explicit strategies over annotation tricks in application code

Architectural & Technical Review

Core Interview Questions

Q01What is the difference between BeanFactoryPostProcessor and BeanPostProcessor in the Spring lifecycle?Show answer

BeanFactoryPostProcessor runs first and modifies bean metadata (BeanDefinition) before any instances exist. BeanPostProcessor runs later and operates on actual bean instances before and after initialization callbacks, enabling dynamic proxying.

Q02Why should BeanPostProcessor implementations never inject high-level business services?Show answer

BeanPostProcessors are created very early in container startup. Injecting business services causes those services to instantiate prematurely, bypassing other BeanPostProcessors and losing AOP proxy wrapping.

Q03How does @ConditionalOnMissingBean enable shared enterprise starters to provide extensible default beans?Show answer

It evaluates bean definitions in the context. If the consuming application provides its own @Bean definition, Spring skips the starter method, allowing seamless user overrides without duplicate bean definition errors.

Production Pitfalls

Critical Anti-Patterns

  • Pitfall: Injecting managed transactional services into an early BeanPostProcessor — Forces premature bean initialization, bypassing proxy generation and breaking @Transactional advice.
  • Pitfall: Implementing custom AOP aspects without considering proxy self-invocation limitations — Internal calls within the same bean bypass custom aspects unless routed through the proxy.

Primary Documentation & Specifications