The magic you never wrote
You add one line to your build file — spring-boot-starter-web — start the app, and a web server is already listening on port 8080. You never created a server object, never configured a thread pool, never registered anything to turn objects into JSON. Yet requests come in and JSON goes out.
That "it just works" is auto-configuration: Spring Boot looking at which libraries are on your classpath and quietly wiring up sensible objects so you don't have to.
Two terms to reset before we lean on them. A bean is just an object that Spring creates and manages for you. The container is the registry that holds those beans and hands them to whoever asks. Everything below is about one question: how does that container get filled without you writing the wiring?
Life before Boot
Plain Spring made you wire everything by hand. To talk to a database you wrote a configuration class — a @Configuration class is simply a class whose job is to hand beans to the container — and inside it you built the connection pool, the transaction manager, and the template object yourself.
@Configuration
public class DatabaseConfig {
@Bean
public DataSource dataSource() {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl("jdbc:postgresql://localhost/app");
ds.setUsername("user");
ds.setPassword("secret");
return ds;
}
// ...and a transaction manager, and a JdbcTemplate, and more
}
Every project retyped nearly the same thing. Boot's bet was simple: most of that setup is identical everywhere, so ship it once, pre-written, and switch it on automatically when the relevant library is present. You keep only the parts that are actually yours.
The one switch that starts it
Almost every Boot app is annotated with @SpringBootApplication.
@SpringBootApplication
public class MyApp {
public static void main(String[] args) {
SpringApplication.run(MyApp.class, args);
}
}
That single annotation is three annotations stacked together:
@Configuration
@ComponentScan
@EnableAutoConfiguration // <-- this is the one that matters here
public @interface SpringBootApplication { }
The piece doing the auto-configuring is @EnableAutoConfiguration. It tells Boot: after you've processed my own configuration, go find all the pre-written configuration classes on the classpath and consider applying them too.
The obvious next question is: where is that list of pre-written classes, and who reads it?
Where the list actually lives
Boot does not scan every jar for configuration classes — that would be slow and unpredictable. Instead each library ships an explicit list of its auto-configuration classes in a known file, and Boot reads those files.
The location of that file changed, and you'll meet both forms in real codebases.
The old way (Spring Boot 2.6 and earlier) — a properties file at META-INF/spring.factories, keyed by a long property name, with class names as a comma-separated, line-continued value:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,\
org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
The new way (Spring Boot 2.7+, and the only way in Boot 3) — a plain text file with one fully-qualified class name per line, at a longer but more honest path:
```plain text
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
```plain text
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
Same idea both times: a flat list of class names, sitting inside the library jar. The move to AutoConfiguration.imports was mostly cleanup — spring.factories was a shared bucket used for many unrelated things, so pulling auto-configuration into its own dedicated file made it faster to load and clearer to read.
What reads the file
@EnableAutoConfiguration is meta-annotated to import a class called AutoConfigurationImportSelector. That selector is the engine. When Boot starts, it:
- Reads the
AutoConfiguration.importsfile (or the oldspring.factorieskey) from every jar on the classpath and merges them into one big candidate list. - Removes anything you explicitly excluded, plus duplicates.
- Does a fast first-pass filter to drop candidates whose required classes obviously aren't present — cheap rejects before the expensive work.
- Hands the survivors to the container as ordinary configuration classes to process.
One subtlety makes the whole thing well-behaved. The selector is a DeferredImportSelector — "deferred" meaning it runs after all of your own configuration has been processed. That single design choice is why your beans are seen first and Boot's defaults fill in around them. Hold onto that; it's the key to overriding, which we reach shortly.
Why a list, not component scanning
You might wonder why Boot needs this file at all. It already has @ComponentScan — why not just scan the library packages?
Two reasons. First, scope: auto-configuration classes live inside library jars, in packages your app never scans. An explicit list reaches them without you knowing their package names. Second, control: component scanning is unconditional and eager — it registers what it finds, full stop. Auto-configuration must be conditional (apply only when it fits), overridable (step aside when you've done it yourself), and ordered (some setup depends on other setup). A curated, gated list gives all three; a blind scan gives none.
Which brings us to the gate.
The gate: conditions decide what survives
Being on the list only makes a class a candidate. Whether it actually contributes beans is decided by conditions — annotations that each ask a yes/no question before the class is allowed to run. Here is a trimmed version of the real database auto-configuration:
@AutoConfiguration
@ConditionalOnClass(DataSource.class)
public class DataSourceAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public DataSource dataSource(DataSourceProperties properties) {
return properties.initializeDataSourceBuilder().build();
}
}
Read it plainly. @ConditionalOnClass(DataSource.class) means "only apply this class if DataSource is on the classpath" — i.e. only if you actually pulled in a JDBC library. No database jar, no attempt to configure a database. That is exactly why adding a starter dependency is enough to switch a feature on: the jar brings the class, the class satisfies the condition, the condition opens the gate.
This family of @ConditionalOn... annotations is a topic in its own right — the next lesson digs into it. For now, hold the shape: a candidate on the list runs only when its conditions all pass.
Your bean always wins
Look again at that inner condition: @ConditionalOnMissingBean. It means "define this DataSource bean only if the container doesn't already have one."
Because the selector is deferred, your own configuration is processed first. So if you declared your own DataSource:
@Configuration
public class MyDbConfig {
@Bean
public DataSource dataSource() {
return new HikariDataSource(myTunedConfig());
}
}
...then by the time Boot's DataSourceAutoConfiguration is considered, a DataSource already exists, @ConditionalOnMissingBean fails, and Boot quietly backs off. To back off is the word for exactly this: an auto-configuration declining to act because you've already covered that ground.
This is the whole reason auto-configuration feels safe rather than intrusive. It only ever fills gaps. Anything you define yourself takes precedence, automatically, with no opt-out flag to remember.
Ordering: who configures first
Some setup depends on other setup — a JdbcTemplate needs a DataSource to already exist. Auto-configuration classes can declare their order relative to one another:
@AutoConfiguration(after = DataSourceAutoConfiguration.class)
public class JdbcTemplateAutoConfiguration {
// runs only after the DataSource has had its chance to be created
}
The @AutoConfiguration annotation — the one that marks these classes in Boot 2.7+ — carries before and after attributes for exactly this. (Older code uses the separate @AutoConfigureAfter / @AutoConfigureBefore annotations; same effect.) And because the whole selector is deferred, every one of these runs after your own configuration — your beans are always in place before Boot's defaults are weighed.
Seeing it happen
All of this is invisible until you ask to see it. Start the app with the --debug flag and Boot prints a Conditions Evaluation Report: for every candidate, whether it matched and why.
```plain text
Positive matches:
DataSourceAutoConfiguration matched:
- @ConditionalOnClass found required class 'javax.sql.DataSource'
Negative matches:
MongoAutoConfiguration:
Did not match:
- @ConditionalOnClass did not find required class 'com.mongodb.client.MongoClient'
This report is the first place to look whenever a bean you expected isn't there, or one you didn't expect is. It turns "why is this happening?" into a line you can read.
## Writing your own
Once you see the mechanism, writing your own auto-configuration is unremarkable. You annotate a class with `@AutoConfiguration`, gate it with conditions, and list its name — one line — in your library's `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`. Any app that puts your jar on its classpath now gets your beans, gated and overridable, for free.
That is precisely how **starter dependencies** package up ready-made features — the subject of an upcoming lesson. For now the point is that auto-configuration isn't a closed, magic mechanism; it's an open convention you can join.
## The one mental model
Strip it all down and it's a short chain:
- `@SpringBootApplication` includes `@EnableAutoConfiguration`.
- That imports `AutoConfigurationImportSelector`, which reads `AutoConfiguration.imports` from every jar to build a candidate list.
- Because the selector is **deferred**, your own beans are registered first.
- Each candidate runs only if its **conditions** pass — and most contain `@ConditionalOnMissingBean`, so they **back off** wherever you've already done the work.
Auto-configuration isn't magic filling your app with things you didn't ask for. It's a curated list of gap-fillers, each one asking permission before it acts, each one stepping aside the moment you take over. Once you can name the switch, the list, the reader, and the gate, the "it just works" stops being mysterious — and, more usefully, becomes something you can steer.