Build Your Own Starter: From Zero to Auto-Loaded
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.
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.
Six terms you will see immediately, defined once here:
| Term | In one line |
|---|---|
| Starter | A jar that "works once you depend on it" and contains almost no code itself |
| Dependency | Your declaration in pom.xml that you want someone else's jar; Maven downloads it onto the classpath |
| classpath | The 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 |
| Bean | An object the container creates and manages, rather than one you new scattered around |
| yml / properties | Configuration files of key: value pairs, e.g. sms.timeout: 2000, feeding those beans |
| Auto-configuration | Boot walking a candidate list at startup, wiring each one only if its conditions pass |

After this article you should be able to answer three questions:
- Do my Java classes belong in the
startermodule or theautoconfiguremodule, 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?
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.
Spring Boot officially splits "starter" into two things with completely different jobs:
| Form | Example | What is inside | In one sentence |
|---|---|---|---|
| Dependency descriptor (starter) | spring-boot-starter-web | Only a pom aggregating dependencies | "What to buy" |
| Auto-configuration (autoconfigure) | spring-boot-autoconfigure | A set of XxxAutoConfiguration classes | "How to wire it" |
The spring-boot-starter-web pom (trimmed) looks roughly like this:
<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>spring-boot-starter-webitself 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 inspring-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.
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.

Following official guidance, split into two modules:
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.xmlThe autoconfigure pom: the key point is to mark compile-time-only dependencies as optional:
<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>The starter pom only carries the autoconfigure module:
<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>spring-boot-configuration-processor: a compile-time annotation processor that gives yoursms.*properties IDE hints and docs in yml. It should exist only at your own compile time, henceoptional- The
startermodule 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
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:
<?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>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:
The properties class binds sms.* from yml into a Java object. It is the starter's "parameter panel":
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}@ConfigurationProperties(prefix = "sms"): declares it as the receiver ofsms.*; field-to-property loose binding is its job (templateIdacceptstemplate-id)@Validated: mandatory, otherwise@NotBlankand@Minare 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.
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:

SmsClient is the capability the starter actually exposes. The key insight: it is a plain POJO that registers nothing.
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()); }}- 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
SmsPropertiesthrough the constructor rather than creating it. That makes it naturally testable: a unit test just passes a hand-builtSmsProperties - 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
This is the soul of the starter: it decides when to register SmsClient into the container.
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); }}Annotation by annotation — this is how you read and write auto-configuration:
@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 = truemeans "on by default even if not written in yml"@EnableConfigurationProperties(SmsProperties.class): registersSmsPropertiesas a bean and binds it, so the@Beanmethod below can simply take a parameter@ConditionalOnMissingBean(on the@Beanmethod): the embodiment of user first — if the consumer defines their ownSmsClient, this steps aside; only when none exists does it provide the fallback
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.
The auto-configuration class is written, but how does the container learn about it? Through a list file whose path must be exact:
src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importsIts content is trivially simple, one class name per line, with no key at all:
com.example.sms.autoconfigure.SmsAutoConfiguration- 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
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:


Once the starter is done, the consumer does only three things.
First, add the dependency:
<dependency> <groupId>com.example</groupId> <artifactId>sms-spring-boot-starter</artifactId> <version>1.0.0</version></dependency>Second, write the configuration:
sms: enabled: true endpoint: https://sms.example.com/api access-key: ak-123456 secret-key: sk-abcdef sign-name: Acme Inc. timeout: 2000Third, use it directly:
@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")); }}A line of console output after startup proves the wiring worked:
[SMS] endpoint=https://sms.example.com/api sign=Acme Inc., send to 138****0000 with TPL_LOGIN- The consumer writes not a single
@Bean, which is exactly the point of a starter being "out of the box" - If they set
sms.enabledtofalse,SmsClientis not registered and injection fails withNoSuchBeanDefinitionException— direct evidence the condition works - If the consumer defines their own
SmsClient, the auto-configuration yields and the consumer's implementation wins
When a starter "does not work", do not guess — turn on the condition report:
java -jar app.jar --debugFind your own configuration class in the report and check each condition:
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)- 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 underNegative matches: ifSmsAutoConfigurationdoes 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"
The official guidance on custom starters is explicit; follow it to avoid many traps:
| Convention | Recommendation | Reason |
|---|---|---|
| Naming | xxx-spring-boot-starter | Distinguishes third-party starters from official spring-boot-starter-xxx |
| Module split | starter + autoconfigure | Logic is reusable and independently testable |
| Spring Boot dependency | Use optional or provided | Do not force a version on consumers and cause conflicts |
| Depth of version coupling | Do not depend on Boot internal classes | Internal classes shift between minor versions and break on upgrade |
| Property prefix | A company/project prefix (e.g. sms) | Avoids colliding with official spring.* |
| Docs | Ship additional-spring-configuration-metadata.json | Gives IDE hints for custom properties |
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.
| Symptom | Root cause | Fix |
|---|---|---|
| Auto-configuration never loads, not even named in the report | The 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 type | Put it on the @Bean method providing the default, with the type you want to check |
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/.
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.
The demo below turns those three conditions into switchable knobs. Toggle "user already customized" and watch the registration of SmsClient get skipped:
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":

| Step | What happens | Who owns it | What breaks if you get it wrong |
|---|---|---|---|
| 1 | sms-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 dependencies | Wrong 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 jar | Spring Boot's auto-configuration import mechanism | Wrong path or file name → your class never enters the candidate list |
| 3 | Conditions are evaluated one by one: @ConditionalOnClass checks the class exists, @ConditionalOnProperty checks the switch is on | Your auto-configuration class | Any failure → the class lands in Negative matches and no bean is built |
| 4 | Once 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 aside | Your @Bean method | Missing setter / wrong prefix → nothing binds and every field keeps its default |
| 5 | The consumer writes SmsClient as a constructor parameter and the container hands over the instance built in step 4 | The IoC container | Step 3 failed → NoSuchBeanDefinitionException |
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.
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:
// consumer pom: <artifactId>sms-spring-boot-starter</artifactId>SpringApplication.run(App.class, args) // @EnableAutoConfiguration sits inside @SpringBootApplicationAutoConfigurationImportSelector.getCandidates() // reads META-INF/spring/*.imports from every jarfilter(SmsAutoConfiguration.class) // OnClass / OnProperty are evaluated herebind(SmsProperties.class, environment) // name normalisation, type conversion, @ValidatedsmsClient(props) writes a definition, then the consumer's constructor receives it| what the consumer wrote | one <dependency> |
| now on the classpath | starter jar + autoconfigure jar |
| your classes | nobody has read them yet |
Maven dependency resolutionclasspathPictures 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.
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:
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:
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:
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:
@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.
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:
Enough buttons; type the commands yourself instead. This console talks to the same in-browser kernel and every reply is computed there:
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.
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.
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.
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
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.
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.
| Error text (excerpt) | Real cause | 30-second fix | Dig 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 dependencies | Run mvn dependency:tree to confirm what the coordinate resolves to; move every .java into xxx-spring-boot-autoconfigure and update the package declarations accordingly | Sections 2 & 13 | |
No completion at all when typing sms. in yml, no descriptions either, yet the app runs fine | Either 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 key | Add the processor back to the autoconfigure module with <optional>true</optional> and rebuild; check prefix against the yml top-level key | Sections 3 & 9 · #17 configuration | |
Property: sms.endpoint / Required: not null / Property: sms.timeout / Value: "100" / Reason: must be greater than or equal to 500 | Properties bound but validation failed. This block is Spring Boot's startup failure analysis; the constraints come from @NotBlank / @Min | Fix the fields named in the report; if you find these annotations never fire at all, it is because the class-level @Validated is missing | Section 3 | |
Could not bind properties to 'SmsProperties' : prefix=sms, ignoreInvalidFields=false, ignoreUnknownFields=true | Binding 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 misspelled | Add 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 loudly | Sections 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.imports | Search 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 jar | Run `jar tf target/sms-spring-boot-autoconfigure-1.0.0.jar \ | grep -i imports` to see whether the file is physically inside the package | Sections 6 & 10 |
Parameter 0 of constructor in noticeService required a single bean, but 2 were found: + - smsClient - mySmsClient | The 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 evaluated | Move @ConditionalOnMissingBean onto the @Bean method that provides the default; as an emergency, mark the user's bean @Primary or add @Qualifier at the injection point | Sections 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 automatically | Add @EnableConfigurationProperties to the auto-configuration class; confirm the consumer's scan base packages do not include yours | Section 5 · #6 component scanning |
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).
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:
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.
Goal: in fifteen minutes build a minimal working starter and walk all four states — "add dependency → live → switched off → not live".
// 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; }}// 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); }}// EchoService.java — one method only, so Level 3 can extend itpackage com.example.echo;public interface EchoService { void echo(String target);}# echo-spring-boot-autoconfigure/src/main/resources/META-INF/spring/# org.springframework.boot.autoconfigure.AutoConfiguration.importscom.example.echo.EchoAutoConfigurationThe consumer's application.yml and the expected output:
echo: tag: DEV[DEV] -> aliceDone 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.
Each variant changes exactly one thing, and each has its own observation point:
- Add
@Min(100) private int delay = 500;toEchoPropertiesbut do not add@Validated. You will observe that writingecho.delay: 50still starts cleanly — the trap from Section 3, validation silently disabled; after adding class-level@Validated, startup fails withmust be greater than or equal to 100. - Move the imports file from
META-INF/spring/up toMETA-INF/(only relocate it; content unchanged). You will observe no error at all at startup, yetEchoServiceno longer exists, and--debugoutput does not even mentionEchoAutoConfiguration— precisely the cause behind the second-to-last row of Section 16. - In the consumer project define your own
@Bean EchoService, placing it once in a plain@Configurationclass and once in a class annotated@AutoConfiguration. You will observe the former making@ConditionalOnMissingBeanyield correctly, while the latter easily lands in the sandbox's "on + one exists" cell and produces two candidate beans. - Move
@ConditionalOnPropertyfrom class level onto the@Beanmethod, then setecho.enabledtofalse. You will observeEchoPropertiesstill present (because@EnableConfigurationPropertiesremains on the class) while onlyEchoServicedisappears — the switch now governs a narrower scope.
Build an in-house starter: oss-spring-boot-starter, wrapping "object-storage upload" so it works out of the box.
- 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@NotBlankon access-key and secret-key,@Min(500)on timeout, plus@Validated - Ship an
OssClientinterface and one default implementation; the default's@Beanmethod must carry@ConditionalOnMissingBeanso consumers can swap in their own mock - Include
additional-spring-configuration-metadata.jsongivingoss.enabledadescriptionand adefaultValueso IDEs show hints - Write an
OssAutoConfigurationTestwithApplicationContextRunnercovering four combinations: default on,enabled=false, user-supplied implementation, and startup failure when endpoint is missing
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.
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?
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.
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)?
should @ConditionalOnMissingBean sit on the class or on the @Bean method that supplies the default? What symptom does each mistake produce?
why must an auto-configuration class avoid @ComponentScan? Which underlying precondition does it break?
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?
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.
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.