Skip to main content

Spring Boot β€” Internals & Architecture

A deep dive into how Spring Boot works under the hood: auto-configuration mechanics, the embedded server model, conditional bean loading, custom starters, and the event-driven architecture.


Auto-Configuration Deep Dive​

How Auto-Configuration Works​

When @EnableAutoConfiguration is present (included in @SpringBootApplication), Spring Boot:

  1. Reads META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (Spring Boot 3.x) or META-INF/spring.factories (2.x) from every JAR on the classpath
  2. Loads the listed auto-configuration classes
  3. Evaluates conditional annotations on each class
  4. Registers beans only if conditions are met
Classpath has HikariCP + PostgreSQL driver
β†’ DataSourceAutoConfiguration conditions pass
β†’ HikariDataSource bean created
β†’ JdbcTemplate bean created

Key Conditional Annotations​

AnnotationBean Is Created When…
@ConditionalOnClassA specific class is on the classpath
@ConditionalOnMissingBeanNo bean of that type already exists
@ConditionalOnPropertyA property has a specific value
@ConditionalOnBeanA specific bean already exists in the context
@ConditionalOnMissingClassA specific class is NOT on the classpath
@ConditionalOnWebApplicationThe app is a web application
@ConditionalOnExpressionA SpEL expression evaluates to true

Example: DataSource Auto-Configuration​

@AutoConfiguration
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
@ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory")
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {

@Configuration
@ConditionalOnMissingBean(DataSource.class)
@ConditionalOnProperty(name = "spring.datasource.url")
static class PooledDataSourceConfiguration {
// Creates HikariCP DataSource with properties from application.yml
}
}

What this means: Spring Boot only creates a DataSource if:

  • DataSource class is on the classpath βœ“
  • No R2DBC ConnectionFactory exists βœ“
  • No custom DataSource bean is already defined βœ“
  • spring.datasource.url property is set βœ“

Overriding Auto-Configuration​

Define your own bean, and auto-configuration backs off:

@Configuration
public class CustomDataSourceConfig {

@Bean
public DataSource dataSource() {
// Your custom DataSource β€” auto-config won't create one
return new CustomPoolDataSource();
}
}

You can also exclude specific auto-configurations:

@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })

Embedded Server Architecture​

How It Works​

Traditional Java web apps are packaged as WAR files and deployed to an external Tomcat/JBoss/WebLogic. Spring Boot flips this model:

Traditional: App β†’ WAR β†’ External Server
Spring Boot: App + Server β†’ Fat JAR β†’ java -jar

Spring Boot embeds the server inside the application:

  1. ServletWebServerAutoConfiguration detects servlet container on the classpath
  2. Creates an EmbeddedServletContainerFactory (e.g., TomcatServletWebServerFactory)
  3. Starts the server during ApplicationContext refresh
  4. Registers DispatcherServlet programmatically

Server Options​

ServerStarterUse Case
TomcatDefault (included in starter-web)General purpose, most widely used
Jettyspring-boot-starter-jettyLightweight, good for async/WebSocket
Undertowspring-boot-starter-undertowHigh performance, non-blocking

Switching servers β€” exclude Tomcat, add the alternative:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>

Spring Boot Starter Mechanism​

What Is a Starter?​

A starter is a dependency descriptor β€” a POM with no code, only managed transitive dependencies. It ensures compatible versions across libraries.

Anatomy of a Starter​

spring-boot-starter-data-jpa
β”œβ”€β”€ spring-boot-starter (core)
β”œβ”€β”€ spring-boot-starter-aop
β”œβ”€β”€ spring-data-jpa
β”œβ”€β”€ hibernate-core
β”œβ”€β”€ jakarta.persistence-api
β”œβ”€β”€ spring-orm
└── spring-aspects

Creating a Custom Starter​

Custom starters follow a naming convention: {project}-spring-boot-starter.

Structure:

my-service-spring-boot-starter/
β”œβ”€β”€ src/main/java/
β”‚ └── com/example/autoconfigure/
β”‚ β”œβ”€β”€ MyServiceAutoConfiguration.java
β”‚ └── MyServiceProperties.java
└── src/main/resources/
└── META-INF/
└── spring/
└── org.springframework.boot.autoconfigure.AutoConfiguration.imports

Auto-configuration class:

@AutoConfiguration
@ConditionalOnClass(MyService.class)
@EnableConfigurationProperties(MyServiceProperties.class)
public class MyServiceAutoConfiguration {

@Bean
@ConditionalOnMissingBean
public MyService myService(MyServiceProperties properties) {
return new MyService(properties.getEndpoint(), properties.getTimeout());
}
}

Properties class:

@ConfigurationProperties(prefix = "my.service")
public class MyServiceProperties {
private String endpoint = "http://localhost:8080";
private int timeout = 5000;
// getters and setters
}

Registration file (AutoConfiguration.imports):

com.example.autoconfigure.MyServiceAutoConfiguration

Spring Boot Event System​

Spring Boot publishes events during the application lifecycle. You can hook into these for custom initialization, logging, or cleanup.

Application Lifecycle Events (in order)​

EventWhen It Fires
ApplicationStartingEventBefore anything β€” just after run() is called
ApplicationEnvironmentPreparedEventEnvironment is ready, context not yet created
ApplicationContextInitializedEventContext created, beans not yet loaded
ApplicationPreparedEventBeans loaded, context not yet refreshed
ContextRefreshedEventContext fully refreshed, all beans instantiated
ApplicationStartedEventContext refreshed, runners not yet called
ApplicationReadyEventEverything ready β€” app can serve traffic
ApplicationFailedEventStartup failed with an exception

Listening to Events​

@Component
public class ReadinessListener {

@EventListener(ApplicationReadyEvent.class)
public void onReady() {
// Initialize caches, warm up connections, etc.
}
}

For events before the context is ready, register via SpringApplication:

SpringApplication app = new SpringApplication(MyApp.class);
app.addListeners(event -> {
if (event instanceof ApplicationEnvironmentPreparedEvent) {
// Modify environment before context is created
}
});
app.run(args);

Configuration Properties Binding​

Spring Boot can bind structured properties to Java objects:

app:
cache:
enabled: true
ttl: 300
max-size: 1000
@ConfigurationProperties(prefix = "app.cache")
public class CacheProperties {
private boolean enabled;
private int ttl;
private int maxSize;
// getters and setters
}

// Or using Java Records (Spring Boot 3.x Constructor Binding):
@ConfigurationProperties(prefix = "app.cache")
public record CacheProperties(boolean enabled, int ttl, int maxSize) {}

Binding features:

  • Relaxed binding β€” max-size, maxSize, MAX_SIZE all map to maxSize
  • Type conversion β€” Strings to Duration, DataSize, enums, etc.
  • Validation β€” Add @Validated and use JSR-303 annotations (@NotNull, @Min, etc.)
  • Nested objects β€” Complex hierarchies bind naturally
  • List/Map support β€” YAML lists and maps bind to List<> and Map<>

Fat JAR Structure​

Spring Boot packages everything into an executable JAR:

my-app.jar
β”œβ”€β”€ BOOT-INF/
β”‚ β”œβ”€β”€ classes/ ← Your compiled code
β”‚ β”œβ”€β”€ lib/ ← All dependency JARs
β”‚ └── classpath.idx ← JAR loading order
β”œβ”€β”€ META-INF/
β”‚ └── MANIFEST.MF ← Main-Class: JarLauncher
└── org/springframework/boot/loader/
β”œβ”€β”€ JarLauncher.class ← Entry point
└── ... ← Custom classloader

How it boots:

  1. JVM calls JarLauncher.main() (specified in MANIFEST.MF)
  2. JarLauncher sets up a custom ClassLoader that can read nested JARs
  3. Delegates to your @SpringBootApplication class's main()

Proxy Mechanism: CGLIB vs JDK Dynamic Proxy​

Every Spring bean with @Transactional, @Async, @Cacheable, or similar AOP annotations is wrapped in a proxy object at startup. Understanding which proxy mechanism is chosen is critical for debugging and for senior architecture decisions.

Which Proxy Is Used?​

ScenarioProxy Type
Bean implements an interface + proxyTargetClass=falseJDK Dynamic Proxy
Bean has no interface OR proxyTargetClass=true (Spring Boot default)CGLIB subclass proxy
@Configuration classesCGLIB (always, to intercept @Bean method calls)
# Spring Boot default β€” forces CGLIB even for beans with interfaces
spring:
aop:
proxy-target-class: true # default

CGLIB Constraints You Must Know​

// ❌ CGLIB cannot proxy final classes
@Service
public final class PaymentService {
@Transactional
public void pay() { ... } // Spring will throw at startup
}

// ❌ CGLIB cannot proxy final methods β€” silently ignored
@Service
public class OrderService {
@Transactional
public final void place(Order order) { ... } // @Transactional has no effect
}

// ❌ CGLIB requires a no-arg constructor (or no explicit constructors)
@Service
public class MyService {
public MyService(String param) { ... } // Fails with CGLIB β€” add no-arg constructor
}

Why @Configuration Always Uses CGLIB​

@Configuration
public class AppConfig {

@Bean
public ServiceA serviceA() {
return new ServiceA(serviceB()); // calls serviceB() method directly
}

@Bean
public ServiceB serviceB() {
return new ServiceB();
}
}
// Without CGLIB: serviceB() would be called twice β†’ two different instances
// With CGLIB proxy: serviceB() is intercepted β†’ same singleton returned both times
// This is why @Configuration classes cannot be final!

ImportBeanDefinitionRegistrar​

For framework-level programmatic bean registration β€” the mechanism used internally by Spring Data JPA's @EnableJpaRepositories and Spring Security's @EnableWebSecurity.

public class DynamicRepositoryRegistrar implements ImportBeanDefinitionRegistrar {

@Override
public void registerBeanDefinitions(AnnotationMetadata metadata,
BeanDefinitionRegistry registry) {
// Called at configuration phase β€” before any beans are created
// Read annotation attributes from the importing class
Map<String, Object> attrs = metadata
.getAnnotationAttributes(EnableDynamicRepositories.class.getName());
String basePackage = (String) attrs.get("basePackage");

// Programmatically register beans
BeanDefinitionBuilder builder = BeanDefinitionBuilder
.genericBeanDefinition(DynamicRepositoryFactory.class)
.addConstructorArgValue(basePackage);
registry.registerBeanDefinition("dynamicRepositoryFactory", builder.getBeanDefinition());
}
}

// Usage β€” triggers the registrar
@Configuration
@Import(DynamicRepositoryRegistrar.class)
public class AppConfig { }

When to use: When you need to programmatically register beans based on classpath scanning, annotation attributes, or external configuration β€” similar to how Spring Data automatically creates repository implementations.


Profiles Architecture​

Profiles control which beans and configurations are active:

@Configuration
@Profile("production")
public class ProductionConfig {

@Bean
public DataSource dataSource() {
// Production connection pool
}
}

@Configuration
@Profile("development")
public class DevConfig {

@Bean
public DataSource dataSource() {
// In-memory H2 for development
}
}

Profile resolution order:

  1. spring.profiles.active from command line
  2. spring.profiles.active from environment variable
  3. spring.profiles.active from application.properties
  4. spring.profiles.default (defaults to "default")

Profile-specific property files are loaded automatically:

  • application-dev.yml when dev profile is active
  • application-prod.yml when prod profile is active
  • Values in profile-specific files override application.yml

Summary​

Spring Boot's power comes from:

  • Conditional auto-configuration that reacts to the classpath
  • Embedded servers that simplify deployment
  • Starter POMs that ensure dependency compatibility
  • A rich event system for lifecycle hooks
  • Relaxed property binding for type-safe configuration
  • Fat JAR packaging for single-artifact deployment

Understanding these internals enables you to debug startup issues, write custom starters, and optimize application behavior.


Advanced Editorial Pass: Spring Boot Internals for Debuggability​

Why Internals Matter in Real Systems​

  • Understanding condition evaluation prevents accidental behavior changes during upgrades.
  • Context lifecycle clarity improves startup sequencing and shutdown safety.
  • Bean wiring visibility reduces time-to-diagnosis for production misconfiguration.

Failure Modes​

  • Relying on implicit ordering for critical initialization logic.
  • Hidden bean replacement through permissive component scanning.
  • Configuration binding mismatches that fail late under specific profiles.

Practical Heuristics​

  1. Keep package boundaries explicit for component scanning and auto-configuration imports.
  2. Surface condition and binding diagnostics in non-production environments.
  3. Add smoke tests for key context invariants after dependency upgrades.

Compare Next​


Interview Questions​

Q: How do you debug unexpected auto-configuration in production?​

A: Inspect condition evaluation reports, bean definitions, and classpath differences between environments.

Q: What is the practical difference between configuration properties and ad-hoc @Value usage?​

A: Properties classes provide typed, validated, maintainable configuration models; @Value is best for isolated simple values.

Q: Why are conditional annotations central to Boot internals?​

A: They control feature activation by classpath, properties, and existing beans, which is the core of Boot's behavior.

Q: When should a team build a custom starter?​

A: When repeated internal platform patterns need standardized auto-configured setup across many services.

Q: What failure mode appears when classpath changes silently?​

A: Different auto-configurations activate and alter runtime behavior without code changes.

Q: How do you prevent premature bean initialization bugs?​

A: Keep post-processors minimal, avoid heavy dependencies in early lifecycle hooks, and add startup invariants tests.

Q: Why does understanding fat JAR classloading matter operationally?​

A: It helps diagnose startup failures, shading conflicts, and environment-specific classpath issues quickly.