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.
![]()
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 Framework | Spring Boot | |
|---|---|---|
| Gives you | the IoC container, DI, AOP, the web MVC stack | starters, auto-configuration, an embedded server |
| You write | the bean definitions | mostly nothing, then override what you disagree with |
| Output | a library your app uses | an executable jar with main |
| In this project | spring-context 7.0.9, spring-webmvc 7.0.9 | spring-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.
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 demoThere 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:
<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:
<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).
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:
@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);
}
}./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"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.

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.
@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:
@Service
public class FinalField {
@Autowired
private final GreetingRepository repository;
public String greet(String locale) {
return repository.find(locale).orElse("Hello");
}
}FinalField.java:10: error: variable repository not initialized in the default constructor
private final GreetingRepository repository;
^
1 errorThe 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:
@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:
static class FieldInjectedGreetingService {
@Autowired
private GreetingRepository repository;
String greet(String locale) {
return repository.find(locale).orElse("Hello");
}
}NPE MESSAGE: Cannot invoke "com.example.demo.GreetingRepository.find(String)" because "this.repository" is nullInside 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:
@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:
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:
***************************
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 consumedThe underlying exception, logged a few lines above the report, names the type and both candidates:
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,shoutFormatterTwo 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":
@Component
@Primary
public class PlainFormatter implements Formatter { /* ... */ }public Diagnostics(GreetingService greetingService,
@Qualifier("shoutFormatter") Formatter shout) {
this.greetingService = greetingService;
this.shout = shout;
}QUALIFIER primary -> Hello, world
QUALIFIER shoutFormatter -> HELLO, WORLDIf 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.

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:
| Condition | Matches when |
|---|---|
@ConditionalOnClass | a class is on the classpath |
@ConditionalOnMissingClass | a class is not on the classpath |
@ConditionalOnBean | a bean of a type or name already exists |
@ConditionalOnMissingBean | no such bean exists — this is the back-off rule |
@ConditionalOnProperty | a property has a given value |
@ConditionalOnWebApplication | the 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:
java -jar target/demo-0.0.1-SNAPSHOT.jar --server.port=18437 --spring.output.ansi.enabled=NEVER --debug============================
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:
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:
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:
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.

@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:
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 resourcesLine 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:
@Service
public class ServiceA {
private final AuditLog auditLog;
public ServiceA(AuditLog auditLog) {
this.auditLog = auditLog;
}
public int auditLogIdentity() {
return System.identityHashCode(auditLog);
}
}SINGLETON ServiceA sees 1387556178, ServiceB sees 1387556178, same instance = trueSame 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:
@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));
}
}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 = falseTwo 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
@PreDestroyis 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=1spring:
application:
name: demo
app:
owner: platform-team
demo:
greeting:
name: world
locale: en
exclamations: 1Swapping 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}:
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:
@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:
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:
demo.greeting.name=production
demo.greeting.exclamations=3java -jar target/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prodINFO 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:
| Priority | Source |
|---|---|
| Highest | command-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 | |
| Lowest | application.properties inside the jar |
Adding one argument to the prod run above, and changing nothing else, demonstrates the top two rows:
java -jar target/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod --demo.greeting.name=from-command-lineCONFIG 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:
@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:
@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();
}
}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.DemoApplicationTestsThe 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:
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.