Command Palette

Search for a command to run...

[Advanced Java] Spring Boot Basics: the IoC Container, Auto-Configuration and the Bean Lifecycle

The previous article built a small application with a controller, a service and a repository, no framework anywhere, and wired the three together by hand in main. It ended by saying a framework automates exactly that. This is the framework.

Spring is a container that constructs your objects and passes them to each other. Spring Boot is a packaging of that container with sensible defaults, so a project starts as one class with one annotation instead of a folder of XML. Everything below was produced by building and running a real project, so the log lines, the exceptions and the compiler errors are the ones the toolchain actually printed.

A hand-written new chain in main on the left, the same object graph assembled inside an ApplicationContext on the right

The project was generated from Spring Initializr with the web dependency only, built with the bundled Maven wrapper, and run on OpenJDK 21.0.6 (arm64) with Spring Boot 4.1.1, which pulls in Spring Framework 7.0.9, embedded Tomcat 11.0.24 and JUnit Jupiter 6.0.3. One thing is deliberately missing from every transcript below: Spring Boot prints a Started DemoApplication in ... seconds line at the end of startup, and every such number has been replaced with [redacted], because these articles are written on shared, loaded machines and a startup time measured there would be fiction.

Spring versus Spring Boot

Spring Framework is the container: it reads your classes, decides which of them are beans, constructs them in dependency order and hands each one the collaborators it declared. That part is old, stable and unopinionated — it will do whatever you configure it to do, and historically you configured all of it yourself.

Spring Boot is a layer of opinions on top. It contributes three things and nothing else conceptually new: starters, which are dependency bundles so you add one artifact instead of fifteen; auto-configuration, which creates the beans a typical application would have created by hand, but only when the conditions for them hold; and an embedded server plus a launcher, so the output is one runnable jar with a main method rather than a WAR you deploy into something. Take Boot away and every Spring concept below still exists — you would just write far more configuration to get to the same place.

Spring FrameworkSpring Boot
Gives youthe IoC container, DI, AOP, the web MVC stackstarters, auto-configuration, an embedded server
You writethe bean definitionsmostly nothing, then override what you disagree with
Outputa library your app usesan executable jar with main
In this projectspring-context 7.0.9, spring-webmvc 7.0.9spring-boot 4.1.1, spring-boot-autoconfigure 4.1.1

Creating a project that actually builds

Generate the project from Spring Initializr. The archive ships a Maven wrapper (mvnw), so a Maven installation is not required.

Bash
curl -sS -o demo.zip "https://start.spring.io/starter.zip?type=maven-project&language=java&bootVersion=4.1.1.RELEASE&javaVersion=21&groupId=com.example&artifactId=demo&dependencies=web"
unzip -q demo.zip -d demo
cd demo

There is one trap here worth knowing before it costs you an hour. Initializr identifies boot versions with a .RELEASE suffix, but the artifacts published to Maven Central do not carry it. A project generated with bootVersion=4.1.1.RELEASE gets a parent of spring-boot-starter-parent:4.1.1.RELEASE and the first build dies with Could not find artifact org.springframework.boot:spring-boot-starter-parent:pom:4.1.1.RELEASE in central. Rewrite the parent version to plain 4.1.1 and it resolves:

XML
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
    <relativePath/>
</parent>

The second surprise is in the dependency block. Asking Initializr for "Spring Web" on Boot 4 no longer produces spring-boot-starter-web:

XML
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
 
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc-test</artifactId>
    <scope>test</scope>
</dependency>

The old name still resolves — its POM on Maven Central describes itself as "Starter for building web, including RESTful, applications using Spring MVC. Uses Tomcat as the default embedded container (deprecated in favor of spring-boot-starter-webmvc)". Tutorials written for Boot 3 will say spring-boot-starter-web; on Boot 4 that is the deprecated spelling.

@SpringBootApplication is the whole bootstrap. It is a composed annotation: @Configuration (this class may declare beans), @EnableAutoConfiguration (consider Boot's auto-configuration classes) and @ComponentScan (scan this package and everything under it).

Java
package com.example.demo;
 
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
 
@SpringBootApplication
public class DemoApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

One trivial endpoint is enough to prove the whole thing is alive. HTTP mapping, status codes and validation are a subject of their own and are not covered here:

Java
@RestController
public class HelloController {
 
    private final GreetingService service;
 
    public HelloController(GreetingService service) {
        this.service = service;
    }
 
    @GetMapping("/hello")
    public String hello(@RequestParam(defaultValue = "world") String name) {
        return service.greet("en", name);
    }
}
Bash
./mvnw -B -DskipTests package
java -jar target/demo-0.0.1-SNAPSHOT.jar --server.port=18437 --spring.output.ansi.enabled=NEVER
curl "http://localhost:18437/hello?name=Spring"
Text
Hello, Spring

--spring.output.ansi.enabled=NEVER is worth making a habit: without it the banner and the log levels are wrapped in ANSI escape sequences, which are invisible in a terminal and garbage in a file you paste into a ticket.

The IoC container: the wiring you were doing by hand

Inversion of control means one thing concretely: your code no longer calls new on its collaborators, and no longer decides when they are created. You declare what a class needs, the container works out the order and supplies it.

Component scan produces bean definitions, injection resolves each constructor parameter by type, and the singleton registry hands out one instance

The stereotype annotations mark a class as a candidate. @Component is the general one; @Service and @Repository are @Component with a name that says what layer the class belongs to. Spring treats all three the same when scanning — @Repository additionally translates persistence exceptions, which matters once a data access technology is involved.

Java
@Repository
public class GreetingRepository {
 
    private final Map<String, String> byLocale = Map.of("en", "Hello", "vi", "Xin chao");
 
    public Optional<String> find(String locale) {
        return Optional.ofNullable(byLocale.get(locale));
    }
}
 
@Service
public class GreetingService {
 
    private final GreetingRepository repository;
    private final Formatter formatter;
 
    public GreetingService(GreetingRepository repository, Formatter formatter) {
        this.repository = repository;
        this.formatter = formatter;
    }
 
    public String greet(String locale, String name) {
        String greeting = repository.find(locale).orElse("Hello");
        return formatter.format(greeting, name);
    }
}

Nothing in GreetingService says where its dependencies come from. That is the point: the class is a description of a need, and the container is the only thing that knows how the need is met.

Constructor injection, and why it beats field injection

Since Spring 4.3 a class with exactly one constructor does not need @Autowired on it — the container uses that constructor. Constructor injection is not a style preference; it buys three concrete things.

The fields can be final. Field injection cannot, and the compiler says so before Spring is ever involved:

Java
@Service
public class FinalField {
 
    @Autowired
    private final GreetingRepository repository;
 
    public String greet(String locale) {
        return repository.find(locale).orElse("Hello");
    }
}
Text
FinalField.java:10: error: variable repository not initialized in the default constructor
    private final GreetingRepository repository;
                                     ^
1 error

The class is testable without a container. A constructor-injected bean is an ordinary object, so a plain JUnit test constructs it directly — no Spring, no reflection, no context:

Java
@Test
void constructorInjectionNeedsNoSpring() {
    GreetingService service =
            new GreetingService(new GreetingRepository(), new PlainFormatter());
    assertThat(service.greet("vi", "world")).isEqualTo("Xin chao, world");
}

A missing dependency fails loudly instead of quietly. With field injection there is no way for a caller to supply the dependency at all, so an instance built outside the container is half-built and only tells you at the first call:

Java
static class FieldInjectedGreetingService {
 
    @Autowired
    private GreetingRepository repository;
 
    String greet(String locale) {
        return repository.find(locale).orElse("Hello");
    }
}
Text
NPE MESSAGE: Cannot invoke "com.example.demo.GreetingRepository.find(String)" because "this.repository" is null

Inside the container the difference is the same one moved earlier in time: a constructor dependency that cannot be resolved stops the application at startup, while a field dependency marked @Autowired(required = false) leaves a null sitting in the object.

Beans you do not own: @Configuration and @Bean

You cannot annotate a JDK class or a third-party class. For those, write a @Configuration class with @Bean methods — the method name becomes the bean name and the return type becomes the bean type:

Java
@Configuration
@EnableConfigurationProperties(GreetingProperties.class)
public class AppConfig {
 
    @Bean
    public Clock clock() {
        return Clock.system(ZoneId.of("UTC"));
    }
 
    @Bean
    public CharacterEncodingFilter myEncodingFilter() {
        CharacterEncodingFilter filter = new CharacterEncodingFilter();
        filter.setEncoding(StandardCharsets.UTF_8.name());
        filter.setForceResponseEncoding(true);
        return filter;
    }
}

A @Bean method may take parameters, and the container resolves them the same way it resolves constructor parameters. This is also the escape hatch for anything that needs real logic — reading an environment variable, choosing an implementation, building something with a builder.

Two candidates and no @Primary

Injection resolves by type first. Give the container two beans that satisfy one parameter type and it refuses to guess. This is one of the most common Spring startup failures, so it is worth seeing rather than paraphrasing. Two implementations, neither marked:

Java
public interface Formatter {
    String format(String greeting, String name);
}
 
@Component
public class PlainFormatter implements Formatter {
    @Override
    public String format(String greeting, String name) {
        return greeting + ", " + name;
    }
}
 
@Component
public class ShoutFormatter implements Formatter {
    @Override
    public String format(String greeting, String name) {
        return (greeting + ", " + name).toUpperCase();
    }
}

The application does not start. This is the report verbatim, with only the absolute path shortened to /path/to/demo:

Text
***************************
APPLICATION FAILED TO START
***************************
 
Description:
 
Parameter 1 of constructor in com.example.demo.GreetingService required a single bean, but 2 were found:
	- plainFormatter: defined in file [/path/to/demo/target/classes/com/example/demo/PlainFormatter.class]
	- shoutFormatter: defined in file [/path/to/demo/target/classes/com/example/demo/ShoutFormatter.class]
 
This may be due to missing parameter name information
 
Action:
 
Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumed

The underlying exception, logged a few lines above the report, names the type and both candidates:

Text
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'greetingService': Unsatisfied dependency expressed through constructor parameter 1: No qualifying bean of type 'com.example.demo.Formatter' available: expected single matching bean but found 2: plainFormatter,shoutFormatter

Two annotations fix it, and they answer different questions. @Primary sits on the bean and means "when nothing else is specified, use me". @Qualifier sits at the injection point and means "at this particular spot, I want that one":

Java
@Component
@Primary
public class PlainFormatter implements Formatter { /* ... */ }
Java
public Diagnostics(GreetingService greetingService,
                   @Qualifier("shoutFormatter") Formatter shout) {
    this.greetingService = greetingService;
    this.shout = shout;
}
Text
QUALIFIER primary -> Hello, world
QUALIFIER shoutFormatter -> HELLO, WORLD

If neither annotation is present, Spring falls back on one more rule before giving up: a bean whose name equals the parameter name wins. That fallback is why the failure report ends with advice about the -parameters compiler flag — without parameter names retained in the bytecode the container has one less way to break the tie. The Spring Boot parent POM turns that flag on for you.

Auto-configuration, the thing that makes it Boot

A starter is only a bundle of dependencies; it creates nothing. What creates beans is a set of auto-configuration classes, each guarded by conditions that are evaluated against the classpath, the existing beans and the properties before a single object is constructed.

A starter adds jars, AutoConfiguration.imports lists candidates, and a condition decides whether each bean is created

Each jar declares its candidates in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. Boot reads them all, then asks each one's conditions whether it applies. The common conditions are worth memorising:

ConditionMatches when
@ConditionalOnClassa class is on the classpath
@ConditionalOnMissingClassa class is not on the classpath
@ConditionalOnBeana bean of a type or name already exists
@ConditionalOnMissingBeanno such bean exists — this is the back-off rule
@ConditionalOnPropertya property has a given value
@ConditionalOnWebApplicationthe context is a web context

Reading the CONDITIONS EVALUATION REPORT

None of this has to be taken on faith. Start the application with --debug and Boot prints exactly which candidates matched and why:

Bash
java -jar target/demo-0.0.1-SNAPSHOT.jar --server.port=18437 --spring.output.ansi.enabled=NEVER --debug
Text
============================
CONDITIONS EVALUATION REPORT
============================
 
 
Positive matches:
-----------------
 
   JacksonAutoConfiguration matched:
      - @ConditionalOnClass found required class 'tools.jackson.databind.json.JsonMapper' (OnClassCondition)
 
   HttpEncodingAutoConfiguration#characterEncodingFilter matched:
      - @ConditionalOnMissingBean (types: org.springframework.web.filter.CharacterEncodingFilter; SearchStrategy: all) did not find any beans (OnBeanCondition)
 
Negative matches:
-----------------
 
   GsonHttpMessageConvertersConfiguration:
      Did not match:
         - @ConditionalOnClass did not find required class 'com.google.gson.Gson' (OnClassCondition)
 
   JmxAutoConfiguration:
      Did not match:
         - @ConditionalOnBooleanProperty (spring.jmx.enabled=true) did not find property 'spring.jmx.enabled' (OnPropertyCondition)
      Matched:
         - @ConditionalOnClass found required class 'org.springframework.jmx.export.MBeanExporter' (OnClassCondition)

Read those four entries side by side and the mechanism stops being magic. JSON works because tools.jackson.databind.json.JsonMapper is on the classpath — Boot 4 uses Jackson 3 under the tools.jackson package, not Jackson 2's com.fasterxml.jackson. Gson support is absent for the mirror-image reason. And JmxAutoConfiguration shows a candidate that lost on a property while its class condition passed, which is the shape of most "why is this not switched on" questions.

The report has two more sections, and the last one is a useful reminder that a handful of auto-configurations have no conditions at all:

Text
Exclusions:
-----------
 
    None
 
 
Unconditional classes:
----------------------
 
    org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration
 
    org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration
 
    org.springframework.boot.autoconfigure.context.LifecycleAutoConfiguration
 
    org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration

@ConditionalOnMissingBean: your bean wins

@ConditionalOnMissingBean is the rule that makes auto-configuration safe to live with: Boot only creates the bean if you have not. The AppConfig above declares a CharacterEncodingFilter named myEncodingFilter. Before it existed, the entry sat in Positive matches:

Text
   HttpEncodingAutoConfiguration#characterEncodingFilter matched:
      - @ConditionalOnMissingBean (types: org.springframework.web.filter.CharacterEncodingFilter; SearchStrategy: all) did not find any beans (OnBeanCondition)

With that one @Bean method added and nothing else changed, the same entry moves to Negative matches and names the bean that displaced it:

Text
   HttpEncodingAutoConfiguration#characterEncodingFilter:
      Did not match:
         - @ConditionalOnMissingBean (types: org.springframework.web.filter.CharacterEncodingFilter; SearchStrategy: all) found beans of type 'org.springframework.web.filter.CharacterEncodingFilter' myEncodingFilter (OnBeanCondition)

The bean count across both runs is unchanged at 159: Boot's bean stepped aside and yours took the slot. Note that the condition matched on the type, not on the name — calling the method myEncodingFilter rather than characterEncodingFilter made no difference at all.

The bean lifecycle

A bean is not just constructed. It goes through an ordered sequence, and knowing where you are in it is what stops the "why is this field still null" class of bug.

Five ordered steps for a singleton, the same five for a prototype, with the last one never called

Java
@Component
public class LifecycleDemo {
 
    private final Clock clock;
    private AuditLog auditLog;
 
    public LifecycleDemo(Clock clock) {
        this.clock = clock;
        System.out.println("LIFECYCLE 1 constructor: clock injected = " + (clock != null)
                + ", auditLog = " + auditLog);
    }
 
    @Autowired
    public void setAuditLog(AuditLog auditLog) {
        this.auditLog = auditLog;
        System.out.println("LIFECYCLE 2 setter injection: auditLog set");
    }
 
    @PostConstruct
    public void warmUp() {
        System.out.println("LIFECYCLE 3 @PostConstruct: every dependency present, zone = "
                + clock.getZone());
    }
 
    public void use() {
        System.out.println("LIFECYCLE 4 in use");
    }
 
    @PreDestroy
    public void shutDown() {
        System.out.println("LIFECYCLE 5 @PreDestroy: releasing resources");
    }
}

Running the application and then sending it a SIGTERM produces the full sequence. Boot's own log lines are interleaved; the Started DemoApplication timing is redacted as promised:

Text
INFO  o.s.boot.tomcat.TomcatWebServer  : Tomcat initialized with port 18437 (http)
INFO  o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.24]
LIFECYCLE 1 constructor: clock injected = true, auditLog = null
LIFECYCLE 2 setter injection: auditLog set
LIFECYCLE 3 @PostConstruct: every dependency present, zone = UTC
INFO  com.example.demo.DemoApplication : Started DemoApplication in [redacted]
LIFECYCLE 4 in use
INFO  o.s.boot.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
LIFECYCLE 5 @PreDestroy: releasing resources

Line 1 is the load-bearing one. Inside the constructor, clock is already set and auditLog is still null, because constructor injection happens as the object is built while setter and field injection happen after. That is why work that touches every dependency belongs in @PostConstruct and not in the constructor.

Singleton is the default scope

Every bean is a singleton unless you say otherwise — one instance per container, shared by everything that asks for it. Two services, each injecting the same AuditLog, printing System.identityHashCode of what they got:

Java
@Service
public class ServiceA {
 
    private final AuditLog auditLog;
 
    public ServiceA(AuditLog auditLog) {
        this.auditLog = auditLog;
    }
 
    public int auditLogIdentity() {
        return System.identityHashCode(auditLog);
    }
}
Text
SINGLETON ServiceA sees 1387556178, ServiceB sees 1387556178, same instance = true

Same number, so literally the same object. That is worth internalising, because it is also the reason a mutable field on a @Service is a concurrency bug: every request thread is sharing it.

Prototype, and the callback that never runs

@Scope("prototype") gives a fresh instance on every request for the bean:

Java
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class Ticket {
 
    @PostConstruct
    public void created() {
        System.out.println("TICKET @PostConstruct " + System.identityHashCode(this));
    }
 
    @PreDestroy
    public void destroyed() {
        System.out.println("TICKET @PreDestroy " + System.identityHashCode(this));
    }
}
Text
BEANS count = 159
SINGLETON ServiceA sees 1387556178, ServiceB sees 1387556178, same instance = true
TICKET @PostConstruct 1695301724
TICKET @PostConstruct 552266488
PROTOTYPE first 1695301724, second 552266488, same instance = false

Two things in that transcript are easy to miss. The two @PostConstruct lines appear after the bean count is printed, not during startup — a prototype is created when it is asked for, not when the context is built. And on shutdown, TICKET @PreDestroy never appears at all, in contrast with LIFECYCLE 5 for the singleton.

⚠️ Spring does not track prototype instances after handing them over, so @PreDestroy is never called on them. If a prototype bean owns a resource, closing it is your job.

Configuration: properties, YAML, profiles and precedence

Configuration lives in src/main/resources/application.properties, or in application.yaml if you prefer indentation to dots. The two are interchangeable; snakeyaml ships with the starter, so YAML needs no extra dependency.

spring.application.name=demo
app.owner=platform-team
demo.greeting.name=world
demo.greeting.locale=en
demo.greeting.exclamations=1

Swapping one file for the other and re-running produced identical output, which is the only sense in which "equivalent" means anything.

@Value and @ConfigurationProperties

@Value reads one property and is best for one-offs. It resolves a placeholder, so a missing key fails at startup unless you supply a default with ${app.owner:unknown}:

Java
public Diagnostics(@Value("${app.owner}") String owner) {
    this.owner = owner;
}

@ConfigurationProperties binds a whole prefix to a typed object, and since Java 16 that object can be a record — immutable, with the constructor doing the binding:

Java
@ConfigurationProperties(prefix = "demo.greeting")
public record GreetingProperties(String name, String locale, int exclamations) {
}

A record bound this way needs registering, either with @EnableConfigurationProperties(GreetingProperties.class) on a configuration class or with @ConfigurationPropertiesScan on the application class. It is then injected like any other bean:

Text
CONFIG name = world, locale = en, exclamations = 1
CONFIG app.owner (@Value) = platform-team
CONFIG active profiles = []

Prefer @ConfigurationProperties once a feature has more than one or two knobs: the values are typed and validated once at startup rather than string-by-string at each injection point, and the whole group travels as a single object.

Profiles and the precedence order

A profile is a named overlay. application-prod.properties is loaded on top of application.properties when the prod profile is active — it overrides the keys it mentions and leaves the rest alone:

Properties
demo.greeting.name=production
demo.greeting.exclamations=3
Bash
java -jar target/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod
Text
INFO com.example.demo.DemoApplication : The following 1 profile is active: "prod"
CONFIG name = production, locale = en, exclamations = 3
CONFIG active profiles = [prod]

name and exclamations came from the profile file; locale is still en from the base file, because a profile file adds to the base rather than replacing it.

Sources are consulted in a fixed order, and a command-line argument sits near the top of it:

PrioritySource
Highestcommand-line arguments (--demo.greeting.name=...)
OS environment variables (DEMO_GREETING_NAME)
application-{profile}.properties outside the jar
application-{profile}.properties inside the jar
application.properties outside the jar
Lowestapplication.properties inside the jar

Adding one argument to the prod run above, and changing nothing else, demonstrates the top two rows:

Bash
java -jar target/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod --demo.greeting.name=from-command-line
Text
CONFIG name = from-command-line, locale = en, exclamations = 3
CONFIG active profiles = [prod]

name now comes from the command line, exclamations still from the profile file, locale still from the base file. Three sources, one merged view, resolved by priority.

Testing: the full context and the narrow slice

spring-boot-starter-test arrives transitively with the web test starter and brings JUnit Jupiter 6.0.3, AssertJ, Mockito, Hamcrest, JSONPath and Spring's own test support in one dependency.

@SpringBootTest starts the real application context — every bean, exactly as production would build it:

Java
@SpringBootTest
class DemoApplicationTests {
 
    @Autowired
    private ApplicationContext context;
 
    @Autowired
    private GreetingService greetingService;
 
    @Test
    void contextLoads() {
        System.out.println("FULL CONTEXT beans = " + context.getBeanDefinitionCount());
        assertThat(context.containsBean("helloController")).isTrue();
        assertThat(greetingService.greet("en", "test")).isEqualTo("Hello, test");
    }
}

A slice test starts a deliberately incomplete context. @WebMvcTest loads the web layer and the controller you name, and nothing below it — collaborators are supplied as mocks with @MockitoBean, which replaced the removed @MockBean:

Java
@WebMvcTest(HelloController.class)
class HelloControllerSliceTests {
 
    @Autowired
    private ApplicationContext context;
 
    @MockitoBean
    private GreetingService greetingService;
 
    @Test
    void loadsOnlyTheWebLayer() {
        System.out.println("SLICE beans = " + context.getBeanDefinitionCount());
        assertThat(context.containsBean("helloController")).isTrue();
        assertThat(context.containsBean("greetingRepository")).isFalse();
    }
}
Text
SLICE beans = 109
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.demo.HelloControllerSliceTests
FULL CONTEXT beans = 156
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.demo.DemoApplicationTests

The honest way to describe the difference is what gets loaded, not how long it takes: 156 bean definitions against 109, and greetingRepository is provably absent from the slice. Fewer beans is also fewer things that can fail for reasons unrelated to the controller you are testing. In Boot 4 the annotation moved package — it is org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest, not the Boot 3 org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest.

What Spring Boot does not do

Boot is not a language feature and it changes nothing about how Java works. Underneath, it is classpath scanning, annotation metadata and reflection: SpringApplication.run reads class files, builds bean definitions, evaluates conditions, and calls constructors you could have called yourself. That has costs — startup does real work proportional to how many classes are scanned, and a mistake in an annotation surfaces at runtime rather than at compile time.

The useful consequence is that none of it is hidden. The cheapest route is --debug, which prints the conditions report shown above and answers both "why does this bean exist" and "why does it not".

The actuator exposes the same information over HTTP. Add spring-boot-starter-actuator, expose the endpoints, and /actuator/beans reports every bean with its scope and its dependencies:

Text
greetingService  -> singleton | com.example.demo.GreetingService | deps ['greetingRepository', 'plainFormatter']
ticket           -> prototype | com.example.demo.Ticket | deps []
clock            -> singleton | java.time.Clock$SystemClock | deps ['appConfig']
myEncodingFilter -> singleton | org.springframework.web.filter.CharacterEncodingFilter | deps ['appConfig']

Beyond that, the auto-configuration classes are ordinary Java in the spring-boot-autoconfigure jar. Opening the one you are arguing with and reading its @Conditional annotations is faster than searching for a blog post about it.

Two habits follow. Do not fight a default you have not read, and do not assume a bean exists because a tutorial said so — the report is authoritative for your classpath, and a tutorial is not.

FAQ

Is Spring Boot a replacement for Spring?

No. Spring Boot depends on Spring Framework and adds starters, auto-configuration and an embedded server on top. This project pulls in spring-context and spring-webmvc 7.0.9 underneath Boot 4.1.1. Every annotation in the container sections above is a Spring Framework annotation.

Do I still need @Autowired on a constructor?

Not since Spring 4.3, as long as the class has exactly one constructor. With two or more constructors you must mark the one the container should use, otherwise it picks the no-argument one if there is one and fails if there is not.

Why does my application fail with "expected single matching bean but found 2"?

Two beans satisfy one injection point by type and neither is preferred. Put @Primary on the one that should be the default, or @Qualifier("beanName") at the injection point that wants the other. Injecting a List of the interface type is the third option when you genuinely want all of them.

What is the difference between @Component, @Service and @Repository?

For component scanning, nothing — @Service and @Repository are meta-annotated with @Component. They differ in intent and in one behaviour: @Repository enables translation of persistence exceptions into Spring's DataAccessException hierarchy. Use the specific one where it applies, because tools and readers both use it as a layer marker.

Why is my @Value field null in the constructor?

Because it is a field, and fields are populated after the object is constructed. Move the value into a constructor parameter annotated with @Value, or do the work in a @PostConstruct method, where every form of injection has finished.

How do I see which properties the application actually resolved?

Run with --debug for the conditions report, and add the actuator for /actuator/env and /actuator/configprops, which show the resolved value of each key and which source it came from. That is a faster answer than reasoning about the precedence table when three files and an environment variable are in play.

Conclusion

The container is not doing anything mysterious: it reads your annotations, builds the same object graph you would have built in main, and hands out one instance per singleton bean. Auto-configuration is a list of candidate classes with conditions in front of them, and --debug prints the verdict for every one. Constructor injection, @Primary and @Qualifier, @PostConstruct and @PreDestroy, @ConfigurationProperties and the precedence order cover most of what a Spring application does before it does anything domain-specific.

The next article builds a real REST API with Spring Boot: @RestController and @RequestMapping, path variables and request bodies, status codes, exception handling and request validation.

Related Posts

[Advanced Java] Mocking with Mockito: Stubbing, Verification and When Not to Mock

Mockito 5.14.2 on OpenJDK 21: what a test double is and why a hand-written fake often beats a library, what an unstubbed mock returns, stubbing with when and thenReturn, argument matchers and the real InvalidUseOfMatchersException, verify with times, never, InOrder and ArgumentCaptor, MockitoExtension with Mock and InjectMocks, the spy trap, mocking final classes and static methods with the inline mock maker, and the over-mocking failure mode where a test that mocks everything tests nothing.

[Advanced Java] Functional Interfaces: Supplier, Consumer, Function and Predicate

Functional interfaces in java.util.function on OpenJDK 21: the shape grid behind all 43 of them, what @FunctionalInterface really checks, why an abstract equals does not break single-abstract-method status, andThen versus compose, the Predicate and Consumer combinators, the primitive specialisations and the boxing they remove, and how to write your own.

[Advanced Java] Working with JSON and XML in Java: Gson, Jackson and the Defaults That Bite

JSON binding in Java with Gson 2.10.1 and Jackson 2.17.3: round trips, nested objects and collections, renaming and ignoring fields, the null-handling and unknown-key defaults that differ, TypeToken and TypeReference for generics, java.time support, streaming large documents with JsonReader and JsonParser, the traps that fail somewhere other than the mistake, and what XML looks like on a JDK that no longer ships JAXB.

[Spring Boot Basics] Spring Framework vs Spring Boot: What Auto-Configuration, Starters and the Embedded Server Actually Do

Spring Framework 7.0.9 versus Spring Boot 4.1.1 on Java 21: what a plain Spring web application made you write, what starters and the spring-boot-dependencies BOM replace it with, why Boot 4 renamed the web starter to spring-boot-starter-webmvc, how auto-configuration backs off, embedded Tomcat against a WAR, and a map of the Spring ecosystem.