Skip to main content

Write Your Own Spring Boot 4 Starter: Auto-Configuration, @Conditional and Properties

Build a real Spring Boot 4.1 starter from an empty directory: the two-jar layout, AutoConfiguration.imports, @ConditionalOnMissingBean back-off, configuration metadata, an optional Jackson 3 integration, and the ApplicationContextRunner test that passes while the starter is broken. Every claim comes from a committed transcript.

The same forty lines of @Configuration end up in a second service, then a third. A shared property prefix, a bean with a sensible default, an optional integration that only makes sense when some library is present. Copying them works until one copy drifts, and then nobody can say which service has the right one. Spring Boot’s own answer to this is a starter: add one dependency, and the beans appear, configured from properties, out of the way if you define your own. This article builds a real one, from an empty directory to a tested pair of jars, and every behaviour it describes was run. The starter provides a single Masker that hides all but the last few characters of a value, plus an optional Jackson integration, which is a small enough job that the packaging stays in view. Along the way it reproduces the mistakes that cost the most time: an auto-configuration missing from the imports file that passes its own tests, a @ConditionalOnBean that is false because of a class name, a condition placed on a method that breaks an application with no Jackson in it, and an annotation-processor version that Maven cannot find. Everything lives in the custom-starter module of a companion repository. There is no separate documentation folder: the deeper material sits in the collapsible “going deeper” sections beside the paragraph each one extends, and every console block is quoted from a transcript that a test or a script wrote.
Versions. Spring Boot 4.1.1 and Spring Framework 7.0.9 (both poms were published to Maven Central on 20 August 2026), on Java 25 (LTS, Temurin 25.0.4.1) and Maven 3.9. Boot 4 split the old spring-boot-autoconfigure jar into per-technology modules, which changes what a starter imports; the section on Boot 4 has the measured details.

A starter is two jars and a text file

Start with the mechanism, because everything else hangs off it. @SpringBootApplication includes @EnableAutoConfiguration. At start-up that annotation reads a list of class names from every jar on the classpath, looks at the conditions on each listed class, and applies the ones that hold. That list is a plain text file with a fixed name and location. A “starter” is only a convention for shipping such a file: one jar carries the classes and the file, and a second, empty jar exists so that an application can depend on one coordinate and receive the first jar plus whatever else it needs.
your application one dependency in its pom masker-spring-boot-starter no code, only a pom masker-spring-boot-autoconfigure classes + the imports file Spring Boot at start-up reads AutoConfiguration.imports Solid arrows are Maven dependencies. The dashed arrow is Boot reading a text file from a jar, not a dependency.
The picture is the whole architecture. The two-jar split is optional, and Boot’s reference allows a single combined module named after the starter; the split earns its keep when the starter should decide which libraries come along while a separate module decides what happens. The text file is the part that is easy to get wrong, and it is one line per class (AutoConfiguration.imports):
com.ankurm.masker.MaskerAutoConfiguration
com.ankurm.masker.MaskerJacksonAutoConfiguration
The starter jar is the other extreme. Its pom lists two dependencies and it contains no classes at all (pom.xml):
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
  </dependency>
  <dependency>
    <groupId>com.ankurm.masker</groupId>
    <artifactId>masker-spring-boot-autoconfigure</artifactId>
    <version>${project.version}</version>
  </dependency>
</dependencies>
Building both and listing them shows the difference (from 12-jar-contents-and-dependency-trees.txt):
--- masker-spring-boot-starter-1.0.0.jar ---
META-INF/MANIFEST.MF
META-INF/maven/com.ankurm.masker/masker-spring-boot-starter/pom.xml
META-INF/maven/com.ankurm.masker/masker-spring-boot-starter/pom.properties

--- masker-spring-boot-autoconfigure-1.0.0.jar (META-INF and the classes) ---
META-INF/spring-autoconfigure-metadata.properties
META-INF/additional-spring-configuration-metadata.json
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
META-INF/spring-configuration-metadata.json
com/ankurm/masker/MaskerJacksonAutoConfiguration.class
com/ankurm/masker/Sensitive.class
com/ankurm/masker/Masker.class
com/ankurm/masker/MaskerAutoConfiguration.class
com/ankurm/masker/TailMasker.class
com/ankurm/masker/MaskerJacksonModule.class
com/ankurm/masker/MaskerProperties.class
com/ankurm/masker/MaskerJacksonModule$1.class
Two rules from Boot’s reference are worth following from the first commit, because both are painful to change later. Name the modules after your own project (masker-spring-boot-starter here), and do not start a module name with spring-boot, even under a different group id; the reference says Boot may offer official support for the thing you configure later. And put every configuration key under a prefix you own. Here that is masker, and the reference specifically asks starters to stay out of server, management, spring and the other namespaces Boot uses.
Going deeper: what the starter pulls in, and what it deliberately does not

The starter depends on spring-boot-starter and on the autoconfigure module. The autoconfigure module lists spring-boot-starter-jackson as an optional dependency. In Maven that means the module compiles against Jackson and its tests can use it, while consumers of the module do not receive it. Both trees are in 12-jar-contents-and-dependency-trees.txt; the part that matters is one line of each (the starter tree has no Jackson anywhere, and this is the line in the autoconfigure tree that says why):

\- org.springframework.boot:spring-boot-starter-jackson:jar:4.1.1:compile (optional)

An application that wants the Jackson integration therefore brings Jackson itself, which it almost certainly already does. If the starter dragged Jackson in for everyone, an application that has no use for it would get a JSON library it never asked for, and one that pins a different version would have to fight for it. Optional dependencies are what make the conditions in the fifth section necessary rather than decorative.

The reference page for all of this is Creating Your Own Auto-configuration.

Going deeper

The smallest thing that works: one class, one record, one line in a text file

The auto-configuration is an ordinary configuration class with one @Bean method (MaskerAutoConfiguration.java):
@AutoConfiguration
@ConditionalOnProperty(prefix = "masker", name = "enabled", matchIfMissing = true)
@EnableConfigurationProperties(MaskerProperties.class)
public class MaskerAutoConfiguration {

    /** Backs off when the application defines its own {@link Masker}. */
    @Bean
    @ConditionalOnMissingBean
    public Masker masker(MaskerProperties properties) {
        return new TailMasker(properties.replacement(), properties.visibleTail());
    }
}
It reads its settings from a record (MaskerProperties.java). A record fits configuration well: the fields are final, the constructor is the only way in, and @DefaultValue puts each default next to the field it belongs to.
/**
 * Settings for the auto-configured {@link Masker}.
 *
 * @param enabled     whether the starter configures anything at all
 * @param replacement the text that stands in for each hidden character
 * @param visibleTail how many characters at the end of the value stay readable
 */
@ConfigurationProperties("masker")
public record MaskerProperties(
        @DefaultValue("true") boolean enabled,
        @DefaultValue("*") String replacement,
        @DefaultValue("4") int visibleTail) {
}
Now the consumer. The demo application (DemoApplication.java) is an ordinary Boot application whose pom has the starter dependency and Jackson, and nothing in its source mentions the masker:
@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}
It has one type that carries a card number (Customer.java), and a test that starts the application with SpringApplication and serialises a Customer (DemoApplicationTests.java). The card is written masked and no line of the demo asked for that (10-demo-app-with-only-the-starter.txt):
Masker beans           : [masker]
MaskerProperties beans : [masker-com.ankurm.masker.MaskerProperties]
Jackson module beans   : [maskerJacksonModule]
JSON of a Customer     : {"name":"Asha","card":"************1111"}
Going deeper: what @AutoConfiguration is, and what it is not

@AutoConfiguration is a specialised @Configuration. Read from the 4.1.1 class file with javap (14-boot4-autoconfiguration-locations.txt), it is meta-annotated with @Configuration(proxyBeanMethods = false) and carries the ordering attributes before, after and their ...Name variants, which it forwards to @AutoConfigureBefore and @AutoConfigureAfter:

org.springframework.context.annotation.Configuration(
proxyBeanMethods=false
org.springframework.boot.autoconfigure.AutoConfigureBefore
org.springframework.boot.autoconfigure.AutoConfigureAfter

What it does not do is register anything. Putting the annotation on a class does not make Boot load it; the imports file does, and the seventh section reproduces what happens when the two disagree. The proxyBeanMethods = false part means the class is not proxied by CGLIB, so one @Bean method calling another in the same class would create a second instance instead of returning the shared one. Have beans take each other as method parameters, as masker(MaskerProperties) does.

Going deeper

Backing off: how a starter stays out of the way

A starter that cannot be overridden is a starter people work around. There are two ways an application should be able to take control: replace a bean, or turn the whole thing off. Both are conditions. On masker(...) the condition is @ConditionalOnMissingBean, which means “create this only if nobody has defined a Masker”. On the class it is @ConditionalOnProperty(prefix = "masker", name = "enabled", matchIfMissing = true), which means “on, unless the property says otherwise”.
1 your own beans defined first 2 imports files read every jar, then sorted 3 conditions checked against what exists so far 4 matching classes their beans are defined A condition can only see what steps 1 to 3 have already defined. That is why @ConditionalOnMissingBean works for the application’s beans (they were defined first) and why the order among auto-configurations matters. Beans are instantiated only after all of this, in dependency order. Definition order and creation order are different things.
The diagram is the reason both conditions behave. An application’s own beans are defined first, so when the starter’s condition runs, a user-defined Masker is already there and the starter backs off. The test that shows it uses ApplicationContextRunner, which the seventh section covers properly; here it is enough to know it builds a context from the classes it is given (MaskerAutoConfigurationTests.java):
private final ApplicationContextRunner runner = new ApplicationContextRunner()
        .withConfiguration(AutoConfigurations.of(MaskerAutoConfiguration.class));

/** A user configuration. Deliberately not annotated: the runner registers whatever class it is given. */
static class CustomMasker {
    @Bean
    Masker customMasker() {
        return value -> "<redacted>";
    }
}
With that user bean present, the auto-configuration adds nothing (03-conditional-on-missing-bean-and-registration-order.txt):
--- user configuration + auto-configuration (the way Boot loads them) ---
Masker beans           : [customMasker]
getBean(Masker.class)  : mask -> <redacted>
The off switch works the same way from the application. Starting the demo with --masker.enabled=false leaves no Masker and no Jackson module, and Boot’s own record of the decision says why (from 11-demo-app-properties-and-off-switch.txt):
--- --masker.enabled=false ---
Masker beans           : []
Jackson module beans   : []

--- what Boot recorded ---
com.ankurm.masker.MaskerAutoConfiguration  [not matched]
    did not match: @ConditionalOnProperty (masker.enabled) found different value in property 'enabled'
com.ankurm.masker.MaskerJacksonAutoConfiguration  [not matched]
    matched: @ConditionalOnClass found required class 'tools.jackson.databind.json.JsonMapper'
    did not match: @ConditionalOnBean (types: com.ankurm.masker.Masker; SearchStrategy: all) did not find any beans of type com.ankurm.masker.Masker
That block is the same report that --debug prints at start-up, filtered here to the starter’s classes. It is the first place to look when a bean you expected is missing, and the reason to write conditions that produce readable outcomes: the last line says the Jackson integration was skipped because there is no Masker, which follows directly from the switch above it.
A property condition without matchIfMissing is off until somebody sets it. @ConditionalOnProperty on its own matches only when the property exists (and is not false). A starter written that way does nothing after the dependency is added, and the person who added it sees no error, only a missing bean. Boot’s report says it plainly, 16-conditional-on-property-without-match-if-missing.txt:
Marker beans : 0
com.ankurm.traps.DefaultOffAutoConfiguration  [not matched]
    did not match: @ConditionalOnProperty (traps.enabled) did not find property 'enabled'
Decide deliberately which way your default should point. For a starter whose whole purpose is to configure something, matchIfMissing = true is usually right. For one that costs something (a connection, a background thread), off by default is defensible, and the README should say so on the first line.
Going deeper: the same two classes, registered in the wrong order

Back-off depends on ordering, and the transcript above has two more sections that show what happens without it (03-conditional-on-missing-bean-and-registration-order.txt). Registering the starter’s class as ordinary configuration before the user’s makes both beans exist:

--- the same two classes registered as ordinary configuration, starter first ---
Masker beans           : [masker, customMasker]
getBean(Masker.class)  : NoUniqueBeanDefinitionException

--- the same two classes, user configuration first ---
Masker beans           : [customMasker]

Two Masker beans, and the first injection point that asks for one by type fails with NoUniqueBeanDefinitionException. Nobody chose that order: it is what happens when an auto-configuration class is reachable by component scanning, or when a library is imported with @Import ahead of the application’s own configuration. Boot’s reference states the consequence directly: these two conditions are evaluated against what has been defined so far, and it recommends using @ConditionalOnBean and @ConditionalOnMissingBean only on auto-configuration classes, which are guaranteed to be processed after the user’s own definitions.

Going deeper

Properties, and the metadata that makes an editor understand them

Binding a properties record works without any further help, and an IDE knows nothing about it. Completion, the description tooltip and the default value all come from a JSON file that ships in the jar, and Boot has an annotation processor that writes it from the record. It reads the Javadoc, including the @param tags on a record, and the @DefaultValues. The processor is added to the compiler plugin (pom.xml):
<annotationProcessorPaths>
  <path>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
  </path>
  <path>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-autoconfigure-processor</artifactId>
  </path>
</annotationProcessorPaths>
The configuration-processor path is the metadata described above. Its neighbour, autoconfigure-processor, writes a second file the reference describes as a start-up optimisation: it records the class, bean and ordering conditions of each auto-configuration so Boot can rule some out without loading them. Both files are in the built jar. This is the entry the first one wrote for one property (from 13-configuration-metadata-in-the-jar.txt):
    {
      "name": "masker.visible-tail",
      "type": "java.lang.Integer",
      "description": "how many characters at the end of the value stay readable",
      "sourceType": "com.ankurm.masker.MaskerProperties",
      "defaultValue": 4
    }
The description and the default come straight from the record: the description is the @param visibleTail line, and the 4 is the @DefaultValue. A property that should offer a list of values needs a hand-written file, because the processor cannot know them. Put it next to the generated one and the two are merged (additional-spring-configuration-metadata.json):
{
  "hints": [
    {
      "name": "masker.replacement",
      "values": [
        { "value": "*", "description": "Asterisk (the default)." },
        { "value": "#", "description": "Hash sign." },
        { "value": "•", "description": "Bullet." }
      ]
    }
  ]
}
The second processor’s file records what it can read from annotations. Note what it lists for each class, and what it leaves out (from 13-configuration-metadata-in-the-jar.txt):
com.ankurm.masker.MaskerAutoConfiguration=
com.ankurm.masker.MaskerJacksonAutoConfiguration=
com.ankurm.masker.MaskerJacksonAutoConfiguration.AutoConfigureAfter=com.ankurm.masker.MaskerAutoConfiguration
com.ankurm.masker.MaskerJacksonAutoConfiguration.ConditionalOnBean=com.ankurm.masker.Masker
com.ankurm.masker.MaskerJacksonAutoConfiguration.ConditionalOnClass=tools.jackson.databind.json.JsonMapper
MaskerJacksonAutoConfiguration has its class, bean and ordering conditions recorded. MaskerAutoConfiguration has an empty entry: its @ConditionalOnProperty is not among the conditions this file holds, so Boot still loads and evaluates it in the normal way. That is a behaviour of the processor, not a bug, and it is a reminder that this file is an optimisation over the conditions and not a second copy of them.
The version in annotationProcessorPaths is the one trap in this section. Maven needs a version for the processor. With the Spring Boot starter parent as your parent, leaving it out works: the build above resolved the processor at 4.1.1 from Boot’s dependency management. The same pom fails the moment you write ${project.parent.version}, the natural thing to type, if your module’s parent is your own multi-module parent and not Boot’s. Boot is then the grandparent, project.parent.version is the version of your parent, and Maven looks for a processor that does not exist. Reproduced by rewriting the pom in a scratch copy (15-annotation-processor-version-trap.txt):
Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project masker-spring-boot-autoconfigure: Resolution of annotationProcessorPath dependencies failed: The following artifacts could not be resolved: org.springframework.boot:spring-boot-configuration-processor:jar:1.0.0 (absent): Could not find artifact org.springframework.boot:spring-boot-configuration-processor:jar:1.0.0 in central (https://repo.maven.apache.org/maven2)
Leave the version out and let the Boot parent manage it, or use a property that is defined to the Boot version.
Going deeper: what else the metadata can carry

The metadata format also supports deprecations with a replacement (a renamed property keeps working and your users see a warning), value providers that let an IDE complete a class name or a Spring bean name, and groups for nested properties. None of them is needed to get a working starter, and each is documented in one place, the configuration metadata specification. I did not exercise them here, so nothing in this article claims how an IDE presents them.

Going deeper

Optional integrations: @ConditionalOnClass, and where it is safe to put it

The Jackson integration should exist only when the application has Jackson. That is a class condition, and it is the reason the dependency is optional. It lives in its own auto-configuration, and the condition names a Jackson class in an annotation (MaskerJacksonAutoConfiguration.java):
@AutoConfiguration(after = MaskerAutoConfiguration.class)
@ConditionalOnClass(JsonMapper.class)
@ConditionalOnBean(Masker.class)
public class MaskerJacksonAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public MaskerJacksonModule maskerJacksonModule(Masker masker) {
        return new MaskerJacksonModule(masker);
    }
}
Naming a class in an annotation is safe because Spring reads annotations from the class file with a bytecode reader rather than loading the class, so @ConditionalOnClass(JsonMapper.class) can be evaluated on a classpath where JsonMapper does not exist. The class-level condition, the bean condition and the back-off on the bean method all report on the same page (from 04-jackson-integration-active.txt):
com.ankurm.masker.MaskerJacksonAutoConfiguration  [matched]
    matched: @ConditionalOnClass found required class 'tools.jackson.databind.json.JsonMapper'
    matched: @ConditionalOnBean (types: com.ankurm.masker.Masker; SearchStrategy: all) found bean 'masker'
com.ankurm.masker.MaskerJacksonAutoConfiguration#maskerJacksonModule  [matched]
    matched: @ConditionalOnMissingBean (types: com.ankurm.masker.MaskerJacksonModule; SearchStrategy: all) did not find any beans
Jackson on the classpath @ConditionalOnClass JsonMapper found @ConditionalOnBean Masker found module registered Sensitive written masked No Jackson on the classpath @ConditionalOnClass JsonMapper not found skipped not evaluated further application starts Masker bean still present The starter degrades to a smaller starter; it does not fail. Transcripts 04 and 05 are these two lanes.
The second lane is what a consumer without Jackson gets. The test hides Jackson with FilteredClassLoader and asserts that the context started, that the Masker exists and that the module does not (JacksonIntegrationTests.java, 05-jackson-integration-absent-without-error.txt):
context failed     : false
Masker beans       : [masker]
module beans       : []

--- what Boot recorded for the Jackson auto-configuration ---
com.ankurm.masker.MaskerJacksonAutoConfiguration  [not matched]
    did not match: @ConditionalOnClass did not find required class 'tools.jackson.databind.json.JsonMapper'
There is one placement rule that this diagram hides. The condition is on the class, and the class must therefore not mention Jackson anywhere else that the JVM would have to resolve when it loads it. Put the condition on a @Bean method instead, with a Jackson type in the return type, and the annotation is never reached, because the JVM has to load the class and read its methods first. I wrote that variant on purpose (MethodLevelConditionAutoConfiguration.java):
@AutoConfiguration
public class MethodLevelConditionAutoConfiguration {

    @Bean
    @ConditionalOnClass(JsonMapper.class)
    MaskerJacksonModule methodLevelModule(Masker masker) {
        return new MaskerJacksonModule(masker);
    }
}
and the repair the Boot reference recommends, the condition moved to a nested class (NestedConditionAutoConfiguration.java):
@AutoConfiguration
public class NestedConditionAutoConfiguration {

    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(JsonMapper.class)
    static class JacksonConfiguration {

        @Bean
        MaskerJacksonModule nestedModule(Masker masker) {
            return new MaskerJacksonModule(masker);
        }
    }
}
Both were started on a classpath with every Jackson jar removed, and on one with Jackson (MethodLevelMain.java, 07-conditional-on-class-on-a-bean-method.txt):
--- condition on the @Bean method, classpath without any Jackson jar ---
context failed
exception chain: BeanDefinitionStoreException -> IllegalStateException -> NoClassDefFoundError -> ClassNotFoundException
root cause     : java.lang.ClassNotFoundException: tools.jackson.databind.module.SimpleModule

--- condition on a nested class, classpath without any Jackson jar ---
context started; nestedModule bean present: false

--- condition on the @Bean method, classpath with Jackson ---
context started; methodLevelModule bean present: true
This one cannot be caught by the obvious test. My first version of the check was an ApplicationContextRunner test with FilteredClassLoader("tools.jackson"), and it passed: the context started. The same class fails on a real classpath without Jackson, as above. My reading is that the class under test had already been loaded by the test’s own class loader, which can see Jackson, so the filter never got a chance to matter. I did not investigate further, and the takeaway does not depend on the cause: a green FilteredClassLoader test proves the condition works, not that the class loads without the library. If the starter has an optional dependency, run it once on a classpath that really lacks it. The demo application in this repository does not do that, because it brings Jackson itself.
Going deeper: how the module reaches the ObjectMapper, and Jackson 3

Boot registers any bean of Jackson’s module type with the application’s mapper, so the auto-configuration only has to publish one, and the demo’s output above proves that the mapper picked it up. The Jackson in Boot 4 is Jackson 3, whose packages start with tools.jackson instead of com.fasterxml.jackson; the module and serializer are MaskerJacksonModule.java. A starter written against Jackson 2 imports something else, which is a source change and not a configuration one.

The module is registered under @ConditionalOnMissingBean too, so an application that wants a different serializer can define its own module bean and the starter steps aside in the same way it does for Masker.

Going deeper

Order matters: a @ConditionalOnBean that is false because of a class name

A bean condition on somebody else’s bean depends on when the two auto-configurations run. Boot sorts the listed classes alphabetically by class name first, and then applies the ordering the classes declare with before and after. Two auto-configurations with the same condition, @ConditionalOnBean(JsonMapper.class), differ in one attribute (EarlyModuleAutoConfiguration.java and OrderedModuleAutoConfiguration.java):
@AutoConfiguration
@ConditionalOnBean(JsonMapper.class)
public class EarlyModuleAutoConfiguration {

    @Bean
    Marker earlyMarker() {
        return new Marker("EarlyModuleAutoConfiguration");
    }
}
@AutoConfiguration(afterName = "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration")
@ConditionalOnBean(JsonMapper.class)
public class OrderedModuleAutoConfiguration {

    @Bean
    Marker orderedMarker() {
        return new Marker("OrderedModuleAutoConfiguration");
    }
}
afterName takes the class name as a string, so this class does not need Boot’s Jackson module on its own compile classpath, which is what an optional integration wants. Both were run with the starter’s own configuration and Boot’s JacksonAutoConfiguration, which creates the JsonMapper (ConditionTrapTests.java). Here is the order Boot evaluated them in (from 06-conditional-on-bean-needs-an-ordering.txt):
com.ankurm.masker.MaskerAutoConfiguration
com.ankurm.traps.EarlyModuleAutoConfiguration
org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration
com.ankurm.traps.OrderedModuleAutoConfiguration
1 MaskerAuto- Configuration 2 EarlyModule- AutoConfiguration 3 JacksonAuto- Configuration 4 OrderedModule- AutoConfiguration no JsonMapper yet: not matched defines the JsonMapper found bean: matched com.ankurm… sorts before org.springframework…, so EarlyModule is evaluated before Boot’s Jackson auto-configuration for no reason except its package name. afterName moved OrderedModule behind it.
The diagram is the transcript with the reason attached. com.ankurm.traps.EarlyModuleAutoConfiguration sorts before org.springframework.boot.jackson..., so it is evaluated when no JsonMapper is defined yet, and its condition is false. The recorded outcomes say so, and only the class with afterName produced a bean (from 06-conditional-on-bean-needs-an-ordering.txt):
Marker bean created by OrderedModuleAutoConfiguration

--- what Boot recorded ---
com.ankurm.traps.EarlyModuleAutoConfiguration  [not matched]
    did not match: @ConditionalOnBean (types: tools.jackson.databind.json.JsonMapper; SearchStrategy: all) did not find any beans of type tools.jackson.databind.json.JsonMapper
com.ankurm.traps.OrderedModuleAutoConfiguration  [matched]
    matched: @ConditionalOnBean (types: tools.jackson.databind.json.JsonMapper; SearchStrategy: all) found bean 'jacksonJsonMapper'
The failure is silent, and it depends on your package name. Nothing throws. The bean the class would have defined is simply missing, and the same code works or fails depending on how its package sorts against Boot’s. Rename com.ankurm to org.zzz and the broken class would sort after Boot’s and pass, which is how a starter can work in its author’s tests and fail for a consumer whose other auto-configurations shift the order. Any condition on a bean that another auto-configuration creates needs an explicit after or afterName. I did not run the renamed variant; the sentence follows from the sort order shown above.
Going deeper: the ordering attributes, and what ordering does not do

@AutoConfiguration has before, beforeName, after and afterName. There is also @AutoConfigureOrder, which behaves like @Order and is for classes that should be ordered without knowing about each other. The starter’s own Jackson class states after = MaskerAutoConfiguration.class. Alphabetically it would already come second, so the attribute does not change today’s behaviour; it records a dependency, so that renaming either class cannot silently break it. That is the same reasoning as the ordered class above, applied to your own code.

Ordering affects only the order in which bean definitions are added. It does not decide the order in which the beans are created; the reference says that is decided by each bean’s dependencies and any @DependsOn. So after fixes a condition that is looking at the wrong state, and does nothing for a bean that needs another one initialised first.

Going deeper

Testing with ApplicationContextRunner, and the test that passes while the starter is broken

ApplicationContextRunner builds a small context from exactly the classes you name, runs a lambda against it and closes it. Nothing starts a server and nothing scans, so a test takes milliseconds. The tests above all use one shared runner (JacksonIntegrationTests.java):
private final ApplicationContextRunner runner = new ApplicationContextRunner()
        .withInitializer(new ConditionEvaluationReportLoggingListener())
        .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class,
                MaskerAutoConfiguration.class, MaskerJacksonAutoConfiguration.class));
AutoConfigurations.of(...) is the important part. It registers the classes the way Boot would, sorted and ordered by their declared constraints, so the ordering in the previous section is exercised and not bypassed. withPropertyValues, withUserConfiguration and withClassLoader(new FilteredClassLoader(...)) cover the properties, the back-off and the missing library. The runner is where the transcripts for the properties, the off switch and the back-off came from (01-defaults-without-any-configuration.txt, 02-properties-and-the-off-switch.txt). It also has a blind spot, and it is the most useful thing to know about testing a starter. The runner is told which class to test. It cannot tell you whether Boot would ever find that class. I added an auto-configuration to the module that looks finished and left it out of the imports file (ForgottenAutoConfiguration.java):
@AutoConfiguration
public class ForgottenAutoConfiguration {

    @Bean
    String forgottenMarker() {
        return "created by ForgottenAutoConfiguration";
    }
}
and started three things against it: a runner that names the class, the starter author’s own application, which scans the package that holds the class (OwnersApplication.java), and a consumer application in its own package. The spring.factories file in the test resources also lists the class, which is how Boot 2.6 and earlier registered auto-configurations (spring.factories). The result is in 08-auto-configuration-missing-from-the-imports-file.txt:
--- ApplicationContextRunner: the test names the class itself ---
forgottenMarker bean   : true

--- the starter author's application: @SpringBootApplication scanning the package that holds both classes ---
forgottenMarker        (@AutoConfiguration) : false
forgottenPlainMarker   (@Configuration)     : true

--- a consumer application: its own package, the starter on the classpath ---
forgottenMarker        (@AutoConfiguration) : false
forgottenPlainMarker   (@Configuration)     : false
Masker beans                                : [masker]
A green runner test proves nothing about the imports file. The runner found the class because the test named it. Neither application found it: not the consumer, which is what you would expect, and not the author’s own application either, whose scan covers the package the class sits in. A plain @Configuration class in the same place was picked up by that scan, which is a worse outcome: it works for the author and disappears for everyone else. And the spring.factories entry did nothing. The imports file is the only registration Boot 4 honours for auto-configuration classes, in this run and per the reference, which says they must be loaded only by being named there.
The guard against all of this is a test that reads the imports file the way Boot does, using ImportCandidates, and asserts the list (ForgottenImportsEntryTests.java):
void aTestThatReadsTheImportsFileCatchesTheOmission() {
    try (Transcript t = new Transcript("09-reading-the-imports-file-in-a-test.txt",
            "ImportCandidates: what Boot itself reads from META-INF/spring/...AutoConfiguration.imports")) {
        var candidates = ImportCandidates.load(AutoConfiguration.class, getClass().getClassLoader()).getCandidates();
        var ours = candidates.stream().filter(c -> c.startsWith("com.ankurm.masker.")).toList();
        t.line("entries under com.ankurm.masker: %d", ours.size());
        ours.forEach(c -> t.line("  %s", c));
        t.line("ForgottenAutoConfiguration listed: %s", candidates.contains(ForgottenAutoConfiguration.class.getName()));
        assertThat(ours).containsExactlyInAnyOrder(MaskerAutoConfiguration.class.getName(),
                MaskerJacksonAutoConfiguration.class.getName());
    }
}
The transcript is short, and the assertion is the point (09-reading-the-imports-file-in-a-test.txt):
entries under com.ankurm.masker: 2
  com.ankurm.masker.MaskerAutoConfiguration
  com.ankurm.masker.MaskerJacksonAutoConfiguration
ForgottenAutoConfiguration listed: false
Going deeper: why @AutoConfiguration classes are invisible to a scan, read from the filter

The scan that @SpringBootApplication performs carries a filter, AutoConfigurationExcludeFilter, that skips auto-configuration classes. The bytecode of its 4.1.1 version tests two things: whether the class is a @Configuration, and whether it is an auto-configuration, which it decides by asking for the @AutoConfiguration annotation and by looking the name up in the list loaded by ImportCandidates (from 14-boot4-autoconfiguration-locations.txt; read with javap -c, so this is what the code loads, not a description of intent):

ldc #26 // class org/springframework/context/annotation/Configuration
ldc #40 // class org/springframework/boot/autoconfigure/AutoConfiguration
ldc #40 // class org/springframework/boot/autoconfigure/AutoConfiguration
invokestatic #65 // Method org/springframework/boot/context/annotation/ImportCandidates.load:(Ljava/lang/Class;Ljava/lang/ClassLoader;)Lorg/springframework/boot/context/annotation/ImportCandidates;

That agrees with the transcript: the annotated class is skipped by the scan whether or not it is listed, so omitting it from the file leaves it inactive everywhere, and the plain @Configuration is a candidate for the scan and nothing else. The author’s application scans only the package that holds these two test classes, which is what a real starter’s own test application usually does.

Going deeper

What Boot 4 changed for people writing starters

Boot 4 split its old auto-configuration jar by technology. spring-boot-autoconfigure still exists and still holds the infrastructure a starter needs, but the per-technology auto-configurations left it, and a starter that orders itself after one of them has to import it from its new home. The 4.1.1 jars say so directly (from 14-boot4-autoconfiguration-locations.txt):
--- entries in spring-boot-autoconfigure's own AutoConfiguration.imports ---
12

--- JacksonAutoConfiguration: which jar, which package ---
spring-boot-autoconfigure-4.1.1.jar, classes named JacksonAutoConfiguration: 0
spring-boot-jackson-4.1.1.jar: org/springframework/boot/jackson/autoconfigure/JacksonAutoConfiguration.class
The autoconfigure jar now lists twelve of its own auto-configurations, and Jackson’s is in spring-boot-jackson under org.springframework.boot.jackson.autoconfigure. A Boot 3 starter that ordered itself after Jackson with the old import does not compile (OldJacksonImport.java, kept out of the build and compiled on purpose by capture-jar-facts.sh):
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;

/** How a Boot 3 starter orders itself after Jackson. Not part of the build: scripts/capture-jar-facts.sh compiles it on purpose. */
@AutoConfiguration(after = JacksonAutoConfiguration.class)
public class OldJacksonImport {
}
masker-spring-boot-autoconfigure/src/broken/OldJacksonImport.java:4: error: package org.springframework.boot.autoconfigure.jackson does not exist
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
                                                     ^
masker-spring-boot-autoconfigure/src/broken/OldJacksonImport.java:7: error: cannot find symbol
@AutoConfiguration(after = JacksonAutoConfiguration.class)
                           ^
  symbol: class JacksonAutoConfiguration
2 errors
That is the useful failure: the compiler tells you at build time. The classes that did not move are just as important to know, because they are the ones every starter uses. Each of these was imported by this module and compiled against 4.1.1, and the transcript shows which jar holds it (17-where-the-classes-live.txt):
spring-boot-autoconfigure-4.1.1.jar            org/springframework/boot/autoconfigure/AutoConfiguration.class
spring-boot-autoconfigure-4.1.1.jar            org/springframework/boot/autoconfigure/AutoConfigureAfter.class
spring-boot-autoconfigure-4.1.1.jar            org/springframework/boot/autoconfigure/AutoConfigurations.class
spring-boot-autoconfigure-4.1.1.jar            org/springframework/boot/autoconfigure/condition/ConditionalOnClass.class
spring-boot-autoconfigure-4.1.1.jar            org/springframework/boot/autoconfigure/condition/ConditionalOnBean.class
spring-boot-autoconfigure-4.1.1.jar            org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBean.class
spring-boot-autoconfigure-4.1.1.jar            org/springframework/boot/autoconfigure/condition/ConditionalOnProperty.class
spring-boot-autoconfigure-4.1.1.jar            org/springframework/boot/autoconfigure/logging/ConditionEvaluationReportLoggingListener.class
spring-boot-4.1.1.jar                          org/springframework/boot/context/annotation/ImportCandidates.class
spring-boot-4.1.1.jar                          org/springframework/boot/context/annotation/Configurations.class
spring-boot-jackson-4.1.1.jar                  org/springframework/boot/jackson/autoconfigure/JacksonAutoConfiguration.class
spring-boot-test-4.1.1.jar                     org/springframework/boot/test/context/runner/ApplicationContextRunner.class
spring-boot-test-4.1.1.jar                     org/springframework/boot/test/context/FilteredClassLoader.class
The condition annotations, @AutoConfiguration and the ordering annotations are in spring-boot-autoconfigure. ImportCandidates and Configurations are in spring-boot itself, and the test support is in spring-boot-test. I checked where each of them lives in 4.1.1; I did not compare this list with Boot 3, so I make no claim about which of these are new locations. The Jackson auto-configuration is the one the compiler proved is.
Going deeper: the relocation as a migration problem

The same modularisation affects everything that imports a per-technology class, not only starters, and the guide on this site covers the wider list (the Spring Boot 3 to 4 migration guide, the section on modularisation). For a starter author the working rule is short. Compile against the Boot version you target, read every “package does not exist” error as a relocation, and use afterName where a compile-time dependency on the other module would make it a required dependency of yours.

If your starter supports both Boot 3 and Boot 4 from one artifact, the Jackson case shows why that is hard: the package names differ, and so does the Jackson generation the module has to be written against (tools.jackson here). Two artifacts, one per Boot line, is the straightforward answer.

Going deeper

Should you write a starter at all?

Not for one application. A starter is a second artifact with its own version, its own release process and its own compatibility matrix against Boot, and every application that depends on it inherits the consequences of each Boot upgrade you have not yet made. If only one application needs the beans, a @Configuration class inside it costs nothing and needs no imports file. Two applications is the point at which duplication starts to drift, and three is the point at which a starter usually pays for itself. Before writing one, check whether the thing you are wrapping already has an official or vendor starter, because the property namespace you would invent will not match the one your colleagues meet next.

If you do write one, keep it small, make everything overridable with @ConditionalOnMissingBean, make the off switch obvious, and put the transcripts in the README so a reader can see what it did in a real run. The starter in this repository is about 130 lines of Java, and most of what is written above is about the ways those lines go wrong quietly.

Going deeper

  • Run everything yourself: the module README has the quick-start and an index of the seventeen transcripts; ./scripts/run-all.sh regenerates them

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.