Build Your Own Starter: From Zero to Auto-Loaded

bee2026-10-0854 min read0 views
How many modules and annotations does a real starter need? Build an SMS starter end to end: auto-configuration, conditions, configuration properties, the imports file and verification.
1 / 142
Section
0. The 30-second version
2 / 142

This article is not about using Spring Boot — it is about building the thing that makes Spring Boot light up for somebody else when they merely add a dependency. You write a capability (say, sending SMS), package it as a jar, and the consumer only adds one <dependency> block plus a few lines of yml before injecting it like a built-in component. In Spring Boot that package is called a starter.

3 / 142
类比|Analogy

a starter is the central air-conditioning unit installed in your home. The manufacturer ships one cardboard box holding the compressor unit, the remote control and the install leaflet. I do exactly two things: plug it in (add the dependency to my pom) and press two buttons (write a few lines in yml) — and cold air arrives. I never need to know how the refrigerant loops or how the coils are wound; that is the factory's job. Now flip the box over: if inside it there is only a power cable and a shopping list, while the compressor and the remote sit in three different cupboards elsewhere in the house, the box saved me nothing. That is the first hard conclusion of this article: the xxx-spring-boot-starter box holds only the shopping list (pom dependencies); the compressor and the remote — all real Java code — live in the xxx-spring-boot-autoconfigure module.

4 / 142

Six terms you will see immediately, defined once here:

5 / 142
Table
TermIn one line
StarterA jar that "works once you depend on it" and contains almost no code itself
DependencyYour declaration in pom.xml that you want someone else's jar; Maven downloads it onto the classpath
classpathThe list of jars and folders searched at runtime — off it means the JVM cannot find your class
Container (IoC container)The warehouse Spring builds at startup that creates objects and hands them out when asked
BeanAn object the container creates and manages, rather than one you new scattered around
yml / propertiesConfiguration files of key: value pairs, e.g. sms.timeout: 2000, feeding those beans
Auto-configurationBoot walking a candidate list at startup, wiring each one only if its conditions pass
6 / 142
Diagram
Figure · Chapter map: what a starter is built from
Figure · Chapter map: what a starter is built from
7 / 142

After this article you should be able to answer three questions:

8 / 142
  • Do my Java classes belong in the starter module or the autoconfigure module, and which error message do I get if I choose wrong?
  • How does the container even learn that my third-party configuration class exists — which file, and how exact must its path be?
  • If a consumer wants to switch my starter off, or replace my default with their own implementation, what hook did I have to leave in place?
9 / 142
Section
1. The two forms of a starter: one carries dependencies, one carries logic
10 / 142

After adding spring-boot-starter-web, you write no configuration at all, yet you suddenly have embedded Tomcat, Spring MVC, Jackson serialization… Many people's first reaction is "this starter is magic". But open its pom and you find a counter-intuitive fact: the starter jar contains not a single line of Java.

11 / 142

Spring Boot officially splits "starter" into two things with completely different jobs:

12 / 142
Table
FormExampleWhat is insideIn one sentence
Dependency descriptor (starter)spring-boot-starter-webOnly a pom aggregating dependencies"What to buy"
Auto-configuration (autoconfigure)spring-boot-autoconfigureA set of XxxAutoConfiguration classes"How to wire it"
13 / 142

The spring-boot-starter-web pom (trimmed) looks roughly like this:

14 / 142
Code
Codexml
<project>    <artifactId>spring-boot-starter-web</artifactId>    <dependencies>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter</artifactId>       <!-- indirectly brings spring-boot-autoconfigure -->        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-json</artifactId>   <!-- Jackson -->        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-tomcat</artifactId> <!-- embedded container -->        </dependency>        <dependency>            <groupId>org.springframework</groupId>            <artifactId>spring-webmvc</artifactId>        </dependency>    </dependencies></project>
Notes
  • spring-boot-starter-web itself holds no logic; it only packages the list of jars a web app needs
  • The classes that actually make Tomcat and MVC spring to life — WebMvcAutoConfiguration, ServletWebServerFactoryAutoConfiguration — live in spring-boot-autoconfigure
  • So "adding a starter" means two steps: bring the dependencies, and bring a preset of auto-configuration

Tip: why does the official team split them? Because a company often has several starters that want to reuse the same auto-configuration logic. Once split, autoconfigure can be depended on and tested independently, while each starter only "composes a different bundle". Understand this split and you know how many modules your own project should have.

15 / 142
Section
2. Build the skeleton: the directory of sms-spring-boot-starter
16 / 142

Let's build an "SMS sending" starter from scratch. The goal is concrete: the consumer adds the dependency, writes a few lines of yml, and can immediately @Autowired SmsClient.

17 / 142
Diagram
Figure 1 · Anatomy of a starter
Figure 1 · Anatomy of a starter
18 / 142

Following official guidance, split into two modules:

19 / 142
text
sms-spring-boot-starter/                  # aggregator module (parent pom)├── pom.xml├── sms-spring-boot-autoconfigure/        # auto-configuration module: holds the logic│   ├── pom.xml│   └── src/main/│       ├── java/com/example/sms/autoconfigure/│       │   ├── SmsProperties.java        # configuration properties│       │   ├── SmsClient.java            # core service│       │   └── SmsAutoConfiguration.java # auto-configuration class│       └── resources/META-INF/spring/│           └── org.springframework.boot.autoconfigure.AutoConfiguration.imports└── sms-spring-boot-starter/              # descriptor module: holds the dependencies    └── pom.xml
20 / 142

The autoconfigure pom: the key point is to mark compile-time-only dependencies as optional:

21 / 142
xml
<project>    <artifactId>sms-spring-boot-autoconfigure</artifactId>    <dependencies>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-autoconfigure</artifactId>        </dependency>        <!-- Needed at compile time only, not forwarded to consumers -->        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-configuration-processor</artifactId>            <optional>true</optional>        </dependency>    </dependencies></project>
22 / 142

The starter pom only carries the autoconfigure module:

23 / 142
Code
Codexml
<project>    <artifactId>sms-spring-boot-starter</artifactId>    <dependencies>        <dependency>            <groupId>com.example</groupId>            <artifactId>sms-spring-boot-autoconfigure</artifactId>            <version>${project.version}</version>        </dependency>    </dependencies></project>
Notes
  • spring-boot-configuration-processor: a compile-time annotation processor that gives your sms.* properties IDE hints and docs in yml. It should exist only at your own compile time, hence optional
  • The starter module has almost no code; its value is that "the consumer depends on one coordinate"
  • If your starter is tiny and never reused, a single module is also fine; but splitting out autoconfigure is the officially recommended layering
24 / 142

Which boxes to tick in those two poms is worth generating once yourself rather than copying, because every scope and every optional in the output traces back to one rule in this section:

25 / 142
Generator
GeneratorWhat goes in each pom: the list carries dependencies, autoconfigure carries logicpom.xml2 / 5
Generate once with only Validation and Test ticked: the first is the implementation that actually runs the @NotBlank in your properties class, the second is the ApplicationContextRunner used by Level 3 in Section 18. Now add Redis and make it an optional dependency per Section 9 — that is Section 5's 'skip the whole auto-configuration when the dependency is absent', expressed as one line of pom. Check the generated scope and optional markers against the prose
Output
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>demo-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>17</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
Why each choice matters
parentInheriting 3.3.4 starter-parent means no spring-boot-starter-* needs a version; the moment someone adds an explicit version to one starter, that one wins — the most common source of dependency drift.
ValidationWithout it @Valid silently does nothing — @NotNull on its own checks no one.
Testscope=test: @SpringBootTest, MockMvc and AssertJ live here; without it @Test is unresolved.
26 / 142

Two modules are up; now, what does each of the four files actually own? That relationship is easier to mix up than the poms, so here it is as a stack you can click from the bottom up — remove one layer and the consumer's experience loses exactly one feature:

27 / 142
Diagram
LayersFour files, one job each1 / 4
Click from the bottom up: capability, parameters, wiring, entry point. The last one is the number-one crash site of Section 10
→
→
→
SmsClient | the capability
Business methods only, no Spring annotation anywhere on it: no `@Component`, no `@Service`. It is the capability itself, so it is testable and replaceable — a unit test just `new`s it with hand-built props. Letting it declare itself a component is the first step towards a consumer who can no longer swap it out (Section 4).
All clearMnemonic: capability registers nothing, unvalidated parameters might as well be absent, wiring yields to the user, and the list is what makes you seen.
28 / 142
Section
3. Step one: the configuration properties class
29 / 142

The properties class binds sms.* from yml into a Java object. It is the starter's "parameter panel":

30 / 142
Code
Codejava
package com.example.sms.autoconfigure;import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.validation.annotation.Validated;import jakarta.validation.constraints.Min;import jakarta.validation.constraints.NotBlank;@Validated@ConfigurationProperties(prefix = "sms")public class SmsProperties {    /** master switch, on by default */    private boolean enabled = true;    /** gateway endpoint */    @NotBlank    private String endpoint;    @NotBlank    private String accessKey;    @NotBlank    private String secretKey;    /** SMS signature, e.g. "Acme Inc." */    @NotBlank    private String signName;    /** timeout in milliseconds, at least 500 */    @Min(500)    private int timeout = 3000;    // getters / setters omitted}
Notes
  • @ConfigurationProperties(prefix = "sms"): declares it as the receiver of sms.*; field-to-property loose binding is its job (templateId accepts template-id)
  • @Validated: mandatory, otherwise @NotBlank and @Min are silently ignored — the line beginners most often forget
  • The validation annotations come from Jakarta Bean Validation; the consumer only needs an implementation on the classpath (such as hibernate-validator), and a bad config fails fast at startup, naming the offending field

Trap: writing @ConfigurationProperties without @Validated means validation does not run. More insidious still: someone puts validation annotations on fields but forgets the implementation dependency, so startup also does not fail — the empty property flows all the way into business code until messages cannot be sent.

31 / 142

The properties class is only the cup; the animation below shows how the water arrives. Six beats from one yml line to a live SmsClient — pay attention to beat ③, because that is the moment access-key and accessKey finally meet:

32 / 142
Animation
Animation · How one yml line reaches SmsProperties
Animation · How one yml line reaches SmsProperties
33 / 142
Section
4. Step two: the core service class SmsClient
34 / 142

SmsClient is the capability the starter actually exposes. The key insight: it is a plain POJO that registers nothing.

35 / 142
Code
Codejava
package com.example.sms.autoconfigure;import java.util.Map;public class SmsClient {    private final SmsProperties props;    // constructor injection: the auto-configuration passes it in; no self new-ing    public SmsClient(SmsProperties props) {        this.props = props;    }    public SmsResult send(String phone, String templateId, Map<String, String> params) {        // a real project would call the SMS gateway over HTTP here        System.out.printf("[SMS] endpoint=%s sign=%s, send to %s with %s%n",                props.getEndpoint(), props.getSignName(), phone, templateId);        return SmsResult.ok("msg-" + System.currentTimeMillis());    }}
Notes
  • No @Component, no @Service, no @Configuration: if it declares itself a component, it fights the registration logic in the auto-configuration, and the consumer can no longer replace it with a custom implementation
  • It receives SmsProperties through the constructor rather than creating it. That makes it naturally testable: a unit test just passes a hand-built SmsProperties
  • Separating "capability" from "wiring" is the first rule of a replaceable component: the capability belongs to the core class, the wiring belongs to the auto-configuration class
36 / 142
Section
5. Step three: the auto-configuration class SmsAutoConfiguration
37 / 142

This is the soul of the starter: it decides when to register SmsClient into the container.

38 / 142
java
package com.example.sms.autoconfigure;import org.springframework.boot.autoconfigure.AutoConfiguration;import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;import org.springframework.boot.context.properties.EnableConfigurationProperties;import org.springframework.context.annotation.Bean;@AutoConfiguration@ConditionalOnClass(SmsClient.class)@ConditionalOnProperty(prefix = "sms", name = "enabled", havingValue = "true", matchIfMissing = true)@EnableConfigurationProperties(SmsProperties.class)public class SmsAutoConfiguration {    @Bean    @ConditionalOnMissingBean    public SmsClient smsClient(SmsProperties props) {        return new SmsClient(props);    }}
39 / 142

Annotation by annotation — this is how you read and write auto-configuration:

40 / 142
  • @AutoConfiguration: the new marker, equivalent to @Configuration(proxyBeanMethods = false) but gaining ordering (before / after). It must be registered via the imports list and cannot be picked up by @ComponentScan
  • @ConditionalOnClass(SmsClient.class): the class must exist on the classpath. This is exactly the switch for "skip the whole auto-configuration when the dependency is absent"
  • @ConditionalOnProperty(prefix = "sms", name = "enabled", havingValue = "true", matchIfMissing = true): gives the consumer a master switch; matchIfMissing = true means "on by default even if not written in yml"
  • @EnableConfigurationProperties(SmsProperties.class): registers SmsProperties as a bean and binds it, so the @Bean method below can simply take a parameter
  • @ConditionalOnMissingBean (on the @Bean method): the embodiment of user first — if the consumer defines their own SmsClient, this steps aside; only when none exists does it provide the fallback
41 / 142

Those five annotations each own exactly one job, and those five jobs are what interviewers and tickets keep asking about. Lists are not memorable, so play a round: pick the annotation you wrote, then the one thing it is responsible for.

42 / 142
Match
MatchFive annotations, five separate jobsMatched 0/6 · Missed 0
Left: what you write on the auto-configuration class. Right: the single thing each one owns. Two of them look almost identical and are the most common mis-pick
Pick a card on the left first
43 / 142
Section
6. Step four: the registration list file
44 / 142

The auto-configuration class is written, but how does the container learn about it? Through a list file whose path must be exact:

45 / 142
text
src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
46 / 142

Its content is trivially simple, one class name per line, with no key at all:

47 / 142
Code
Codetext
com.example.sms.autoconfigure.SmsAutoConfiguration
Notes
  • This is the mechanism introduced in Spring Boot 2.7 and the only valid one since 3.0, replacing the old META-INF/spring.factories
  • The file name itself is the "key", so one typo in the name means the list does not exist — see the traps in Section 10
  • A starter may have several auto-configuration classes; omitting the package prefix makes a name resolve as same-package, so always write the fully-qualified name
48 / 142

Somebody always asks: why not put @ComponentScan on the auto-configuration class and sweep your own package in? It is less typing, yes — but it hands "when is a condition evaluated" back to scan order and takes it away from the container. Here is the difference between the two doors; through the left one, @ConditionalOnMissingBean can answer "none" far too early:

49 / 142
Diagram
Figure · Two doors: scanning versus the list
Figure · Two doors: scanning versus the list
50 / 142
Animation
Animation · How a starter gets loaded
Animation · How a starter gets loaded
51 / 142
Section
7. Step five: how the consumer uses it
52 / 142

Once the starter is done, the consumer does only three things.

53 / 142

First, add the dependency:

54 / 142
xml
<dependency>    <groupId>com.example</groupId>    <artifactId>sms-spring-boot-starter</artifactId>    <version>1.0.0</version></dependency>
55 / 142

Second, write the configuration:

56 / 142
yaml
sms:  enabled: true  endpoint: https://sms.example.com/api  access-key: ak-123456  secret-key: sk-abcdef  sign-name: Acme Inc.  timeout: 2000
57 / 142

Third, use it directly:

58 / 142
java
@Servicepublic class NoticeService {    private final SmsClient smsClient;   // inject directly, no @Bean needed    public NoticeService(SmsClient smsClient) {        this.smsClient = smsClient;    }    public void notifyUser(String phone) {        smsClient.send(phone, "TPL_LOGIN", Map.of("code", "8848"));    }}
59 / 142

A line of console output after startup proves the wiring worked:

60 / 142
Code
Codetext
[SMS] endpoint=https://sms.example.com/api sign=Acme Inc., send to 138****0000 with TPL_LOGIN
Notes
  • The consumer writes not a single @Bean, which is exactly the point of a starter being "out of the box"
  • If they set sms.enabled to false, SmsClient is not registered and injection fails with NoSuchBeanDefinitionException — direct evidence the condition works
  • If the consumer defines their own SmsClient, the auto-configuration yields and the consumer's implementation wins
61 / 142
Section
8. Debugging: find your auto-configuration in the condition report
62 / 142

When a starter "does not work", do not guess — turn on the condition report:

63 / 142
bash
java -jar app.jar --debug
64 / 142

Find your own configuration class in the report and check each condition:

65 / 142
Code
Codetext
Positive matches:-----------------   SmsAutoConfiguration matched:      - @ConditionalOnClass found required class 'com.example.sms.autoconfigure.SmsClient' (OnClassCondition)      - @ConditionalOnProperty (sms.enabled=true) matched (OnPropertyCondition)      - @ConditionalOnMissingBean (types: com.example.sms.autoconfigure.SmsClient) did not find any beans (OnBeanCondition)   SmsAutoConfiguration#smsClient matched:      - @ConditionalOnMissingBean (types: com.example.sms.autoconfigure.SmsClient) did not find any beans (OnBeanCondition)Negative matches:-----------------   SmsAutoConfiguration:      Did not match:         - @ConditionalOnClass did not find required class 'com.example.sms.autoconfigure.SmsClient' (OnClassCondition)
Notes
  • Read Positive matches: each line lists a condition that passed, effectively telling you "these are the ones that matched"
  • Read the Did not match: list under Negative matches: if SmsAutoConfiguration does not appear in the report at all, the problem is probably not a condition but the imports list not being read
  • A class missing entirely from the report is the dividing line between "wrong list path" and "condition not met"
66 / 142
Section
9. Naming and versioning conventions
67 / 142

The official guidance on custom starters is explicit; follow it to avoid many traps:

68 / 142
Table
ConventionRecommendationReason
Namingxxx-spring-boot-starterDistinguishes third-party starters from official spring-boot-starter-xxx
Module splitstarter + autoconfigureLogic is reusable and independently testable
Spring Boot dependencyUse optional or providedDo not force a version on consumers and cause conflicts
Depth of version couplingDo not depend on Boot internal classesInternal classes shift between minor versions and break on upgrade
Property prefixA company/project prefix (e.g. sms)Avoids colliding with official spring.*
DocsShip additional-spring-configuration-metadata.jsonGives IDE hints for custom properties
69 / 142
Key point

asked "how do you keep a custom starter compatible", two points suffice — keep dependencies optional and never let the starter decide the Boot version, and use only public APIs, never classes under an internal package. Do both and your starter rarely needs changes when consumers upgrade Boot.

70 / 142
Section
10. Two high-frequency traps
71 / 142
Table
SymptomRoot causeFix
Auto-configuration never loads, not even named in the reportThe imports path is wrong (missing spring/, written as spring.factories, or missing the package name)Verify exactly META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
The default clobbers the user's bean / the user's bean is ignored@ConditionalOnMissingBean is in the wrong place or has the wrong typePut it on the @Bean method providing the default, with the type you want to check
72 / 142
Trap

the imports file path is the single most common mistake. Three details you must remember: the directory is META-INF/spring/ (an extra spring level), the file name is that very long fully-qualified class name plus .imports, and the content allows neither comments nor a key= prefix. Many people's first starter "just does not work" only to find the file was placed under META-INF/.

73 / 142
Trap

the position of @ConditionalOnMissingBean is the second most common mistake. It belongs on the @Bean method that provides the default implementation, expressing "the user already defined a bean of this type, so I yield". Put it at the configuration-class level and it applies to every bean in that class; give it a superclass or the wrong type and it checks a different object, so it "always matches or never matches" — the default either registers twice or never takes effect.

74 / 142
Section
11. Hands-on: watch how conditions decide whether your starter takes effect
75 / 142

The demo below turns those three conditions into switchable knobs. Toggle "user already customized" and watch the registration of SmsClient get skipped:

76 / 142
Kernel lab
77 / 142
Section
12. Decision: should the company build an in-house starter
78 / 142
Decision
Decisionthree team projects all need the same combination of "SMS sending + log tracing + unified exceptions", and today each copies its own utility classes. Build an in-house starter?
79 / 142
Section
13. Wiring the whole chain: from "add the dependency" to "injection succeeds"
80 / 142

All four files are written; now connect them in startup order. This chain is the hardest part to derive on your own, and it is also the skeleton of the interview answer "walk me through how a custom starter works":

81 / 142
Animation
Animation · From dependency to injected bean: five wiring steps
Animation · From dependency to injected bean: five wiring steps
82 / 142
Table
StepWhat happensWho owns itWhat breaks if you get it wrong
1sms-spring-boot-starter in the consumer pom drags sms-spring-boot-autoconfigure onto the classpath (the classpath being the list of jars and folders searched at runtime)Maven transitive dependenciesWrong coordinates or version → classes missing already at compile time
2@EnableAutoConfiguration, sitting on @SpringBootApplication, reads META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports from every jarSpring Boot's auto-configuration import mechanismWrong path or file name → your class never enters the candidate list
3Conditions are evaluated one by one: @ConditionalOnClass checks the class exists, @ConditionalOnProperty checks the switch is onYour auto-configuration classAny failure → the class lands in Negative matches and no bean is built
4Once conditions pass, yml is bound into SmsProperties and the @Bean method builds a SmsClient; if the consumer already has a bean of that type, @ConditionalOnMissingBean makes you step asideYour @Bean methodMissing setter / wrong prefix → nothing binds and every field keeps its default
5The consumer writes SmsClient as a constructor parameter and the container hands over the instance built in step 4The IoC containerStep 3 failed → NoSuchBeanDefinitionException
83 / 142
Tip

the question beginners ask most is "I did write an auto-configuration class — what makes it run?" The answer is step 2 — not scanning, but a list file whose path must match character for character. Once you see that, you understand why Section 10 calls a wrong path the number-one crash site.

84 / 142

That table is for reading; order still has to be clicked. Here are the same five steps as a single-step run: six lines on the left, variables and call stack refreshing on the right. Step through and stop on beat ② — that beat decides whether the other four ever get a chance to happen:

85 / 142
Stepper
StepperStep by step: from "add the dependency" to "injection succeeds"1 / 6
Six beats. Watch the two cells 'candidate list length' and 'is your class in it' — beat 2 is where beginners are most thoroughly wrong
Code under debug
1// consumer pom: <artifactId>sms-spring-boot-starter</artifactId>
2SpringApplication.run(App.class, args) // @EnableAutoConfiguration sits inside @SpringBootApplication
3AutoConfigurationImportSelector.getCandidates() // reads META-INF/spring/*.imports from every jar
4filter(SmsAutoConfiguration.class) // OnClass / OnProperty are evaluated here
5bind(SmsProperties.class, environment) // name normalisation, type conversion, @Validated
6smsClient(props) writes a definition, then the consumer's constructor receives it
Variables now
what the consumer wroteone <dependency>
now on the classpathstarter jar + autoconfigure jar
your classesnobody has read them yet
Call stack
1Maven dependency resolution
2classpath
1Nothing Spring-specific yet: Maven merely placed two jars on the runtime search path. The starter jar holds no .class at all, only a pom — so 'adding the dependency' really means the autoconfigure jar has arrived.
86 / 142
Section
14. Kernel labs: run these five things for real in your browser
87 / 142

Pictures alone do not make the wiring chain stick, because it runs inside the first seconds of startup and never shows you an object. These five labs genuinely execute Spring Boot's layers in your browser, and each lets you flip parameters. Suggested order: ① for the overall picture, ② for registration and ordering, ③ for the condition annotations one at a time, ④ for how the consumer's configuration actually reaches SmsProperties, and ⑤ to settle which door SmsProperties and SmsClient each came in through.

88 / 142

The first lab takes apart the starter's four load-bearing columns. Metadata means "data describing your own configuration" — here, that imports list. Press the four buttons in order and you watch "list present → properties bound → bean created → I switch it off"; the last button is exactly where the decision card in Section 12 lands, giving consumers a graceful way to opt out:

89 / 142
Kernel lab
TeaVMThe four load-bearing columns of a starter: list, properties, bean, switchidle
Press the four buttons in order; finish on 'turning it off' and watch SmsClient vanish from the container
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
90 / 142

The second lab answers the most abstract part of step 2: which jars the candidate list is harvested from, and why the harvest has to be sorted. The "sorting" step reveals the before / after relations among the official auto-configurations — this is precisely what @AutoConfiguration adds over a bare @Configuration, and your third-party class is ordered after all official ones by default:

91 / 142
Kernel lab
TeaVMThe auto-configuration chain: where candidates come from, how they are filtered, in what order they applyidle
Look at 'where candidates come from' first, then at the report, and compare it with the --debug output in Section 8
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
92 / 142

The third lab evaluates the three condition annotations from Section 5 individually. A conditional annotation is simply a gate that says "wire this only if the condition holds". Switch to @ConditionalOnMissingBean and watch the default implementation get skipped once the user has defined a bean of the same type — the very thing the sandbox in Section 15 asks you to verify by hand:

93 / 142
Kernel lab
TeaVMA check-up for each condition annotation: OnClass / OnProperty / OnMissingBeanidle
Switch to @ConditionalOnMissingBean and compare 'user already defined one' against 'nobody defined one'
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
94 / 142

The fourth lab answers the sentence consumers ask most: "I wrote access-key in yml, my Java field is accessKey — how do those match?" Relaxed binding is the ability of @ConfigurationProperties to let kebab-case, camelCase and upper-case environment variables all hit the same field. While you are there, compare "who overrides whom" to see the priority relation among profile, environment variable and command line:

95 / 142
Kernel lab
TeaVMHow config reaches SmsProperties: precedence, profiles and relaxed bindingidle
Switch to 'who wins' to understand why editing yml on a server often appears to do nothing
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
96 / 142
Analogy

@ConfigurationProperties is a hotel key card. At check-in the front desk (yml + relaxed binding) writes floor, room number and expiry onto the card once; from then on every door beep (business code calling props.getEndpoint()) needs no re-explanation of who you are. And a mis-encoded card does not fail politely once — it fails on every single attempt afterwards, which is exactly why property binding must be validated hard at startup rather than discovered when the first SMS refuses to send.

97 / 142

The fifth lab answers a sentence nobody spells out: SmsProperties and SmsClient are both beans, but they did not come in through the same door. Click scan, bean, import, auto in turn — component scanning, an @Bean method, a direct @Import, and list-registered auto-configuration — and each prints the moment it writes its BeanDefinition. Your properties class takes the "direct" route via @EnableConfigurationProperties, SmsClient takes the @Bean route, and the auto-configuration class itself came in through the list. Then click miss and see why "the package was never scanned" and "the list was never read" produce the identical complaint:

98 / 142
Kernel lab
TeaVMTwo doors into the container: the properties class is imported, SmsClient is a @Beanidle
Run scan / bean / import / auto once each, then miss, and compare with the last two rows of the Section 16 error table — same 'no such bean', entirely different causes
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
99 / 142

Enough buttons; type the commands yourself instead. This console talks to the same in-browser kernel and every reply is computed there:

100 / 142
Console
101 / 142
Note

cond configLoaded false is meaningless until you also type restart and then beans — the switch only changes configuration, it does not rebuild the container for you. That little sequence is the manual version of "locate the break from the report" in Section 8.

102 / 142
Section
15. Sandbox: how the starter's switch should be configured
103 / 142

This sandbox simulates the final state of the consumer's project. Two knobs on the left: how sms.enabled is written, and whether the consumer defined a bean of the same type. The right panel immediately reports which SmsClient the container ends up holding, and whether the injection point blows up.

104 / 142

State the conclusion first: a well-designed starter exposes two hooks — switchable-off (sms.enabled) and replaceable (@ConditionalOnMissingBean). The first lets the consumer express intent in their own application.yml; the second guarantees the user's customization always beats your default. That is the payoff of the annotation-by-annotation reading in Section 5.

105 / 142
Sandbox
SandboxSwitch setting × did the user define their own bean
Result
Positive matches: SmsAutoConfiguration#smsClient matched
- @ConditionalOnProperty (sms.enabled) matched (OnPropertyCondition)
- @ConditionalOnMissingBean did not find any beans (OnBeanCondition)
The container holds 1 SmsClient — the auto-configured one
Because the code declares matchIfMissing = true, leaving it out means 'on'. This cell is the out-of-the-box default state.
106 / 142
Explanation

the least intuitive cell is "false + user defined one". Many assume switching the starter off means it is entirely out of the picture, yet the user's @Bean belongs to the consumer's own configuration class and is unrelated to your conditions. What decides "is there a bean" comes down to two things: do your conditions pass, and did they define one themselves.

107 / 142
Section
16. Common errors, searchable by exact wording
108 / 142

Every phrase in the first column can be pasted straight into a search box — do not paraphrase or shorten it. The last column says where to dig deeper.

109 / 142
Table
Error text (excerpt)Real cause30-second fixDig deeper in
Cannot resolve symbol 'SmsClient' (red at the consumer's import)The class simply is not in the jar being depended on — you put the Java code in the xxx-spring-boot-starter module, which by convention holds only pom dependenciesRun mvn dependency:tree to confirm what the coordinate resolves to; move every .java into xxx-spring-boot-autoconfigure and update the package declarations accordinglySections 2 & 13
No completion at all when typing sms. in yml, no descriptions either, yet the app runs fineEither the spring-boot-configuration-processor dependency is missing (IDE hints come from the generated META-INF/spring-configuration-metadata.json produced at compile time), or the @ConfigurationProperties(prefix = ...) value disagrees with the top-level yml keyAdd the processor back to the autoconfigure module with <optional>true</optional> and rebuild; check prefix against the yml top-level keySections 3 & 9 · #17 configuration
Property: sms.endpoint / Required: not null / Property: sms.timeout / Value: "100" / Reason: must be greater than or equal to 500Properties bound but validation failed. This block is Spring Boot's startup failure analysis; the constraints come from @NotBlank / @MinFix the fields named in the report; if you find these annotations never fire at all, it is because the class-level @Validated is missingSection 3
Could not bind properties to 'SmsProperties' : prefix=sms, ignoreInvalidFields=false, ignoreUnknownFields=trueBinding itself failed: a field has no setter (@ConfigurationProperties binds via JavaBean setters or a constructor), the types disagree (a Duration declared as int), or the prefix is misspelledAdd setters or switch to constructor binding; check the yml top-level key against the prefix in the message; temporarily set ignoreUnknownFields = false so unmatched keys fail loudlySections 3 & 7
The corresponding listeners for existing spring.factories entries for <class> were not found / a custom starter that "worked before the upgrade and now does nothing at all"Auto-configuration is still registered in the legacy META-INF/spring.factories under the key org.springframework.boot.autoconfigure.EnableAutoConfiguration. That key was deprecated in Boot 2.7 and removed in 3.0; Boot 3 reads only AutoConfiguration.importsSearch the repo for spring.factories and migrate the auto-configuration entry to META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, one fully-qualified class name per line (no key=)Section 6 · #18 auto-configuration internals
No qualifying bean of type 'com.example.sms.autoconfigure.SmsClient' available (even though you are sure the dependency is in and yml is written)Your auto-configuration never entered the candidate list: the imports file lost one spring/ directory level, the file name was shortened to something like auto-configuration.imports, or src/main/resources was not packaged into the jarRun `jar tf target/sms-spring-boot-autoconfigure-1.0.0.jar \grep -i imports` to see whether the file is physically inside the packageSections 6 & 10
Parameter 0 of constructor in noticeService required a single bean, but 2 were found: + - smsClient - mySmsClientThe user's custom implementation and your auto-configured default coexist. Usually @ConditionalOnMissingBean is misplaced (put at class level), or the consumer's @Bean registers later than your condition is evaluatedMove @ConditionalOnMissingBean onto the @Bean method that provides the default; as an emergency, mark the user's bean @Primary or add @Qualifier at the injection pointSections 5 & 10 · #19 conditional beans
Consider defining a bean of type 'com.example.sms.autoconfigure.SmsProperties' in your configuration@EnableConfigurationProperties(SmsProperties.class) is missing, or you placed SmsProperties in a package you expect @ComponentScan to pick up automaticallyAdd @EnableConfigurationProperties to the auto-configuration class; confirm the consumer's scan base packages do not include yoursSection 5 · #6 component scanning
110 / 142
Trap

the most misdiagnosed row in this table is the first. Cannot resolve symbol looks like "Maven did not download it", so the consumer spends half an hour clearing the local repository and re-importing, while the bug is on your side as the starter author: the code went into the wrong module. The test is trivial — run jar tf on that starter jar; if it contains only META-INF/maven/…pom.xml and no .class at all, that is the compliant shape (see Section 2).

111 / 142

The last two rows of that table only become distinguishable once you have clicked a real stack. This is the ticket starter authors receive most: dependency added, yml written, and the bean is still "not found". Do not read the analysis — pick the frame you believe is guilty:

112 / 142
Triage
Error triageNo qualifying bean of type 'com.example.sms.autoconfigure.SmsClient' available
No list entry: an auto-configuration that never became a candidate

The consumer put sms-spring-boot-starter 1.0.0 in the pom and wrote endpoint in the yml, yet startup says SmsClient cannot be found. They ran --debug and could not even search for the name SmsAutoConfiguration.

APPLICATION FAILED TO START
Field smsClient in com.example.notice.NoticeService required a bean of type 'com.example.sms.autoconfigure.SmsClient' that could not be found:
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.resolveFieldValue(AutowiredAnnotationBeanPostProcessor.java:785)
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.inject(AutowiredAnnotationBeanPostProcessor.java:659)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1420)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:599)
at com.example.notice.NoticeService.<init>(NoticeService.java:19)
Action:
Consider defining a bean of type 'com.example.sms.autoconfigure.SmsClient' in your configuration.
$ jar tf target/sms-spring-boot-autoconfigure-1.0.0.jar | grep -i imports
META-INF/auto-configuration.imports
Click the frame you blame — guessing is allowed
No pressure: guess the exception first, then which line actually made the call.
113 / 142
Section
17. Check yourself
114 / 142
Quiz
Check yourselfYou are building sms-spring-boot-starter. A colleague puts SmsClient, SmsProperties and SmsAutoConfiguration into the sms-spring-boot-starter module — the one meant only to aggregate dependencies — and everything compiles fine. What is the consequence?
Pick one — you get feedback right away
115 / 142
Quiz
Check yourselfTo make SmsAutoConfiguration get loaded, somebody suggests adding @ComponentScan("com.example.sms") to it. Why should an auto-configuration class never do that?
Pick one — you get feedback right away
116 / 142
Section
18. Practice in three levels
117 / 142
Section
Level 1 · Follow along
118 / 142

Goal: in fifteen minutes build a minimal working starter and walk all four states — "add dependency → live → switched off → not live".

119 / 142
java
// echo-spring-boot-autoconfigure/src/main/java/com/example/echo/EchoProperties.javapackage com.example.echo;import org.springframework.boot.context.properties.ConfigurationProperties;@ConfigurationProperties(prefix = "echo")public class EchoProperties {    private boolean enabled = true;    private String tag = "echo";    // keep every getter / setter — without a setter the property cannot bind    public boolean isEnabled() { return enabled; }    public void setEnabled(boolean enabled) { this.enabled = enabled; }    public String getTag() { return tag; }    public void setTag(String tag) { this.tag = tag; }}
120 / 142
java
// EchoAutoConfiguration.java — the smallest viable shapepackage com.example.echo;import org.springframework.boot.autoconfigure.AutoConfiguration;import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;import org.springframework.boot.context.properties.EnableConfigurationProperties;import org.springframework.context.annotation.Bean;@AutoConfiguration@EnableConfigurationProperties(EchoProperties.class)@ConditionalOnProperty(prefix = "echo", name = "enabled", havingValue = "true", matchIfMissing = true)public class EchoAutoConfiguration {    @Bean    @ConditionalOnMissingBean    public EchoService echoService(EchoProperties props) {        return target -> System.out.println("[" + props.getTag() + "] -> " + target);    }}
121 / 142
java
// EchoService.java — one method only, so Level 3 can extend itpackage com.example.echo;public interface EchoService {    void echo(String target);}
122 / 142
text
# echo-spring-boot-autoconfigure/src/main/resources/META-INF/spring/# org.springframework.boot.autoconfigure.AutoConfiguration.importscom.example.echo.EchoAutoConfiguration
123 / 142

The consumer's application.yml and the expected output:

124 / 142
yaml
echo:  tag: DEV
125 / 142
text
[DEV] -> alice
126 / 142

Done when: ① setting echo.enabled to false makes the injection point throw NoSuchBeanDefinitionException; ② jar tf shows .class files plus the imports file inside the autoconfigure jar while the starter jar holds only a pom; ③ you can explain why deleting the setter silently restores the default value.

127 / 142
Section
Level 2 · Variants
128 / 142

Each variant changes exactly one thing, and each has its own observation point:

129 / 142
  1. Add @Min(100) private int delay = 500; to EchoProperties but do not add @Validated. You will observe that writing echo.delay: 50 still starts cleanly — the trap from Section 3, validation silently disabled; after adding class-level @Validated, startup fails with must be greater than or equal to 100.
  2. Move the imports file from META-INF/spring/ up to META-INF/ (only relocate it; content unchanged). You will observe no error at all at startup, yet EchoService no longer exists, and --debug output does not even mention EchoAutoConfiguration — precisely the cause behind the second-to-last row of Section 16.
  3. In the consumer project define your own @Bean EchoService, placing it once in a plain @Configuration class and once in a class annotated @AutoConfiguration. You will observe the former making @ConditionalOnMissingBean yield correctly, while the latter easily lands in the sandbox's "on + one exists" cell and produces two candidate beans.
  4. Move @ConditionalOnProperty from class level onto the @Bean method, then set echo.enabled to false. You will observe EchoProperties still present (because @EnableConfigurationProperties remains on the class) while only EchoService disappears — the switch now governs a narrower scope.
130 / 142
Section
Level 3 · Build something
131 / 142

Build an in-house starter: oss-spring-boot-starter, wrapping "object-storage upload" so it works out of the box.

132 / 142
  • Structure must be two modules + four files (properties class, core service class, auto-configuration class, imports list), named per the Section 9 table
  • Properties at least oss.enabled / oss.endpoint / oss.bucket / oss.access-key / oss.secret-key / oss.timeout, with @NotBlank on access-key and secret-key, @Min(500) on timeout, plus @Validated
  • Ship an OssClient interface and one default implementation; the default's @Bean method must carry @ConditionalOnMissingBean so consumers can swap in their own mock
  • Include additional-spring-configuration-metadata.json giving oss.enabled a description and a defaultValue so IDEs show hints
  • Write an OssAutoConfigurationTest with ApplicationContextRunner covering four combinations: default on, enabled=false, user-supplied implementation, and startup failure when endpoint is missing
133 / 142

Acceptance checklist: ① a consumer injects OssClient with only a <dependency> plus five yml lines; ② --debug output shows one Positive match and one Negative match caused by enabled=false; ③ leaving access-key empty fails startup immediately and names that field; ④ when the consumer defines their own OssClient, theirs is the only one (prove it with doesNotHaveBean / hasSingleBean assertions); ⑤ dropping your starter into a brand-new project requires zero @Bean written by the consumer.

134 / 142
Section
19. Self-check
135 / 142
Self-check

from memory, say what xxx-spring-boot-starter and xxx-spring-boot-autoconfigure each carry and why they are split in two. If Java classes end up in the former, which error does the consumer see?

136 / 142
Self-check

which file makes the container discover your auto-configuration class? Write the full path, and explain why the old mechanism stopped working on Boot 3.

137 / 142
Self-check

what does each of @ConditionalOnClass / @ConditionalOnProperty / @ConditionalOnMissingBean actually test? Which scene of the central-air-conditioning analogy does each map to (plug not powered, switch turned off, guest brought their own umbrella)?

138 / 142
Self-check

should @ConditionalOnMissingBean sit on the class or on the @Bean method that supplies the default? What symptom does each mistake produce?

139 / 142
Self-check

why must an auto-configuration class avoid @ComponentScan? Which underlying precondition does it break?

140 / 142
Self-check

the consumer writes access-key while your field is accessKey — what makes them match, what is that ability called, and which dependency supplies the IDE completion?

141 / 142
Mnemonic

the list opens the door, conditions guard it, the key card carries the parameters, yielding protects the guest — imports decide whether you are seen at all, conditions decide whether you are wired, @ConfigurationProperties sets the parameters once, @ConditionalOnMissingBean guarantees the user always wins.

142 / 142
Summary

a working starter = two modules (starter for dependencies + autoconfigure for logic) + four files (properties class, core service class, auto-configuration class, imports list) + five annotations (@AutoConfiguration, @ConfigurationProperties, @ConditionalOnClass, @ConditionalOnProperty, @ConditionalOnMissingBean). The load pipeline is: consumer adds the dependency → @EnableAutoConfiguration triggers the import → read the imports list → evaluate conditions → bind yml config → register SmsClient. Remember two iron rules: the core service class is a plain POJO that is never declared a component, and the imports path and the position of @ConditionalOnMissingBean are the two crash sites for beginners.