Spring Boot Internals
Deep Dive

This section separates engineers from annotation-followers. Understanding what Spring actually does at runtime — how beans are created, how proxies intercept your method calls, how auto-configuration makes decisions, how DispatcherServlet routes a request — makes you a dramatically better debugger, a better architect, and a far more confident engineer in interviews.

Spring Boot Startup Lifecycle

When you call SpringApplication.run(), a precise sequence of events unfolds over hundreds of milliseconds. Understanding this sequence tells you why certain beans aren’t available at certain times, why @PostConstruct fires when it does, and where to hook in your own initialisation logic.

Complete Spring Boot Startup Sequence
1. SpringApplication.run()
Creates SpringApplication instance. Determines ApplicationContext type (servlet/reactive). Loads SpringApplicationRunListeners from spring.factories.
2. Environment Preparation
Creates and populates the Environment. Loads application.properties / application.yml. Processes active profiles. Fires EnvironmentPreparedEvent.
3. ApplicationContext Creation
Creates AnnotationConfigServletWebServerApplicationContext. Registers BeanDefinitionRegistryPostProcessors. Fires ApplicationContextInitializedEvent.
4. Bean Definition Loading
Component scanning discovers @Component, @Service, @Repository, @Controller. Configuration classes processed. @Import and @ImportResource handled. Auto-configuration loaded from META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.
5. BeanFactory Post-Processing
ConfigurationClassPostProcessor processes @Configuration classes. PropertySourcesPlaceholderConfigurer resolves ${...} placeholders. All BeanFactoryPostProcessors run.
6. Singleton Bean Instantiation
All singleton beans instantiated, dependencies injected, @Autowired resolved. BeanPostProcessors wrap beans (AOP proxies created here). @PostConstruct methods invoked.
7. Web Server Start
Embedded Tomcat/Jetty/Undertow started. DispatcherServlet registered. HandlerMappings built. ApplicationReadyEvent fired.
8. Application Ready
CommandLineRunner and ApplicationRunner beans execute. ApplicationStartedEvent fires. Application accepts HTTP traffic.
Why Startup Time Matters at Scale

In Kubernetes, a pod must pass its readiness probe before receiving traffic. Slow startup means longer deploy times, slower scaling, and longer recovery after crashes. Spring Boot 3.x introduced GraalVM Native Image compilation which reduces startup from seconds to milliseconds by compiling to native code at build time — eliminating JVM warmup and classpath scanning entirely. The tradeoff: slower build time and reflection-based code requires AOT hints.

IoC Container & BeanFactory Internals

Spring’s core is the Inversion of Control container — the object that owns and manages the lifecycle of your beans. Understanding how it works internally demystifies every Spring behaviour you’ve ever found confusing: circular dependencies, prototype vs singleton scope, lazy vs eager initialisation, and why certain beans can’t be injected in certain contexts.

BeanDefinition — The Blueprint

Before any bean is instantiated, Spring creates a BeanDefinition object for each bean. This is a metadata object that describes everything about the bean — its class, scope, constructor arguments, property values, init/destroy methods, and whether it’s lazy. The BeanFactory uses BeanDefinitions as blueprints to construct beans on demand.

BeanDefinition Internals — What Spring Stores
// This is what Spring internally builds from @Service UserService.class
BeanDefinition bd = new RootBeanDefinition(UserService.class);
bd.setScope(BeanDefinition.SCOPE_SINGLETON);
bd.setLazyInit(false);
bd.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE);
// Constructor dependencies discovered via reflection
bd.getConstructorArgumentValues()
    .addGenericArgumentValue(new RuntimeBeanReference("userRepository"));
bd.setInitMethodName("init");    // if @PostConstruct present
bd.setDestroyMethodName("cleanup"); // if @PreDestroy present

// You can programmatically inspect/modify bean definitions:
ConfigurableApplicationContext ctx = ...;
ConfigurableListableBeanFactory factory = ctx.getBeanFactory();

BeanDefinition userServiceDef = factory.getBeanDefinition("userService");
System.out.println(userServiceDef.getScope());         // singleton
System.out.println(userServiceDef.getBeanClassName()); // com.example.UserService
System.out.println(userServiceDef.isLazyInit());       // false

// Override a bean definition at runtime (useful in tests)
// This is how @TestConfiguration works
GenericBeanDefinition override = new GenericBeanDefinition();
override.setBeanClass(MockUserRepository.class);
((BeanDefinitionRegistry) factory).registerBeanDefinition("userRepository", override);

The Bean Creation Pipeline

When Spring needs to create a singleton bean, it passes through a precise pipeline. Every step has an extension point — this is how Spring’s own features (AOP, transactions, caching, async) are implemented without modifying your code.

Bean Creation Pipeline
getBean("orderService")
  ↓ Check singleton cache (singletonObjects map)
  ↓ If not cached: createBean()
  ↓ InstantiationAwareBeanPostProcessor.postProcessBeforeInstantiation()
  ↓ Instantiate via constructor (reflection: Constructor.newInstance())
  ↓ InstantiationAwareBeanPostProcessor.postProcessAfterInstantiation()
  ↓ Populate properties: resolve @Autowired fields via AutowiredAnnotationBeanPostProcessor
  ↓ BeanPostProcessor.postProcessBeforeInitialization()
  ↓ Call @PostConstruct methods (CommonAnnotationBeanPostProcessor)
  ↓ Call afterPropertiesSet() if implements InitializingBean
  ↓ BeanPostProcessor.postProcessAfterInitialization()AOP PROXY WRAPPING HAPPENS HERE
  ↓ Store in singletonObjects cache
  ↓ Return (possibly wrapped) bean
Implementing a Custom BeanPostProcessor
/**
 * BeanPostProcessor intercepts every bean after creation.
 * Spring uses this same mechanism for @Transactional, @Async, @Cacheable.
 * Here: log the time taken to initialise each bean (startup profiling).
 */
@Component
public class BeanInitTimingPostProcessor implements BeanPostProcessor {

    private final Map<String, Long> startTimes = new ConcurrentHashMap<>();

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) {
        startTimes.put(beanName, System.currentTimeMillis());
        return bean; // Return the same bean — don't replace it here
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        Long start = startTimes.remove(beanName);
        if (start != null) {
            long elapsed = System.currentTimeMillis() - start;
            if (elapsed > 100) { // Log only slow beans
                log.warn("Slow bean init: {} took {}ms", beanName, elapsed);
            }
        }
        return bean; // To wrap with a proxy, return a proxy object here instead
    }
}

/**
 * BeanDefinitionRegistryPostProcessor runs BEFORE beans are instantiated.
 * Use it to programmatically register additional bean definitions.
 * This is how @ComponentScan actually works internally.
 */
@Component
public class CustomBeanRegistrar implements BeanDefinitionRegistryPostProcessor {

    @Override
    public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
        // Register a bean programmatically — no @Component annotation needed
        RootBeanDefinition def = new RootBeanDefinition(DynamicService.class);
        def.setScope(BeanDefinition.SCOPE_SINGLETON);
        registry.registerBeanDefinition("dynamicService", def);
    }

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
        // Modify existing bean definitions here
    }
}

Auto-Configuration Engine Internals

Auto-configuration is the mechanism behind @SpringBootApplication that automatically sets up your DataSource, JPA, Security, Redis client, and hundreds of other components based on what’s on the classpath. Understanding how it works lets you debug it, extend it, and avoid its common failure modes.

How Auto-Configuration Is Discovered

Spring Boot reads a manifest file on the classpath to discover auto-configuration candidates. In Spring Boot 3.x, this file is META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (in Spring Boot 2.x it was META-INF/spring.factories). Every starter JAR ships one of these files listing the auto-configuration classes it provides.

META-INF/spring/...AutoConfiguration.imports (from spring-boot-autoconfigure.jar)
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration
org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
# ... 150+ more entries

@Conditional — How Auto-Config Makes Decisions

Every auto-configuration class is guarded by one or more @Conditional annotations. These are evaluated before any bean in the configuration class is created. If the condition is false, the entire configuration class is skipped.

DataSourceAutoConfiguration — Simplified Internals
@AutoConfiguration
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
// Only activate if DataSource.class is on the classpath (spring-jdbc present)
@ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory")
// Don't activate if using reactive R2DBC
@EnableConfigurationProperties(DataSourceProperties.class)
@Import({ DataSourcePoolMetadataProvidersConfiguration.class,
          DataSourceInitializationConfiguration.class })
public class DataSourceAutoConfiguration {

    @Configuration(proxyBeanMethods = false)
    @Conditional(EmbeddedDatabaseCondition.class)
    // Activates if an embedded DB (H2, HSQL) is on the classpath
    protected static class EmbeddedDatabaseConfiguration {
        @Bean
        @ConditionalOnMissingBean
        // Only create if NO DataSource bean already exists — user config wins
        public DataSource dataSource(DataSourceProperties properties) {
            return new EmbeddedDatabaseBuilder()
                .setType(properties.determineEmbeddedDatabaseType())
                .build();
        }
    }

    @Configuration(proxyBeanMethods = false)
    @Conditional(PooledDataSourceCondition.class)
    // Activates if HikariCP, Tomcat DBCP, or DBCP2 is on classpath
    protected static class PooledDataSourceConfiguration {
        @Bean
        @ConditionalOnMissingBean
        public DataSource dataSource(DataSourceProperties properties) {
            // DataSourceBuilder picks the best pool (HikariCP preferred)
            return DataSourceBuilder
                .create(properties.getClassLoader())
                .type(properties.getType())
                .url(properties.determineUrl())
                .username(properties.determineUsername())
                .password(properties.determinePassword())
                .build();
        }
    }
}

// The key conditions you see everywhere:
// @ConditionalOnClass(Foo.class)       — class must be on classpath
// @ConditionalOnMissingClass("Foo")    — class must NOT be on classpath
// @ConditionalOnBean(FooService.class) — a FooService bean must exist
// @ConditionalOnMissingBean            — NO bean of this type may exist yet
// @ConditionalOnProperty("app.feature.enabled", havingValue="true")
// @ConditionalOnWebApplication         — must be a web application
// @ConditionalOnExpression("#{...}")   — SpEL expression must be true

Writing Your Own Auto-Configuration

Any library can ship auto-configuration. If you write a shared library used across microservices, you can ship an auto-configuration class that sets up common beans automatically when the library is on the classpath:

AuditLibraryAutoConfiguration.java
@AutoConfiguration
@ConditionalOnClass(AuditService.class)
@ConditionalOnProperty(prefix = "audit", name = "enabled",
                       havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(AuditProperties.class)
public class AuditLibraryAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean  // User can override by defining their own AuditService
    public AuditService auditService(AuditProperties props,
                                     ApplicationEventPublisher publisher) {
        return new DefaultAuditService(props, publisher);
    }

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnBean(DataSource.class)  // Only create if JDBC is available
    public AuditRepository auditRepository(DataSource dataSource) {
        return new JdbcAuditRepository(dataSource);
    }
}

// Register in: src/main/resources/META-INF/spring/
//   org.springframework.boot.autoconfigure.AutoConfiguration.imports
// Content: com.yourlib.audit.AuditLibraryAutoConfiguration
Debugging Auto-Configuration Decisions

Run your app with --debug or set logging.level.org.springframework.boot.autoconfigure=DEBUG. Spring Boot prints a Conditions Evaluation Report showing exactly which auto-configurations were matched, which were excluded, and why. This is the fastest way to debug "why isn’t my DataSource being configured" or "why is Security activating when I don’t want it".

AOP, Proxies, and the @Transactional Illusion

When you annotate a method with @Transactional, @Cacheable, or @Async, Spring does not modify your class. Instead, it wraps your bean in a proxy object at startup. When a caller invokes a method on that proxy, the proxy intercepts the call, executes the advice (begin transaction, check cache, submit to thread pool), and then delegates to your real object. Understanding this mechanism is essential because it directly causes the most mysterious Spring bugs.

Two Types of Proxies

JDK Dynamic Proxy vs CGLIB Proxy
JDK Dynamic Proxy
Works by implementing the same interfaces as your bean. The proxy is an implementation of the interface, not a subclass.
interface UserService { ... }
class UserServiceImpl implements UserService { ... }
// Proxy: class $Proxy42 implements UserService
// Calls: UserService bean = proxy; ✓
// Calls: UserServiceImpl bean = proxy; ✗ ClassCastException
Requires interface. Spring uses this when bean implements at least one interface.
CGLIB Proxy
Works by generating a subclass of your bean at runtime using bytecode manipulation. No interface required.
class OrderService { ... }
// Proxy: class OrderService$$SpringCGLIB$$0
// extends OrderService
// Calls: OrderService bean = proxy; ✓
// final methods: NOT proxied (cannot override)
Spring Boot default since 2.x. Cannot proxy final classes or final methods.

The Self-Invocation Problem

The most common @Transactional bug. When a bean calls its own method, the call goes directly to this — bypassing the proxy entirely. The proxy never intercepts it, so the transaction never starts.

The Self-Invocation Bug — and the Fix
@Service
public class OrderService {

    // BROKEN: self-invocation — @Transactional on processOrder() is IGNORED
    // because this.processOrder() bypasses the proxy
    public void placeAndProcess(Order order) {
        orderRepository.save(order);
        this.processOrder(order); // Calls the REAL object, not the proxy
        // No transaction wraps processOrder() — it runs without a transaction
    }

    @Transactional  // Never fires when called from placeAndProcess above
    public void processOrder(Order order) {
        paymentService.charge(order);
        inventoryService.deduct(order);
    }
}

// FIX 1: Inject self — forces calls through the proxy
@Service
public class OrderService {

    @Autowired
    @Lazy  // Lazy to avoid circular dependency
    private OrderService self; // Injected proxy, not 'this'

    public void placeAndProcess(Order order) {
        orderRepository.save(order);
        self.processOrder(order); // Goes through proxy — @Transactional fires
    }

    @Transactional
    public void processOrder(Order order) { ... }
}

// FIX 2: Extract to a separate bean (cleaner, recommended)
@Service
@RequiredArgsConstructor
public class OrderPlacementService {

    private final OrderService orderService; // Different bean = proxy

    public void placeAndProcess(Order order) {
        orderRepository.save(order);
        orderService.processOrder(order); // Goes through proxy — correct
    }
}

// FIX 3: Use AopContext.currentProxy() — least clean, avoid if possible
@Service
public class OrderService {

    public void placeAndProcess(Order order) {
        ((OrderService) AopContext.currentProxy()).processOrder(order);
        // Requires @EnableAspectJAutoProxy(exposeProxy = true)
    }
}

How @Transactional Actually Works

Transaction Proxy — Simplified Internal Implementation
// This is effectively what Spring generates as your @Transactional proxy:
public class OrderService$$SpringCGLIB$$0 extends OrderService {

    private final TransactionInterceptor txInterceptor; // injected by Spring

    @Override
    public Order placeOrder(CreateOrderRequest request) {
        // Before invocation: begin transaction
        TransactionInfo txInfo = txInterceptor.createTransactionIfNecessary(
            txAttributeSource.getTransactionAttribute(method, targetClass),
            joinpointIdentification
        );

        Object retVal;
        try {
            // Invoke the real method on the actual object
            retVal = super.placeOrder(request); // calls YOUR code

            // After successful return: commit
            txInterceptor.commitTransactionAfterReturning(txInfo);
            return (Order) retVal;

        } catch (Throwable ex) {
            // On exception: rollback (if exception matches rollbackFor)
            txInterceptor.completeTransactionAfterThrowing(txInfo, ex);
            throw ex;
        } finally {
            txInterceptor.cleanupTransactionInfo(txInfo);
        }
    }
}

// Transaction propagation controls what happens when a @Transactional method
// calls another @Transactional method:
//
// REQUIRED (default): join existing tx; create new if none exists
// REQUIRES_NEW:       always suspend current tx; create a brand-new tx
// NESTED:             create a savepoint inside current tx (partial rollback)
// SUPPORTS:           join if exists; run non-transactionally if none
// NOT_SUPPORTED:      always run non-transactionally; suspend current if exists
// MANDATORY:          must run inside existing tx; throw if none
// NEVER:              must NOT run inside tx; throw if one exists
@Transactional on Private Methods — Silently Does Nothing

CGLIB proxies work by overriding methods in a subclass. You cannot override a private or final method. If you annotate a private method with @Transactional, Spring will NOT throw an error — it will silently create the bean, ignore the annotation, and your code will run without a transaction. This is one of the most insidious Spring bugs because there is no warning. Always annotate public methods with @Transactional.

DispatcherServlet & Request Mapping Internals

DispatcherServlet is the single entry point for all HTTP requests in a Spring MVC application. It acts as a Front Controller — receiving every request and delegating to the appropriate handler. Understanding how it works explains why @RequestMapping works, how argument resolution happens, and where you can hook in custom behaviour.

HTTP Request Journey Through DispatcherServlet
HTTP GET /api/orders/123
  ↓ Tomcat connector accepts TCP connection
  ↓ Tomcat creates HttpServletRequest
  ↓ Filter chain executes (SecurityFilter, MDCFilter, CORSFilter, ...)
  ↓ DispatcherServlet.doDispatch()
    ↓ HandlerMapping.getHandler() — finds OrderController.getOrder() method
      ← RequestMappingHandlerMapping checks all @RequestMapping registrations
      ← Returns HandlerExecutionChain (handler + list of HandlerInterceptors)
    ↓ HandlerInterceptor.preHandle() runs (authentication, rate limiting, ...)
    ↓ HandlerAdapter.handle() — invokes the controller method
      ← HandlerMethodArgumentResolver resolves @PathVariable, @RequestBody, @AuthPrincipal
      ← Calls OrderController.getOrder(UUID orderId)
      ← Method returns OrderResponse (or ResponseEntity)
    ↓ HandlerInterceptor.postHandle() runs
    ↓ HandlerMethodReturnValueHandler processes return value
      ← @ResponseBody: HttpMessageConverter serialises to JSON (Jackson)
      ← ResponseEntity: sets status code and headers
    ↓ HandlerInterceptor.afterCompletion() runs
  ↓ HttpServletResponse flushed to Tomcat
HTTP 200 OK { "id": "123", ... }

HandlerMethodArgumentResolver — How @RequestBody Works

Custom HandlerMethodArgumentResolver
// Spring resolves method parameters using HandlerMethodArgumentResolver.
// @RequestBody uses RequestResponseBodyMethodProcessor internally.
// Here's how to write your own — to inject a custom object from the request:

@Component
public class CurrentTenantArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        // This resolver handles parameters annotated with @CurrentTenant
        return parameter.hasParameterAnnotation(CurrentTenant.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter,
                                   ModelAndViewContainer mavContainer,
                                   NativeWebRequest webRequest,
                                   WebDataBinderFactory binderFactory) {

        HttpServletRequest request =
            (HttpServletRequest) webRequest.getNativeRequest();

        // Extract tenant from JWT claims (already parsed by SecurityFilter)
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth instanceof JwtAuthenticationToken jwt) {
            String tenantId = jwt.getToken().getClaimAsString("tenant_id");
            return new TenantContext(tenantId);
        }

        if (parameter.hasParameterAnnotation(CurrentTenant.class) &&
            parameter.getParameterAnnotation(CurrentTenant.class).required()) {
            throw new MissingTenantException("No tenant context in JWT");
        }
        return null;
    }
}

// Register it:
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new CurrentTenantArgumentResolver());
    }
}

// Use it in controllers:
@GetMapping("/orders")
public List<OrderResponse> getOrders(@CurrentTenant TenantContext tenant) {
    return orderService.getOrdersForTenant(tenant.getId());
}

HttpMessageConverter — JSON Serialisation Internals

Custom HttpMessageConverter
// HttpMessageConverters translate between Java objects and HTTP body bytes.
// MappingJackson2HttpMessageConverter handles JSON.
// You can customise it to change serialisation behaviour globally:

@Configuration
public class JacksonConfig {

    @Bean
    public ObjectMapper objectMapper() {
        return JsonMapper.builder()
            // Serialize dates as ISO-8601 strings, not timestamps
            .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
            // Don't fail on unknown JSON properties
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
            // Omit null fields from JSON output
            .serializationInclusion(JsonInclude.Include.NON_NULL)
            // Register Java 8 date/time types
            .addModule(new JavaTimeModule())
            // Register Kotlin module if using Kotlin
            // .addModule(new KotlinModule.Builder().build())
            .build();
    }
}

// Custom serialiser for Money value object:
public class MoneySerializer extends JsonSerializer<Money> {

    @Override
    public void serialize(Money money, JsonGenerator gen,
                           SerializerProvider provider) throws IOException {
        gen.writeStartObject();
        gen.writeNumberField("amount",
            money.getAmount().setScale(2, RoundingMode.HALF_UP));
        gen.writeStringField("currency", money.getCurrency());
        gen.writeEndObject();
    }
}
// Result: { "amount": 49.99, "currency": "USD" }
// Instead of the default embedded object serialisation

Reflection & Annotation Processing Internals

Spring is built almost entirely on Java Reflection. Component scanning, dependency injection, @Value injection, transaction interception — all of it uses java.lang.reflect to inspect and manipulate classes at runtime. Understanding reflection explains Spring’s startup cost, why it can’t work with private fields in modules, and how to write your own annotation-driven features.

How @Autowired Field Injection Works

AutowiredAnnotationBeanPostProcessor — Simplified
// When Spring processes @Autowired, it does this (simplified):

public class AutowiredAnnotationBeanPostProcessor implements BeanPostProcessor {

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) {
        Class<?> clazz = bean.getClass();

        // Walk the entire class hierarchy looking for @Autowired fields
        while (clazz != null && clazz != Object.class) {
            for (Field field : clazz.getDeclaredFields()) {
                if (field.isAnnotationPresent(Autowired.class)) {
                    injectField(field, bean);
                }
            }
            clazz = clazz.getSuperclass();
        }
        return bean;
    }

    private void injectField(Field field, Object bean) {
        // Make the field accessible even if it's private
        field.setAccessible(true); // This bypasses Java access control

        // Resolve the dependency from the bean factory
        Object dependency = beanFactory.getBean(field.getType());

        try {
            field.set(bean, dependency); // Inject via reflection
        } catch (IllegalAccessException e) {
            throw new BeanCreationException("Cannot inject field: " + field.getName());
        }
    }
}

// WHY CONSTRUCTOR INJECTION IS PREFERRED:
// 1. No reflection needed — constructor is called during normal instantiation
// 2. Dependencies are explicit and visible
// 3. Makes the bean testable without a Spring context
// 4. Enforces non-null dependencies at construction time
// 5. Works with Java module system (no setAccessible needed)
//
// @Autowired field injection requires setAccessible(true) —
// this breaks with Java 9+ strong module encapsulation

Writing Your Own Annotation

Custom @AuditAction Annotation with AOP
// Step 1: Define the annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME) // Must be RUNTIME for reflection/AOP to see it
@Documented
public @interface AuditAction {
    String value();          // action name, e.g. "CREATE_ORDER"
    boolean logArgs() default false;
}

// Step 2: Write the AOP aspect that processes the annotation
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class AuditAspect {

    private final AuditEventRepository auditRepository;

    // Pointcut: any method annotated with @AuditAction
    @Around("@annotation(auditAction)")
    public Object auditMethod(ProceedingJoinPoint pjp,
                               AuditAction auditAction) throws Throwable {

        String userId = SecurityContextHolder.getContext()
            .getAuthentication().getName();
        String action = auditAction.value();
        Instant startTime = Instant.now();

        try {
            Object result = pjp.proceed(); // Execute the actual method

            // Record successful audit event
            auditRepository.save(AuditEvent.builder()
                .action(action)
                .userId(userId)
                .status("SUCCESS")
                .durationMs(Duration.between(startTime, Instant.now()).toMillis())
                .arguments(auditAction.logArgs() ?
                    Arrays.toString(pjp.getArgs()) : null)
                .build());

            return result;

        } catch (Exception e) {
            // Record failed audit event
            auditRepository.save(AuditEvent.builder()
                .action(action)
                .userId(userId)
                .status("FAILED")
                .errorMessage(e.getMessage())
                .build());
            throw e; // Re-throw — don't swallow the exception
        }
    }
}

// Step 3: Use it
@RestController
public class OrderController {

    @PostMapping("/orders")
    @AuditAction("CREATE_ORDER")  // Spring AOP intercepts this call
    public OrderResponse placeOrder(@RequestBody CreateOrderRequest request) {
        return orderService.placeOrder(request);
    }
}

Component Scanning & @Configuration Internals

Component scanning discovers your classes and registers them as bean definitions. @Configuration classes are special — they are themselves proxied by CGLIB to enforce singleton semantics. Understanding this explains one of Spring’s most surprising behaviours.

The @Bean Method Interception

@Configuration vs @Component — A Critical Difference
// @Configuration classes are CGLIB-proxied to intercept @Bean method calls
// This ensures each @Bean method returns the SAME instance (singleton guarantee)

@Configuration
public class AppConfig {

    @Bean
    public DataSource dataSource() {
        return new HikariDataSource(hikariConfig());
    }

    @Bean
    public HikariConfig hikariConfig() {
        // Returns a new HikariConfig object...
        return new HikariConfig();
    }

    @Bean
    public JdbcTemplate jdbcTemplate() {
        // Calls dataSource() — but thanks to CGLIB proxy,
        // this returns the SAME DataSource bean from the container,
        // NOT a new one. Spring intercepts this call and returns the cached bean.
        return new JdbcTemplate(dataSource());
    }
}
// dataSource() is called twice (once for dataSource bean, once from jdbcTemplate)
// But only ONE DataSource is created. This is the @Configuration proxy magic.

// @Component does NOT get this proxy treatment:
@Component
public class AppConfig {  // NOT @Configuration!

    @Bean
    public DataSource dataSource() { return new HikariDataSource(); }

    @Bean
    public JdbcTemplate jdbcTemplate() {
        // This calls the REAL dataSource() method — creates a SECOND DataSource!
        // Two connection pools! Two separate databases! Memory leak!
        return new JdbcTemplate(dataSource());
    }
}
// Rule: ALWAYS use @Configuration (not @Component) for classes with @Bean methods
// that call other @Bean methods. Use @Configuration(proxyBeanMethods=false)
// only when you are certain no @Bean method calls another @Bean method.

Circular Dependency Resolution

Circular Dependencies — How Spring Detects and Resolves Them
// BROKEN: Circular dependency with constructor injection (throws BeanCreationException)
@Service
public class ServiceA {
    public ServiceA(ServiceB b) { ... } // A needs B
}

@Service
public class ServiceB {
    public ServiceB(ServiceA a) { ... } // B needs A — circular!
}
// Spring throws: The dependencies of some of the beans in the application context
// form a cycle: serviceA → serviceB → serviceA

// Spring CAN resolve circular dependencies with FIELD injection (it uses
// a "early reference" cache of partially-constructed beans), but this is
// a code smell indicating poor design.

// CORRECT FIX 1: Break the cycle with @Lazy
@Service
public class ServiceA {
    public ServiceA(@Lazy ServiceB b) { ... }
    // ServiceB is created lazily on first call, not at startup
}

// CORRECT FIX 2: Use setter injection for one dependency
@Service
public class ServiceA {
    private ServiceB b;

    @Autowired
    public void setServiceB(ServiceB b) { this.b = b; }
}

// CORRECT FIX 3: Extract shared logic to a third service (best — fixes the design)
@Service
public class SharedService { ... } // Contains what both A and B needed from each other

@Service
public class ServiceA {
    public ServiceA(SharedService shared) { ... }
}

@Service
public class ServiceB {
    public ServiceB(SharedService shared) { ... }
}
// No cycle. Better design. Always prefer this approach.

Performance Implications of Spring Internals

Spring’s abstractions have real performance costs. None of them are problems for 99% of applications — but knowing they exist lets you diagnose edge cases and make informed architectural decisions.

Reflection Cost and How Spring Mitigates It

Reflection Performance Benchmarks
// Reflection was historically 10-50x slower than direct method calls.
// Modern JVMs (Java 17+) have closed this gap significantly via
// Method Handles and inlining. Spring mitigates remaining costs by:

// 1. Caching reflection lookups
//    Spring caches Method objects, Field objects, and Constructor objects
//    after the first lookup. Subsequent calls use cached references.

// 2. Generating bytecode at startup instead of reflecting at runtime
//    Spring's JIT-friendly code generation means hot paths don't use
//    reflection after the first few invocations.

// 3. AOT compilation (Spring Boot 3.x)
//    @SpringBootApplication AOT processing generates source code for
//    bean registration, eliminating runtime classpath scanning and
//    reflection entirely when compiled to GraalVM native image.

// You can measure reflection overhead with:
// -Djdk.reflect.useDirectMethodHandle=true (Java 17+)
// This switches to MethodHandle-based reflection which is JIT-inlinable

// BENCHMARK (approximate, JVM-warmed):
// Direct method call:       1ns
// Cached Method.invoke():   3-5ns
// Uncached reflection:      100-500ns
// For 10,000 req/s, uncached reflection adds ~5ms/req — noticeable
// For 10,000 req/s, cached reflection adds ~0.05ms/req — negligible

Startup Optimisation Techniques

application.yml — Startup Optimisation
spring:
  # Lazy initialisation: beans created only when first needed, not at startup
  # Reduces startup time by 20-40%, but first request is slower (cold start)
  # Use for non-critical beans. NEVER for beans needed for health checks.
  main:
    lazy-initialization: true

  # Background initialisation: initialise some beans in background threads
  # while other startup work continues in the main thread
  threads:
    virtual:
      enabled: true  # Enable virtual threads (Java 21+) for Tomcat threads

jpa:
  # Disable open-session-in-view (OSIV) — it keeps a DB transaction open
  # for the entire HTTP request lifecycle. Causes connection pool exhaustion.
  # Off by default in Spring Boot 3.x, but check your config.
  open-in-view: false

Internals-Caused Production Bugs

Bug 1: @Transactional on the Wrong Layer

@Transactional only works on Spring-managed beans called through a proxy. Placing it on a utility class instantiated with new, a @Component method called via self-invocation, or a private method silently does nothing. Always verify with the debugger that a transaction is active by checking TransactionSynchronizationManager.isActualTransactionActive() in tests.

Bug 2: Prototype Bean Injected into Singleton

A singleton bean is created once. If it holds a reference to a prototype bean via @Autowired, the prototype is injected once at startup — and that single instance is shared for the lifetime of the singleton, defeating the purpose of prototype scope. Fix: inject the ApplicationContext and call ctx.getBean(PrototypeBean.class) each time you need a fresh instance, or use ObjectProvider<PrototypeBean>.

Bug 3: @PostConstruct Runs Before Dependencies Are Ready

@PostConstruct runs after the bean’s own dependencies are injected, but not necessarily after all other beans in the context are fully initialised. If your @PostConstruct method calls another bean that itself has not completed initialisation, you get BeanCreationException or incorrect behaviour. Use ApplicationReadyEvent instead if you need all beans to be fully ready before running initialisation logic.

Bug 4: Casting to Concrete Class When Bean Is Proxied

If Spring wraps your bean with a JDK dynamic proxy (because it implements an interface and proxyTargetClass=false), injecting it as the concrete class causes ClassCastException. Always inject by interface when using JDK proxies. Spring Boot defaults to CGLIB proxies (proxyTargetClass=true) which allows injection by concrete class, but you may encounter JDK proxies in legacy configurations or when using certain integrations.

Bug 5: SpEL in @Value Not Resolving

@Value("${app.name}") is resolved by PropertySourcesPlaceholderConfigurer, which is a BeanFactoryPostProcessor. It runs early — before most beans are created. If your custom @Configuration class is not loaded early enough, or if the property source it depends on is registered too late, the ${} placeholder remains unresolved and you see the literal string ${app.name} in your bean. Fix: ensure property sources are registered in an EnvironmentPostProcessor (runs before the context is refreshed) rather than in a regular @Bean method.

Interview Preparation

Internals questions appear in senior and staff interviews where interviewers want to distinguish engineers who use Spring from engineers who understand it. These questions also appear in debugging rounds where you are given a broken Spring application and must explain what’s wrong.

Q: A method annotated with @Transactional does not start a transaction when called. Why, and how do you fix it?
The most likely cause is self-invocation. When a Spring bean calls one of its own methods, the call goes directly to the object via this, bypassing the CGLIB or JDK proxy that wraps the bean. The proxy is what intercepts the method call and starts the transaction — if the proxy is bypassed, the transaction never begins. Other causes: the method is private (CGLIB cannot override private methods, so the annotation is silently ignored), the method is in a class that is not a Spring-managed bean (instantiated with new), or the bean is called before the proxy is created (circular dependency causing early exposure of the raw bean). Fix: extract the transactional method to a separate Spring bean, inject self with @Lazy, or use AopContext.currentProxy() with exposeProxy=true.
Q: What is the difference between JDK dynamic proxies and CGLIB proxies in Spring?
JDK dynamic proxies implement the same interfaces as the target bean. They are created via java.lang.reflect.Proxy and work only if the target class implements at least one interface. When you inject the bean, you must use the interface type — injecting by concrete class throws ClassCastException. CGLIB proxies generate a subclass of the target class at runtime using bytecode manipulation. They work without interfaces, so you can inject by concrete class. Spring Boot defaults to CGLIB for all AOP proxying since version 2.x (proxyTargetClass=true). CGLIB proxies cannot proxy final classes or final methods — attempting to do so either fails silently (annotation ignored) or throws at startup. CGLIB is slightly heavier to create at startup but has identical runtime performance once the proxy class is loaded.
Q: Why does a @Configuration class behave differently from a @Component class that also has @Bean methods?
@Configuration classes are CGLIB-proxied. This proxy intercepts calls between @Bean methods within the same class. If bean A calls dataSource() and bean B also calls dataSource(), the proxy intercepts both calls and returns the same singleton instance from the bean factory rather than calling the actual method twice. This is how Spring guarantees singleton semantics even when @Bean methods call each other. A plain @Component class with @Bean methods is NOT proxied. Each call to dataSource() invokes the actual Java method, creating a new object each time — potentially creating multiple connection pools, multiple caches, or other duplicated resources. This is a subtle but critical difference: never use @Component for classes where @Bean methods call other @Bean methods.
Q: Walk me through the Spring bean lifecycle from class definition to destruction.
The lifecycle has seven stages. First, component scanning reads the classpath and creates BeanDefinition metadata objects — blueprints that describe the class, scope, dependencies, and lifecycle methods. Second, BeanFactoryPostProcessors modify bean definitions before any beans are created — this is where ${} placeholders are resolved. Third, the bean is instantiated via its constructor (or factory method). Fourth, InstantiationAwareBeanPostProcessor runs. Fifth, properties and @Autowired dependencies are injected via reflection. Sixth, BeanPostProcessor.postProcessBeforeInitialization() runs — this is where ApplicationContext is injected into ApplicationContextAware beans. Seventh, init methods run: @PostConstruct, then afterPropertiesSet() if InitializingBean, then custom init-method. Eighth, BeanPostProcessor.postProcessAfterInitialization() runs — this is where AOP proxies are created. Finally, on shutdown: @PreDestroy, then destroy() if DisposableBean, then custom destroy-method.
Q: What is a BeanPostProcessor and how does Spring use it internally?
A BeanPostProcessor is an interface with two methods — postProcessBeforeInitialization and postProcessAfterInitialization — that receive every bean after it is created and dependencies are injected. The return value replaces the original bean in the context, which is how proxy wrapping works. Spring’s own features are implemented as BeanPostProcessors: AutowiredAnnotationBeanPostProcessor processes @Autowired and @Value; AbstractAdvisorAutoProxyCreator wraps beans with AOP proxies for @Transactional, @Async, @Cacheable; CommonAnnotationBeanPostProcessor handles @PostConstruct and @PreDestroy; ScheduledAnnotationBeanPostProcessor registers @Scheduled methods. Writing your own BeanPostProcessor gives you a hook into every bean created in the context — useful for auditing, instrumentation, or enforcing architecture rules at startup.
Q: How does Spring Boot auto-configuration work? How does it know what to configure?
Spring Boot reads a manifest file from every JAR on the classpath: META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. Each line is a fully-qualified auto-configuration class name. Spring Boot loads all these classes, but each one is guarded by @Conditional annotations that control whether it actually activates. @ConditionalOnClass checks whether a specific class is on the classpath — the presence of spring-boot-starter-data-jpa puts HibernateJpaAutoConfiguration on the classpath and activates JPA auto-config. @ConditionalOnMissingBean prevents auto-configuration from overriding user-defined beans — your custom DataSource bean suppresses the auto-configured one. This design achieves sensible defaults with maximum overridability: if you define a bean yourself, the auto-configuration backs off. The order of evaluation matters — some auto-configurations use @AutoConfigureAfter and @AutoConfigureBefore to declare dependencies between auto-configurations.
Q: How would you debug why an auto-configuration is not activating?
Run the application with --debug flag or set logging.level.org.springframework.boot.autoconfigure=DEBUG. Spring Boot prints the Conditions Evaluation Report at startup — it shows every auto-configuration class, whether it was matched or not, and the exact condition that caused the decision. Look for your auto-configuration class under "Negative matches" and read the condition that evaluated to false. Common causes: the required class is not on the classpath (wrong starter dependency or excluded dependency); a user-defined bean of the required type already exists (@ConditionalOnMissingBean evaluated false); the required property is not set (@ConditionalOnProperty evaluated false); the auto-configuration class was explicitly excluded via @SpringBootApplication(exclude = MyAutoConfig.class). You can also use the /actuator/conditions Actuator endpoint at runtime to see the same report without restarting.
🔭

Section 14 Complete

You now understand what Spring actually does at runtime — the proxy mechanism behind every annotation, the bean creation pipeline, auto-configuration decisions, and the DispatcherServlet request journey. This knowledge turns mysterious Spring bugs into obvious, diagnosable problems.