Building a Spring Boot Starter from Scratch: Auto-Configuration, Conditional Assembly & Rate-Limiter Example
This comprehensive guide walks through creating a production-ready Spring Boot 3.x starter, covering auto-configuration mechanics, conditional annotations, configuration properties, registration files, and a complete token-bucket rate-limiter implementation with AOP, plus debugging tips and common pitfalls.
What Is a Starter: Definition and Evolution
A Starter is not a technology but a packaging convention. It contains almost no code; the real logic lives in the auto-configuration module. The Starter simply aggregates dependencies so that adding a single coordinate makes the feature work by convention.
1.1 It Solves the "Configuration Hauling" Problem
Without Starters, integrating middleware required four steps per project: add dependency, write configuration class, configure connection parameters, repeat across environments. Starters compress this to one step: add a dependency coordinate, the rest happens by convention. The key semantic difference is "if you don't object, I'll use defaults" — not "auto-guessing". This is exactly why @ConditionalOnMissingBean exists.
1.2 Official Two-Module Split and Why
Official repositories show a pattern: spring-boot-autoconfigure holds all *AutoConfiguration classes, while spring-boot-starter-web etc. contain only dependency declarations. The split centralizes configuration for unified evolution; the Starter only selects dependencies. For an internal company Starter, merging into one module is perfectly fine — the benefit of sharing auto-configuration across multiple Starters doesn't apply.
1.3 Naming Convention: An Often Overlooked Rule
Third-party Starters should use xxx-spring-boot-starter (e.g., mybatis-spring-boot-starter), not spring-boot-starter-xxx — that namespace belongs to Spring official and would clash with future official modules.
1.4 2.x to 3.x: A Critical Breaking Change
The old registration mechanism fails completely in 3.0, often silently — the dependency is present but beans don't appear. In 2.6 and earlier, auto-configuration was registered via META-INF/spring.factories with key
org.springframework.boot.autoconfigure.EnableAutoConfiguration. From 2.7 both formats were supported (transition). From 3.0 only
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importsis recognized; the EnableAutoConfiguration key in spring.factories was removed. Additionally, the configuration class annotation changed from @Configuration to @AutoConfiguration, which forces proxyBeanMethods=false (avoiding CGLIB proxy overhead) and supports after / before ordering declarations. Note: spring.factories itself hasn't disappeared — it's still used for EnvironmentPostProcessor, ApplicationContextInitializer, etc. Only its use for auto-configuration registration is gone.
Technical Analysis: Four Components of a Starter
Every Starter, no matter how complex, consists of four pieces. Missing any makes it "works but incomplete" — e.g., without the metadata file, YAML editing lacks IDE hints.
① Auto-configuration class : annotated with @AutoConfiguration, declares components via @Bean.
② Conditional annotations : decide "when to assemble, when to step aside".
③ Configuration properties class : @ConfigurationProperties binds YAML to an object.
④ Registration file : tells Spring Boot "I have auto-configuration here".
2.1 Skeleton: Thirty Lines to Run
@AutoConfiguration
@ConditionalOnClass(RateLimiter.class)
@EnableConfigurationProperties(RateLimitProperties.class)
public class RateLimitAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public RateLimiter rateLimiter(RateLimitProperties props) {
return new RateLimiter(props.getDefaultPermits());
}
}Five annotations, three actions: declare auto-configuration ( @AutoConfiguration), declare activation precondition ( @ConditionalOnClass), declare step-aside rule ( @ConditionalOnMissingBean). @ConditionalOnMissingBean makes "user customization wins" the default behavior. Your provided RateLimiter is a fallback; anyone dissatisfied declares a same-type bean in their project and it overrides without @Primary, config changes, or excluding auto-configuration.
2.2 Conditional Annotations: Seven Must-Know
Dozens exist internally; only seven are high-frequency for component authors: @ConditionalOnClass / @ConditionalOnResource: activate only if class or resource exists on classpath — the Starter's first gate. @ConditionalOnMissingBean / @ConditionalOnSingleCandidate: activate only if bean absent (or unique) in container — former used for overridable fallbacks. @ConditionalOnProperty / @ConditionalOnWebApplication: toggle by config property or application type — key is havingValue and matchIfMissing. @ConditionalOnBean: activate if bean present — but unreliable in auto-configuration , reason in 3.3.
Composition pattern: class-level OnClass blocks "dependency missing", method-level OnMissingBean blocks "user already provided", plus OnProperty as a master switch. Three-layer filtering leaves only what truly needs assembly.
2.3 Why @ConditionalOnClass Is Safe When Class Absent
Counter-intuitive point, often missed in interviews: if the class referenced by @ConditionalOnClass isn't on classpath, why doesn't JVM throw NoClassDefFoundError when loading the configuration class? Answer: Spring Boot uses ASM to read bytecode directly, extracts annotation metadata, and only checks existence via Class.forName on the class name string — it does not trigger loading/linking of the configuration class itself. However, this safety only holds when the reference appears in annotation attributes. If you write a method signature using the optional class, bytecode parsing must load that class:
// Dangerous: method directly uses optional dependency type
@Bean
public RateLimitAspect aspect(RateLimiter limiter) { ... }
// Safe: isolate via Object or nested class
@Bean
@ConditionalOnClass(Aspect.class)
public Object aspect(RateLimiter limiter) {
return new RateLimitAspect(limiter);
}Standard practice: put beans depending on optional libraries into static inner classes , annotate the inner class with conditions. Outer class bytecode doesn't reference optional types; inner class is processed only after existence confirmed.
Why official code does this: Look at DataSourceAutoConfiguration — full of static class PooledDataSourceConfiguration nested classes, each with its own conditions. Not for aesthetics; so projects without Druid don't crash during configuration class parsing.
2.4 Configuration Properties: Three Key Points for @ConfigurationProperties
@ConfigurationProperties(prefix = "demo.ratelimit")
public class RateLimitProperties {
private boolean enabled = true;
private int defaultPermits = 100;
private Duration defaultWindow = Duration.ofSeconds(1);
// getters/setters omitted
}Use Duration not int . Duration supports 1s, 500ms, PT2S — self-describing; int forces unit into field name ( windowMillis) or it's forgotten in three months.
All fields must have defaults. Starter defaults = behavior when user configures nothing. If default is 0 or null, "zero-config usable" promise breaks — user must read docs, Starter loses half its value.
Register via @EnableConfigurationProperties , not @Component . Former handles binding and works even if user's component scan misses the package; latter relies on scanning, but Starter packages are usually outside user's scan scope.
2.5 Registration File and Metadata
First file: auto-configuration registration manifest . In 3.x path fixed, one fully-qualified class name per line, no key, no equals:
Path:
src/main/resources/META-INF/spring/
org.springframework.boot.autoconfigure
.AutoConfiguration.imports
Content (class name only):
com.example.ratelimit.RateLimitAutoConfigurationSecond file: configuration metadata — not used at runtime, only for IDE auto-complete and hover docs. Generated at compile time by spring-boot-configuration-processor from Javadoc on @ConfigurationProperties class.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency> optional=trueis critical: processor only needed at your compile time, must not propagate to consumers . Otherwise their builds also run metadata generation, slowing them down.
2.6 POM Dependency Strategy: Three "Don'ts"
Don't depend on spring-boot-starter . Depend on spring-boot-autoconfigure only. Pulling full starter drags Tomcat, logging, Jackson to consumers, risking conflicts.
Optional dependencies must be marked optional . AOP, Redis etc. — only needed in specific scenarios — marked optional so they aren't forced on consumers, yet local compile still works.
Don't lock versions. Use spring-boot-dependencies BOM; let consumer's Boot version decide.
All three follow one principle: Starter is a guest, must not become the host — adding it should barely change the dependency tree.
Conditional Assembly: Seven Conditions and Their Execution Order
Writing conditions isn't mastery; knowing when they are evaluated is. This chapter answers three concrete doubts: why @ConditionalOnMissingBean is reliable, why @ConditionalOnBean isn't, why conditions sometimes seem ignored.
3.1 Conditions Evaluated in Two Distinct Phases
Spring splits condition evaluation into two phases — the key to all "condition not working" issues:
PARSE_CONFIGURATION (parse phase) : evaluated immediately when configuration class scanned; no BeanFactory available yet . Suitable only for "check classpath, config property, environment" judgments.
REGISTER_BEAN (registration phase) : evaluated when @Bean methods are converted to BeanDefinitions; BeanFactory is available . Any condition needing "check if container has a bean" must run here.
Corresponding mechanism: Condition#getConfigurationPhase(). OnBeanCondition (parent of @ConditionalOnBean and @ConditionalOnMissingBean) returns REGISTER_BEAN — meaning "don't ask me in parse phase, container incomplete".
Three-phase correspondence:
① Parse configuration class
ConditionEvaluator.shouldSkip(meta, PARSE_CONFIGURATION)
→ OnClass / OnProperty effective here
② Register BeanDefinition
Phase = REGISTER_BEAN
→ OnBean / OnMissingBean effective here
③ Instantiate Bean
→ Property binding, dependency injection3.2 Key Mechanism: Auto-Configuration Is "Deferred"
This is the most clever design in auto-configuration, and the sole reason @ConditionalOnMissingBean works reliably. Auto-configuration classes aren't imported via ordinary @Import; they go through DeferredImportSelector. "Deferred" means: wait until all user @Configuration classes are parsed, all BeanDefinitions registered, then it's my turn.
① Parse user configuration classes
Register userRateLimiter
② All main configuration classes done
③ Then execute DeferredImportSelector
Read imports → filter → sort
④ Parse RateLimitAutoConfiguration
OnMissingBean checks container → already present
→ Skip, don't registerWithout step ④'s "deferral", order reverses: auto-configuration registers rateLimiter first, user declaration later creates two same-type beans , injection throws NoUniqueBeanDefinitionException. So "deferral" isn't optimization — it's a correctness prerequisite.
Verifiable corollary: If your auto-configuration class is processed before user configuration (e.g., user @Import s your auto-configuration class directly instead of via imports file), @ConditionalOnMissingBean fails — because it sees an empty container.
3.3 Why @ConditionalOnBean Is Unreliable in Auto-Configuration
@ConditionalOnMissingBeanreliable, but its "antonym" @ConditionalOnBean is a known pitfall. Same mechanism, two faces: order between auto-configurations doesn't guarantee your imagined dependency order .
Assume A depends on bean from B:
// A wants B's bean
@AutoConfiguration
@ConditionalOnBean(BService.class)
public class AAutoConfiguration { ... }
// If sorting processes A before B
// A judges, B not yet registered
// → Condition fails, A skipped
// → But B registers normally afterward
// → Result: A vanishesFix isn't avoiding it, but explicitly declaring order :
@AutoConfiguration(after = BAutoConfiguration.class)
@ConditionalOnBean(BService.class)
public class AAutoConfiguration { ... } afteronly guarantees "B processed first"; safer: don't judge, just use parameter injection — if B missing, Spring fails fast at A instantiation with clear "bean not found" error, far better than silent non-activation. My advice to all component authors: if dependency injection can express the dependency, don't use conditional annotations .
3.4 Condition Evaluation Order and Short-Circuit
Multiple conditions on same class evaluated in declaration order, short-circuit on first failure . So order is performance design: cheapest @ConditionalOnProperty first, classloader-checking OnClass next, BeanFactory-traversing OnBean last.
Core Flow: From a JAR to a Bunch of Beans
This chapter re-walks the auto-configuration flow from "component author perspective". Difference: previous article cared "how framework finds me", this cares "what can I control at each step".
4.1 Seven-Stage Panorama
Full chain: Compile & Package → Read Candidates → Deduplicate & Exclude → Batch Pre-filter → Sort → Register & Evaluate Conditions → Bind Properties & Instantiate . First four decide "which classes selected", last three decide "how they become beans".
4.2 Stage One: What Happens at Packaging
No magic: imports file is hand-written , placed under resources/META-INF/spring/, packaged into JAR as resource. Because it's hand-written, typos in class name are the most common "silent failure" : wrong class name, extra package level, missing .imports suffix — all no error, just beans absent. Worth a dedicated check:
Verify JAR contains correct file:
unzip -l target/xxx-starter.jar \
| grep META-INF/spring
Should see:
META-INF/spring/
org.springframework.boot
.autoconfigure
.AutoConfiguration.imports
Note: this is the full filename
not a directory, cannot be split across lines4.3 Stages Two to Four: Read, Deduplicate, Filter
At startup, AutoConfigurationImportSelector scans all JARs on classpath for same-named imports files , merges contents into candidate list via ImportCandidates.load() (3.x new API). Immediately deduplicates with LinkedHashSet (preserves order because later sorting falls back to "file read order"), then removes exclusions from @SpringBootApplication(exclude=...) or config properties. Next is performance-critical batch pre-filter : OnClassCondition implements AutoConfigurationImportFilter, uses ASM to read all candidate classes' @ConditionalOnClass metadata in one pass, discards entire batches where classpath unsatisfied.
Derived conclusion: If your auto-configuration class only has conditions on @Bean methods, none on class-level @ConditionalOnClass , it cannot be batch pre-filtered , falls through to per-class parsing. This is a hidden cause of "some projects start slow" — adding @ConditionalOnClass to the class is itself a startup optimization.
4.4 Stage Five: Three Sorting Criteria
AutoConfigurationSortersorts by three priority levels, high to low: @AutoConfigureOrder: explicit integer, smaller first. @AutoConfiguration(after/before): declare relative order, form directed graph, topological sort.
Alphabetical: when none declared, sort by class name — why official classes follow XxxAutoConfiguration uniform suffix.
Note: after is a relative constraint , not absolute position. after = X.class only guarantees "I'm after X"; if X itself isn't assembled (condition fails), constraint auto-invalidates, no error. This "loose coupling" avoids hard dependencies between components.
4.5 Stages Six to Seven: Register, Bind, Instantiate
After sorting, each candidate parsed as ordinary configuration class: evaluate class-level conditions, if pass register @Bean methods as BeanDefinitions; then ConfigurationPropertiesBindingPostProcessor binds config to properties objects before bean initialization; finally instantiate. @ConditionalOnMissingBean already evaluated before this moment.
Detail often missed: conditions use BeanDefinitions, not bean instances . So @ConditionalOnMissingBean checks "exists" before object creation — it sees "registration info". Explains two things: it doesn't trigger early instantiation (performance), and can't detect "runtime dynamically registered" beans.
Core Source Code: Five Methods That Decide Success or Failure
Auto-configuration source looks large; main trunk is just five methods. Reading them isn't for memorizing flow, but knowing which stage to inspect when things break .
5.1 getAutoConfigurationEntry : Four Steps Determine Fate
// AutoConfigurationImportSelector
AutoConfigurationEntry
getAutoConfigurationEntry(
AnnotationMetadata meta,
AnnotationAttributes attrs) {
if (!isEnabled(meta, attrs))
return EMPTY_ENTRY;
// ① Read candidates (cross-JAR scan)
List<String> candidates =
getCandidateConfigurations(meta, attrs);
// ② Remove exclusions
Set<String> exclusions =
getExclusions(meta, attrs, candidates);
candidates.removeAll(exclusions);
// ③ Batch filter (performance critical)
candidates = getConfigurationClassFilter()
.filter(candidates);
// ④ Sort
return new AutoConfigurationEntry(candidates, exclusions);
}Four steps map to four "non-activation" categories: ① file not in JAR, ② excluded, ③ pre-filtered out, ④ sorting causes dependency not ready at condition evaluation. Troubleshoot in this order.
5.2 Reading: Why Multiple Starters Don't Conflict
Reading uses getResources not getResource — former returns all same-named resources across all JARs , not just first. This is exactly why "multiple Starters each declare, don't overwrite each other" works; also means same name/path all get read, deduplication handles the rest.
5.3 ConditionEvaluator.shouldSkip : How Conditions Are Judged
public boolean shouldSkip(
AnnotatedTypeMetadata meta,
ConfigurationPhase phase) {
for (Condition c : getConditions(meta)) {
// Phase mismatch → skip, not fail
ConfigurationPhase p = c.getConfigurationPhase();
if (p != null && p != phase)
continue;
// Any unsatisfied → skip entirely
if (!c.matches(context, meta))
return true;
}
return false;
}Two points to remember: phase-mismatched conditions are continue d, not judged false — so OnBeanCondition in parse phase "doesn't exist", not treated as false; any condition unsatisfied immediately return true — that's short-circuit, why condition order affects startup performance.
5.4 OnBeanCondition : Implementation of Stepping Aside
getMatchOutcomecore three steps: extract declared type/name from condition → call getMatchingBeans to query container → decide match/noMatch per condition type (MISSING vs present). getMatchingBeans internally uses getBeanNamesForType() — queries registered BeanDefinitions by type, no instantiation . Makes judgment cheap, but also means it can't see "runtime dynamically registered" beans.
5.5 An Underestimated Debug Entry
All condition evaluation results recorded in ConditionEvaluationReport. Start with --debug to see full report:
java -jar app.jar --debug
Output two parts:
Positive matches:
RateLimitAutoConfiguration matched
- @ConditionalOnClass found
Negative matches:
RedisAutoConfiguration did not match
- @ConditionalOnClass did not find
required class RedisOperations
Unconditional classes:
...
Exclusions:
... (excluded ones)"My Starter doesn't work" — 80% of answers are in Negative matches — it tells exactly which condition failed. Habit: check this report first, then code. If actuator present, /actuator/conditions endpoint gives same report at runtime without restart.
Complete Practice: Hand-Writing a Rate-Limiter Starter
Rate-limiting chosen because it has four traits: real business value, depends on AOP (demonstrates optional dependency), needs config (demonstrates property binding), needs switch (demonstrates conditional assembly) . After writing, minor tweaks turn it into your own circuit-breaker, idempotency, audit Starters.
6.1 Goal and Directory Structure
Goal: annotate a method to rate-limit, params globally configurable or per-method override, disable config item to turn off entirely.
ratelimit-spring-boot-starter/
pom.xml
src/main/java/com/example/ratelimit/
RateLimit.java
RateLimitProperties.java
RateLimiter.java
RateLimitAspect.java
RateLimitAutoConfiguration.java
src/main/resources/META-INF/spring/
org.springframework.boot.autoconfigure
.AutoConfiguration.imports
Package: com.example.ratelimit
Note: MUST be OUTSIDE business project's component-scan package6.2 POM: Three Dependencies, Two Optional
Dependency 1: required
org.springframework.boot:
spring-boot-autoconfigure
Dependency 2: compile-time metadata generation
org.springframework.boot:
spring-boot-configuration-processor
optional = true
Dependency 3: AOP (user may already have)
org.springframework.boot:
spring-boot-starter-aop
optional = true
Packaging advice:
Don't use spring-boot-maven-plugin's repackage goal
Starter is a dependency for others, not an executable JARLast point is a rookie mistake: repackage produces JAR with BOOT-INF structure; other projects depending on it can't load classes, get "symbol not found" or ClassNotFound.
6.3 First Piece: Configuration Properties Class
@ConfigurationProperties(prefix = "demo.ratelimit")
public class RateLimitProperties {
/** Master switch, default on */
private boolean enabled = true;
/** Default permits, default 100 */
private int defaultPermits = 100;
/** Default window, default 1 second */
private Duration defaultWindow = Duration.ofSeconds(1);
// getters/setters omitted
}All three fields have defaults → user writes zero config and it works . Field Javadoc extracted by configuration-processor into YAML hover hints — not optional comments, part of delivery quality.
6.4 Second Piece: Annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
/** Permits, -1 uses global default */
int permits() default -1;
/** Window, empty uses global default */
String window() default "";
/** Limit key, empty uses method full name */
String key() default "";
}Using -1 and empty string for "use default" instead of Integer null — annotation attributes don't support null, Java syntax limitation, common trade-off when designing annotations.
6.5 Third Piece: Token Bucket Core
public class RateLimiter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
public boolean tryAcquire(String key, int permits, Duration window) {
Bucket b = buckets.computeIfAbsent(key, k -> new Bucket(permits, window));
return b.tryAcquire();
}
}Bucket implements "time-proportional token refill", logic short:
static class Bucket {
final int permits;
final long windowMillis;
long lastRefill;
double tokens;
Bucket(int permits, Duration window) {
this.permits = permits;
this.windowMillis = window.toMillis();
this.lastRefill = System.currentTimeMillis();
this.tokens = permits;
}
synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
long elapsed = now - lastRefill;
if (elapsed > windowMillis) {
tokens = permits; // full window reset
} else {
tokens = Math.min(permits,
tokens + (double) permits * elapsed / windowMillis);
}
lastRefill = now;
if (tokens >= 1) {
tokens -= 1;
return true;
}
return false;
}
}Boundary note: This is single-node in-memory rate-limiting , each instance independent. For global quota need Redis + Lua — and swapping RateLimiter implementation is exactly @ConditionalOnMissingBean 's value.
6.6 Fourth Piece: AOP Aspect
@Aspect
public class RateLimitAspect {
private final RateLimiter limiter;
private final RateLimitProperties props;
public RateLimitAspect(RateLimiter limiter, RateLimitProperties props) {
this.limiter = limiter;
this.props = props;
}
@Around("@annotation(rl)")
public Object around(ProceedingJoinPoint pjp, RateLimit rl) throws Throwable {
String key = keyOf(pjp, rl);
int permits = rl.permits() > 0 ? rl.permits() : props.getDefaultPermits();
Duration window = windowOf(rl);
if (!limiter.tryAcquire(key, permits, window)) {
throw new IllegalStateException("too many requests: " + key);
}
return pjp.proceed();
}
}Notice this class has no @Component . It's created via @Bean in auto-configuration — reason from chapter 2: Starter package usually outside business scan scope. Also avoids a pitfall: @Component class inside scan range would duplicate-register with @Bean declaration.
6.7 Fifth Piece: Auto-Configuration Class (Core of the Show)
@AutoConfiguration
@ConditionalOnClass({RateLimiter.class, Aspect.class})
@EnableConfigurationProperties(RateLimitProperties.class)
public class RateLimitAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public RateLimiter rateLimiter() {
return new RateLimiter();
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(
prefix = "demo.ratelimit",
name = "enabled",
havingValue = "true",
matchIfMissing = true)
public RateLimitAspect rateLimitAspect(RateLimiter limiter, RateLimitProperties props) {
return new RateLimitAspect(limiter, props);
}
}Five annotations, each with explicit purpose: @AutoConfiguration: declares auto-config + disables CGLIB proxy. @Configuration works but loses after/before ordering. @ConditionalOnClass: checks two "optional dependencies" together. If AOP not on classpath, whole config class skipped — user doesn't need to manually exclude. @EnableConfigurationProperties: registers properties class and completes binding. Without it, injecting Properties fails. @ConditionalOnMissingBean: on both methods. User can override limiter implementation (swap to Redis) or aspect (add logging, change exception). @ConditionalOnProperty: matchIfMissing=true means "on by default if not configured". This is Starter default semantics — added dependency means you want to use it, not that you must toggle it on.
6.8 Sixth Piece: Registration File
File:
META-INF/spring/
org.springframework.boot.autoconfigure
.AutoConfiguration.imports
Content (one line, no comments):
com.example.ratelimit.RateLimitAutoConfigurationNote: displayed with line break here, actual file must be a single line . Filename also one full line, no breaks. Easiest mistake: dots in filename are part of filename, not directory separators, so META-INF/spring/ contains exactly one file, no subdirectories.
6.9 Consumer: Three Lines of Config
<dependency>
<groupId>com.example</groupId>
<artifactId>ratelimit-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency> # application.yml
demo:
ratelimit:
enabled: true
default-permits: 100
default-window: 1s @RestController
public class OrderController {
@RateLimit(permits = 10, window = "1s")
@PostMapping("/orders")
public String create() {
return "ok";
}
}Three changes: add dependency, write five lines YAML (or none), add one annotation. No @EnableXxx , no @Import , no configuration class — that's the experience a Starter should deliver.
Verification: Use @SpringBootTest assert RateLimiter injectable (proves assembly works); then in test config declare own same-type bean, assert injection gets yours (proves step-aside works). Starter tests focus on assembly behavior, not business logic — business logic covered by unit tests, assembly behavior only verifiable via container tests.
Seven Pitfalls and Debugging Techniques
These are issues I've hit in real projects and seen most in code reviews. Common trait: no error, but behavior wrong — so only avoidable by knowing beforehand.
Pitfall 1: @ConditionalOnMissingBean on Class
// Wrong: entire class steps aside
@AutoConfiguration
@ConditionalOnMissingBean(Xxx.class)
public class XxxAutoConfiguration {
@Bean Xxx a() { ... }
@Bean Yyy b() { ... }
// User defines Xxx
// b() also disappears
}Class-level condition applies to whole configuration class; if unsatisfied, all @Bean inside skipped. Correct: put on each method individually .
Pitfall 2: Auto-Configuration Class Picked Up by Component Scan
If Starter package shares root with business startup class, auto-configuration class gets scanned by @ComponentScan and registered early , bypassing deferred import's order guarantee — step-aside fails, duplicate beans appear. Convention: Starter package always outside business package, e.g., com.yourcompany.middleware.xxx, separate artifact. No technical enforcement, but prevents this class of issues at root.
Pitfall 3: Optional Type in Method Signature
Consequence: parsing method signature loads parameter type , optional dependency absent → NoClassDefFoundError at startup, stack trace points to framework internals. Fix: move into static inner class.
Pitfall 4: Only Changed Code, Forgot Registration File
Most common during upgrade: code and deps updated, only imports file missing. Symptom: startup normal, logs clean, just doesn't work .
Pitfall 5: @Bean Methods Calling Each Other
// Dangerous when proxyBeanMethods=false
@Bean
public A a() { return new A(); }
@Bean
public B b() {
return new B(a()); // direct call
}With proxyBeanMethods=false, a() is plain call, new object every time , container singleton unused. Change to b(A a) parameter injection.
Pitfall 6: @ConditionalOnMissingBean on Class (Repeated)
Same as Pitfall 1 — emphasized again because extremely common.
Pitfall 7: havingValue Semantics Reversed
// Semantics: activates ONLY when config value EQUALS havingValue
// Want "activate when off"
@ConditionalOnProperty(
name = "demo.mock.enabled",
havingValue = "false")
// Note: NOT writing !enabled
// Want "activate when not configured"
matchIfMissing = trueThis annotation has no "negation" capability, only "equals value then activate". So "enable fallback when off" must be havingValue="false" — reading code it's easy to misinterpret as "enabled=false means don't activate", direction exactly opposite.
Pitfall 8: Using repackage for Packaging
repackagerearranges JAR into executable structure (classes into BOOT-INF/classes). Starter is a library to be depended on, must remain a plain JAR — so never configure that goal.
Universal criterion: Runnable via java -jar = application; only usable as dependency = library. Starter belongs to latter, never mix the two.
7.1 Three Debugging Techniques
① --debug check condition report : Positive/Negative matches directly state "which condition missing" — first choice for troubleshooting.
② Check JAR for that file : unzip -l glance, faster than reading code ten times. One char off in filename ruins everything.
③ Breakpoint in getAutoConfigurationEntry : inspect candidates list for your class name — quickly distinguishes "not read" vs "read but filtered out".
If actuator present, fourth technique: /actuator/conditions endpoint, same as --debug report but viewable at runtime without restart.
Learning Path and Twelve Interview Questions
8.1 Four Stages
Stage 1: Can Read — understand existing Starter skeleton, explain each annotation's role. Graduation: draw MyBatis Starter assembly chain.
Stage 2: Can Write — produce complete Starter per chapter 6, consumable by another project. Graduation: --debug shows yours in Positive matches.
Stage 3: Can Design — design "default works out-of-box, everything overridable" extension points. Graduation: docs "how to override default" under three paragraphs.
Stage 4: Can Govern — multi-team version compatibility and deprecation strategy. Graduation: major version upgrade, consumers zero changes.
8.2 Five Steps to Read Source
01 ImportCandidates.load
How files are read
02 AutoConfigurationImportSelector
.getAutoConfigurationEntry
03 AutoConfigurationImportFilter
.match How batch filtering works
04 AutoConfigurationSorter
.getInPriorityOrder
05 ConditionEvaluator.shouldSkip
How individual condition judged8.3 Twelve Interview Questions
Starter principle? Packaging convention + auto-configuration: imports file read by ImportSelector, config class enters container, conditional annotations control assembly.
Why third-party not called spring-boot-starter-xxx? That's official namespace; using it clashes with future official modules.
3.x registration vs 2.x difference? Pre-2.6 used spring.factories; 3.0 only recognizes META-INF/spring/...imports.
@AutoConfiguration vs @Configuration? Former adds after/before ordering, forces proxyBeanMethods=false.
OnClass referenced class missing why no error? ASM reads bytecode for class name string, checks existence via Class.forName, doesn't load the class.
OnMissingBean why reliable? Auto-configuration deferred to last, user BeanDefinitions already registered.
OnBean why unreliable? Auto-configuration order doesn't guarantee dependency order; may judge before other registers.
Two phases of condition evaluation? PARSE_CONFIGURATION (no BeanFactory) and REGISTER_BEAN (has BeanFactory).
Why auto-configuration class can't have @Component? Would be component-scanned early, bypass deferred import order guarantee.
How to declare Starter dependencies? Depend on autoconfigure not starter; optional deps marked optional.
Where does YAML IDE hint come from? configuration-processor generates metadata.json at compile time from Javadoc.
Starter not working how to troubleshoot? --debug check Negative matches → verify JAR has imports → breakpoint candidate list.
One-Page Cheat Sheet
[Essence] Packaging convention + auto-configuration
Not technology, convention
[Four pieces] Config class / Conditions / Properties / Registration
[Registration] META-INF/spring/
xxx.AutoConfiguration.imports
[3.0 Breaking] spring.factories EnableAutoConfiguration removed
[Annotation] @AutoConfiguration
Forces proxyBeanMethods=false
[Three-layer conditions] OnProperty switch
OnClass dependency
OnMissingBean step-aside
[OnClass Safety] ASM reads metadata
Don't write in method signature
[Step-aside Reliable] Deferred to last
Sees user beans
[OnBean Unreliable] Needs after = Xxx.class
[Two Phases] PARSE no BeanFactory
REGISTER has
[Dependencies] autoconfigure not starter
Optional deps marked optional
[Packaging] No repackage, library not app
[Debug] --debug check Positive/NegativeWriting a Starter isn't technically high-bar — four pieces plus five conventions, one afternoon to first working one. Real differentiator: "After another project adds your Starter, they barely feel its existence" : dependency tree unchanged, startup not slower, clear logs when issues, explicit override points when changes needed.
So if you take only one sentence, let it be this: Starter's design goal isn't "powerful features", but "sensible defaults, replaceable". Use @ConditionalOnMissingBean right, mark optional right, leave the switch well — rest is details.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
