The main selling point of Spring Boot is that it forces you to configure almost nothing by hand. Add a single dependency for working with a database, and JdbcTemplate is already there. Add a dependency for the web, and the application brings up an embedded server on its own. Let's work through how this is put together and where the application gets its settings from.
Every configuration class from a starter is checked against its conditions. No library — the candidate is skipped; your own bean is already declared — the configuration steps aside. Only what passed the conditions ends up in the context.
Starters — bundles of dependencies in a single line
In the past, to bring in, say, the web layer, you had to list a dozen libraries by hand: Spring MVC itself, the server, a JSON library, validation — and make sure their versions were compatible. One incompatible version and the application would fail at startup with a cryptic error. That's slow and fragile.
A starter is a ready-made bundle of dependencies for a single task, assembled for you. Add one line and get everything you need in mutually consistent versions.
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web") // the whole web layer
implementation("org.springframework.boot:spring-boot-starter-data-jpa") // working with a database
}
Starters are easy to recognize by their name: they begin with spring-boot-starter-. A starter contains almost no code of its own — it's just a list of other libraries.
How Spring Boot configures beans on its own
You add the database starter, and a ready-to-use DataSource and JdbcTemplate appear in the application, though you never created them. In the past, every such object had to be assembled by hand: read the database address, username, and password, create a connection pool, wire everything together. Dozens of lines of the same boilerplate in every project.
Auto-configuration is the mechanism Spring Boot uses to do this setup for you. The idea is simple: "look at which libraries are on the project's classpath and configure them with sensible defaults."
It's all turned on by a single annotation on the main class:
@SpringBootApplication
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}
@SpringBootApplication is three annotations rolled into one: marking the class as configuration, scanning your packages for @Component and @Service, and @EnableAutoConfiguration — that very instruction "configure everything you find in the project."
Inside the starters live pre-written configuration classes. At startup Spring Boot goes through them and decides for each one: apply or not.
Conditional annotations — configure "if present"
How does Spring Boot know what exactly to configure? After all, one project has a database and another doesn't, and setting one up in the latter would only get in the way.
The answer is conditional annotations. Each configuration class is marked with conditions, and it's applied only if those conditions are met.
@AutoConfiguration
@ConditionalOnClass(DataSource.class) // only if the DataSource class is on the classpath
public class DataSourceAutoConfiguration {
@Bean
@ConditionalOnMissingBean // only if you didn't create your own DataSource
@ConditionalOnProperty(name = "spring.datasource.url") // only if the database address is set
public DataSource dataSource(DataSourceProperties properties) {
return properties.initializeDataSourceBuilder().build();
}
}
The most common conditions and what they mean:
@ConditionalOnClass— apply only if the required class is on the classpath. No database library — the database setup is simply skipped.@ConditionalOnMissingBean— apply only if no such bean exists yet. This means your configuration always wins: declare your ownDataSourceand Spring Boot steps aside, leaving yours in place.@ConditionalOnProperty— apply only if a certain value is set in the configuration. Handy for turning something on and off with a single line.
The mechanism is easy to reproduce in plain Java: a map of beans created so far and three checks per candidate.
live example
import java.util.LinkedHashMap;
import java.util.Map;
public class MiniAutoConfig {
static final Map<String, String> context = new LinkedHashMap<>();
static final Map<String, String> props = Map.of("spring.datasource.url", "jdbc:postgresql://localhost/shop");
static boolean onClasspath(String className) {
try {
Class.forName(className);
return true;
} catch (ClassNotFoundException e) {
return false;
}
}
static void apply(String config, String bean, String onClass, String onProperty) {
if (!onClasspath(onClass)) {
System.out.println(config + " — skipped: no class " + onClass);
} else if (context.containsKey(bean)) {
System.out.println(config + " — skipped: bean " + bean + " already exists");
} else if (onProperty != null && !props.containsKey(onProperty)) {
System.out.println(config + " — skipped: " + onProperty + " is not set");
} else {
context.put(bean, config);
System.out.println(config + " — applied: created " + bean);
}
}
public static void main(String[] args) {
context.put("JdbcTemplate", "your @Bean");
apply("DataSourceAutoConfiguration", "DataSource", "java.sql.DriverManager", "spring.datasource.url");
apply("KafkaAutoConfiguration", "KafkaTemplate", "org.apache.kafka.clients.producer.KafkaProducer", null);
apply("JdbcTemplateAutoConfiguration", "JdbcTemplate", "java.sql.DriverManager", null);
System.out.println("in the context: " + context.keySet());
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The first passes, the second is skipped for the missing library, the third steps aside in front of your bean. Spring Boot does the same with hundreds of candidates.
Why a bean wasn't created
When "a bean should have appeared but it isn't there," don't guess. Turn on debug mode:
debug=true
At startup the logs will show a report of the decisions made: what matched (satisfied conditions) and what was rejected and why. This is the first place to look.
Settings outside the code
The database address, passwords, and port number must not be hard-coded: development needs one value, the production server another, and passwords should never sit in the repository at all. Rebuilding the application just to change a port is a bad approach.
Spring Boot lets you keep all such values outside — in a settings file or in environment variables. The main file is application.yml (or application.properties) in the project:
server:
port: 8080
spring:
datasource:
url: jdbc:postgresql://localhost:5432/shop
The same value can be set in several places, and they have a priority order. You don't need the whole list, just the main rule: the "closer" to launch a value is set, the more it wins. A command-line argument overrides an environment variable, which in turn overrides the application.yml file.
java -jar app.jar --server.port=9000 # overrides the port from application.yml
Under the hood it's just an ordered list of sources: Spring walks it top to bottom and takes the first value it finds.
live example
import java.util.List;
import java.util.Map;
public class PropertyOrder {
public static void main(String[] args) {
Map<String, String> commandLine = Map.of("server.port", "9000");
Map<String, String> env = Map.of("spring.profiles.active", "prod");
Map<String, String> yaml = Map.of("server.port", "8080");
for (String key : List.of("server.port", "spring.profiles.active")) {
for (Map<String, String> source : List.of(commandLine, env, yaml)) {
if (source.containsKey(key)) {
System.out.println(key + " = " + source.get(key));
break;
}
}
}
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
In practice: common settings live in application.yml in the repository; passwords and addresses for the production server come through environment variables; personal local tweaks go into application-local.yml, which is kept out of the repository.
@ConfigurationProperties and @Value
Settings from a file need to be read into the code. There are two ways.
@Value is suitable for a single value:
@Value("${app.timeout:5000}") // 5000 is the default value if the setting is empty
private long timeoutMs;
But with many related values, @Value has to be repeated on every field, and it's easy to get a name wrong.
@ConfigurationProperties binds a whole group of settings to an object — in one go and with type checking:
app:
retries:
max-attempts: 3
backoff-ms: 1000
timeout-ms: 5000
@ConfigurationProperties(prefix = "app.retries")
public record RetryProperties(int maxAttempts, long backoffMs, long timeoutMs) {}
That alone isn't enough: the annotation says where values come from, but doesn't make the class a bean. You still have to tell Spring to create it: @EnableConfigurationProperties(RetryProperties.class) next to a configuration class, or a single @ConfigurationPropertiesScan on the main class — then all such records are found on their own. This step is easy to forget: the application fails at startup complaining that no RetryProperties bean was found.
After that RetryProperties is injected like any other dependency, with all three values already in place. Names in YAML are written with hyphens (max-attempts), and in the code as ordinary fields (maxAttempts); Spring matches one to the other itself.
A simple rule: one or two values — @Value; a group of related settings — @ConfigurationProperties.
Validating settings at startup
You can add rules to a group of settings, and Spring will check them at startup:
@ConfigurationProperties(prefix = "app.retries")
@Validated
public record RetryProperties(
@Min(1) @Max(10) int maxAttempts,
@Positive long backoffMs,
@Positive long timeoutMs
) {}
If someone sets max-attempts: 0, the application won't start and will immediately say what's wrong. That's better than catching the error later, when the application is already running and fails in some unexpected place.
Profiles — different settings for different environments
For development you want to send emails "for pretend" and write detailed logs. On the production server — send real emails and log sparingly. Keeping separate builds of the application for this is inconvenient and dangerous.
A profile is a mode marker. A bean or a setting is tied to a profile and takes effect only when that profile is active.
@Service
@Profile("prod")
public class RealEmailService implements EmailService { } // production only
@Service
@Profile({"dev", "test"})
public class FakeEmailService implements EmailService { } // in development and tests
You can activate a profile with the spring.profiles.active setting:
spring:
profiles:
active: prod
Or via the SPRING_PROFILES_ACTIVE=prod environment variable, or the --spring.profiles.active=prod launch argument. You can activate several at once, separated by commas.
Profiles also have their own settings files. The application-prod.yml file is layered on top of the common application.yml when the prod profile is active. That way common values stay in one place and the differences between environments live in per-profile files.
Your own auto-configuration in a library
Suppose you have a reusable piece of infrastructure — a metrics wrapper or a shared set of filters. To plug it into different projects with a single dependency and without copying settings around, package it as your own starter: it will configure itself in whoever plugs it in.
The principle is exactly the same as for the built-in starters: a configuration class with conditional annotations plus one service file that tells Spring Boot "here's my configuration class, apply it."
@AutoConfiguration
@ConditionalOnClass(MyService.class)
public class MyAutoConfiguration {
@Bean
@ConditionalOnMissingBean // the bean check goes on the @Bean method, not on the class
public MyService myService() {
return new MyService();
}
}
@ConditionalOnMissingBean sits on the method on purpose: on the class it would switch off the whole configuration class — together with any other beans you add there later.
The pointer file goes into src/main/resources at the fixed path META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, and it contains just the name of your class:
com.example.mystarter.MyAutoConfiguration
After that, anyone who adds your library gets MyService in their context automatically — and can just as easily override it with their own bean thanks to @ConditionalOnMissingBean. This is exactly how the usecase-pattern library works, for example: one dependency, and the beans you need are already in place.
In short
- A starter is a ready-made bundle of dependencies for a single task in mutually consistent versions; recognizable by the
spring-boot-starter-*name. - Auto-configuration configures beans on its own by looking at the libraries on the classpath; it's turned on through
@SpringBootApplication. - Conditional annotations decide whether to apply a configuration:
@ConditionalOnClass(is the class present),@ConditionalOnMissingBean(is there already a bean),@ConditionalOnProperty(is a value set). - Your own beans always win over the automatic ones; a bean wasn't created — set
debug=trueand read the report of startup decisions. - Settings are kept outside the code; the closer a value is to launch, the more it wins.
@Value— a single value,@ConfigurationProperties— a group,@Validatedfails the startup on an invalid one. - Profiles provide different settings per environment;
application-<profile>.ymlis layered on top of the common file.
What to read next
- DI/IoC, bean lifecycle, scopes — how Spring creates the beans that auto-configuration is built on.
- Spring AOP —
@EnableAsync,@EnableCaching,@EnableTransactionManagementare also turned on through auto-configuration. - Spring Testing — how to check
@ConfigurationPropertiesand profiles in tests. - The usecase-pattern library — an example of a real starter with its own auto-configuration.