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.

Architecture Pony
Architecture Pony
Architecture Pony
Building a Spring Boot Starter from Scratch: Auto-Configuration, Conditional Assembly & Rate-Limiter Example

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.imports

is 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.RateLimitAutoConfiguration

Second 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=true

is 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 injection

3.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 register

Without 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

@ConditionalOnMissingBean

reliable, 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 vanishes

Fix isn't avoiding it, but explicitly declaring order :

@AutoConfiguration(after = BAutoConfiguration.class)
@ConditionalOnBean(BService.class)
public class AAutoConfiguration { ... }
after

only 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 lines

4.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

AutoConfigurationSorter

sorts 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

getMatchOutcome

core 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 package

6.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 JAR

Last 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.RateLimitAutoConfiguration

Note: 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 = true

This 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

repackage

rearranges 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 judged

8.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/Negative

Writing 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaAOPSpring BootRate LimitingAuto-configurationStarterSpring Boot 3.xConditional Assembly
Architecture Pony
Written by

Architecture Pony

Learning notes

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.