← Back to the section

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, from scratch, how this is put together, where the application gets its settings from, and how to keep different settings for development and for the production environment.

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. Configuring them into a working state is the job of the next part.

How Spring Boot configures beans on its own

You add the database starter, and out of nowhere a ready-to-use DataSource and JdbcTemplate appear in the application, even 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, whether to apply it 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 up a database 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 own DataSource and 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 upshot: you drop dependencies into the project, and Spring Boot assembles a working context out of them, without interfering where you've set something yourself.

Why a bean wasn't created

When "a bean should have appeared but it isn't there," there's no need to 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. Changing the code and 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 to memorize the whole list; the main rule is enough: 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

In practice it usually goes like this: common settings live in application.yml in the repository; passwords and addresses for the production server come through environment variables; and 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 somehow. 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 when there are 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) {}

Now RetryProperties can be 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 then 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 can be tied to a profile, and it will take 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 separate settings files. The application-prod.yml file is layered on top of the common application.yml when the prod profile is active. This makes it convenient to keep common values in one place and the differences between environments in per-profile files: application-dev.yml, application-prod.yml.

Your own auto-configuration in a library

Suppose you have a reusable piece of infrastructure — a metrics wrapper or a shared set of filters — and you want to plug it into different projects with a single dependency, without copying settings around. Then it's worth packaging 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)
@ConditionalOnMissingBean(MyService.class)
public class MyAutoConfiguration {

    @Bean
    public MyService myService() {
        return new MyService();
    }
}

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 is the mechanism that 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 — @ConditionalOnMissingBean is what makes this so.
  • A bean wasn't created — set debug=true and look at the report of the decisions made at startup.
  • Settings are kept outside the code (application.yml, environment variables); the closer a value is to launch, the more it wins.
  • @Value — for a single value; @ConfigurationProperties — for a group of related settings with type checking.
  • @Validated on a group of settings brings the application down at startup on an invalid value — the error is visible right away.
  • Profiles provide different settings for development and production; application-<profile>.yml is layered on top of the common file.
  • DI/IoC, bean lifecycle, scopes — how Spring creates the beans that auto-configuration is built on.
  • Spring AOP — @EnableAsync, @EnableCaching, @EnableTransactionManagement are also turned on through auto-configuration.
  • Spring Testing — how to check @ConfigurationProperties and profiles in tests.
  • The usecase-pattern library — an example of a real starter with its own auto-configuration.