Custom Spring Boot Starters: What Worked and What Didn't

This is Part 3 of a series: Maven Patterns from Building an Internal SDK.

This is the longest post in the series—starters touch module structure, auto-configuration, properties, and conditionals. Stick with it; the patterns here save hours of debugging later.

What Makes a Starter

A Spring Boot starter is a Maven module that bundles:

  • Dependencies needed for a feature

  • Auto-configuration that wires things up

  • Default properties that work out of the box

Spring Boot’s official convention separates starters (dependency-only) from auto-configuration modules. For internal SDKs, we found combining them into single modules reduced complexity—fewer artifacts to manage, simpler dependency trees. This post describes that combined approach.

The goal is one dependency, zero boilerplate. Consumers add your starter and everything just works.

<!-- Consumer's pom.xml -->
<dependency>
    <groupId>com.example.sdk</groupId>
    <artifactId>sdk-starter-sftp</artifactId>
</dependency>

No @Bean definitions. No property files to copy. No configuration classes to write.

Key Takeaways
  • Three-tier inheritance: sdk-parent → sdk-dependencies (BOM) → starters and sdk-starter-parent

  • Always use @ConditionalOnMissingBean: Let consumers override any bean

  • Ship defaults via @PropertySource: Lower precedence than consumer’s application.properties

The Three-Tier Module Structure

After several iterations, we landed on a three-tier hierarchy that separates concerns cleanly.

The diagram below shows POM inheritance (what each module extends), not Maven module nesting:

sdk-parent (root)
    └── sdk-dependencies (BOM)
            ├── sdk-starter-sftp
            ├── sdk-starter-auth
            ├── sdk-starter-common
            ├── ...
            └── sdk-starter-parent (consumer-facing parent)

Tier 1: Root Parent

The root parent extends spring-boot-starter-parent and contains:

  • Plugin configurations (compiler, surefire, enforcer, etc.)

  • Build properties

  • Repository definitions

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.0</version>
</parent>
<artifactId>sdk-parent</artifactId>
<packaging>pom</packaging>

Tier 2: Dependencies BOM

The BOM extends the root parent and manages all dependency versions. Individual starters extend this module:

<parent>
    <groupId>com.example.sdk</groupId>
    <artifactId>sdk-parent</artifactId>
    <version>${revision}</version>
</parent>
<artifactId>sdk-dependencies</artifactId>
<packaging>pom</packaging>

This is where third-party versions, imported BOMs, and internal module versions live (covered in Part 2).

Tier 3: Starter Parent

The starter parent is what consumers use as their parent POM. It extends the BOM and adds:

  • Common dependencies every consumer needs

  • Build configuration suitable for applications (not libraries)

  • Sensible plugin defaults for consumer projects

<parent>
    <groupId>com.example.sdk</groupId>
    <artifactId>sdk-dependencies</artifactId>
    <version>${revision}</version>
</parent>
<artifactId>sdk-starter-parent</artifactId>
<packaging>pom</packaging>

<dependencies>
    <!-- Every consumer gets these -->
    <dependency>
        <groupId>com.example.sdk</groupId>
        <artifactId>sdk-starter-common</artifactId>
    </dependency>
</dependencies>

Consumers extend this and pick additional starters:

<parent>
    <groupId>com.example.sdk</groupId>
    <artifactId>sdk-starter-parent</artifactId>
    <version>2.0.0</version>
</parent>

<dependencies>
    <dependency>
        <groupId>com.example.sdk</groupId>
        <artifactId>sdk-starter-sftp</artifactId>
    </dependency>
</dependencies>

Auto-Configuration Essentials

Registration

Spring Boot 3.x uses META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:

com.example.sdk.sftp.SftpClientAutoConfiguration

One fully-qualified class name per line. No spring.factories needed for auto-configuration registration in Spring Boot 3.x (though spring.factories is still used for other extension points like failure analyzers and environment post-processors).

The Configuration Class

A minimal auto-configuration:

@AutoConfiguration
@EnableConfigurationProperties(SftpProperties.class)
public class SftpClientAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public SftpSessionFactory sftpSessionFactory(SftpProperties properties) {
        DefaultSftpSessionFactory factory = new DefaultSftpSessionFactory();
        factory.setHost(properties.getHost());
        factory.setPort(properties.getPort());
        factory.setUser(properties.getUsername());
        factory.setPassword(properties.getPassword());
        factory.setTimeout(properties.getTimeoutMillis());
        return factory;
    }

    @Bean
    @ConditionalOnMissingBean
    public SftpRemoteFileTemplate sftpTemplate(SftpSessionFactory factory) {
        return new SftpRemoteFileTemplate(factory);
    }
}

Key points:

  • @AutoConfiguration instead of @Configuration signals this is auto-configuration

  • @EnableConfigurationProperties binds the properties class

  • @ConditionalOnMissingBean lets consumers override with their own bean

Properties Class

@ConfigurationProperties(prefix = "sdk.sftp")
@Getter
@Setter
public class SftpProperties {

    private String host;
    private int port = 22;
    private String username;
    private String password;
    private int timeoutMillis = 60000;
}

Use @ConfigurationProperties instead of @Value. It gives you type safety, IDE completion, and works with @Validated for constraint validation.

Default Properties That Work

One pattern that paid off: shipping sensible defaults via property files.

The Pattern

Create property files in src/main/resources:

# application-sdk-server.properties
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=60s
server.compression.enabled=true
# application-sdk-jackson.properties
spring.jackson.default-property-inclusion=non_null
spring.jackson.deserialization.fail-on-unknown-properties=false
spring.jackson.serialization.write-dates-as-timestamps=false

Load them via @PropertySource:

@AutoConfiguration
@PropertySource("classpath:application-sdk-server.properties")
@PropertySource("classpath:application-sdk-jackson.properties")
public class DefaultPropertiesAutoConfiguration {
    // Empty - just loads properties
}
@PropertySource does not work for logging configuration. Logging properties (like logging.level. or logging.pattern.) are processed very early in Spring Boot’s startup, before @PropertySource is evaluated. To ship logging defaults, implement an EnvironmentPostProcessor instead.

Why This Works

  • Consumers get sensible defaults without copying property files

  • Properties loaded via @PropertySource have lower precedence than application.properties

  • Consumers can override anything—they just set the property themselves

@PropertySource bypasses Spring Boot’s ConfigData pipeline—no profile activation, no spring.config.import, no config tree semantics. For simple key-value defaults this is fine. For anything requiring profiles or advanced config features, use a custom EnvironmentPostProcessor instead.

Organizing Property Files

Split by concern:

resources/
  application-sdk-server.properties      # Server settings
  application-sdk-jackson.properties     # JSON serialization
  application-sdk-observability.properties   # Metrics, tracing

One file per topic makes it easy to find and modify defaults.

Logging properties (logging.level., logging.pattern.) require an EnvironmentPostProcessor registered in META-INF/spring.factories. Don’t include them in @PropertySource-loaded files.

Conditional Configurations

@ConditionalOnClass

Activate configuration only when a class is on the classpath:

@AutoConfiguration
@ConditionalOnClass(Jedis.class)
public class RedisAutoConfiguration {
    // Only activates if Jedis is present
}

@ConditionalOnProperty

Activate based on a property value:

@Bean
@ConditionalOnProperty(name = "sdk.sftp.enabled", havingValue = "true", matchIfMissing = true)
public SftpClientService sftpClientService(SftpRemoteFileTemplate template) {
    return new SftpClientService(template);
}

The matchIfMissing = true means enabled by default—the consumer must explicitly disable it.

@AutoConfigureBefore / @AutoConfigureAfter

Control ordering when you need to run before or after another auto-configuration:

@AutoConfiguration
@AutoConfigureBefore(org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration.class)
public class CustomRedisAutoConfiguration {
    // Runs before Spring Boot's Redis auto-configuration
}

Use this sparingly. If you’re fighting ordering battles, your design might need rethinking.

Bundle Starters

Sometimes consumers need multiple starters together. Instead of making them list five dependencies, create a bundle:

<artifactId>sdk-starter-all</artifactId>

<dependencies>
    <dependency>
        <groupId>com.example.sdk</groupId>
        <artifactId>sdk-starter-common</artifactId>
    </dependency>
    <dependency>
        <groupId>com.example.sdk</groupId>
        <artifactId>sdk-starter-auth</artifactId>
    </dependency>
    <dependency>
        <groupId>com.example.sdk</groupId>
        <artifactId>sdk-starter-profile</artifactId>
    </dependency>
</dependencies>

Consumers add one dependency:

<dependency>
    <groupId>com.example.sdk</groupId>
    <artifactId>sdk-starter-all</artifactId>
</dependency>

We created bundles for specific use cases: sdk-starter-all for full platform access, and domain-specific bundles like sdk-starter-payments for teams that only needed a subset of features.

Test Starters

Don’t forget testing. A test starter bundles everything consumers need for testing.

The Pattern

<artifactId>sdk-starter-test</artifactId>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>compile</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>compile</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>compile</scope>
    </dependency>
    <dependency>
        <groupId>org.wiremock.integrations</groupId>
        <artifactId>wiremock-spring-boot</artifactId>
        <scope>compile</scope>
    </dependency>
    <dependency>
        <groupId>com.tngtech.archunit</groupId>
        <artifactId>archunit-junit5</artifactId>
        <scope>compile</scope>
    </dependency>
</dependencies>

Consumers use this starter with test scope:

<dependency>
    <groupId>com.example.sdk</groupId>
    <artifactId>sdk-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Why This Works

Dependencies are compile scope in the starter, not test. When a consumer declares the starter with test scope, Maven’s scope resolution ensures all transitive dependencies inherit test scope—they’re only available during test compilation and execution.

This gives consumers WireMock, Testcontainers, ArchUnit—all version-managed and tested together—without polluting production classpaths.

What Worked

Separation of BOM and Starters

Keeping version management (BOM) separate from auto-configuration (starters) paid off. Teams could import just the BOM for version management without pulling in auto-configuration they didn’t want.

Common Configuration Starter

A sdk-starter-common that every other starter depends on became the foundation. It handles:

  • Logging defaults

  • Server configuration

  • Jackson settings

  • Observability setup

Other starters don’t duplicate this—they depend on common and add their specific configuration.

@ConditionalOnMissingBean Everywhere

Every bean we create is @ConditionalOnMissingBean. Consumers can always override. This sounds obvious but we initially missed it on some beans, forcing consumers into ugly workarounds.

Property Files for Defaults

Loading defaults via @PropertySource instead of hardcoding in Java made updates easier. Change a property file, release, done. No code changes needed for tuning defaults.

What Didn’t Work

Copying Spring Boot’s Auto-Configuration

When we needed custom Redis behavior (multiple databases, custom connection factory), we copied Spring Boot’s JedisConnectionConfiguration and modified it. Bad idea:

  • Maintenance burden—Spring Boot updates, we have to sync

  • Subtle bugs from incomplete copies

  • Version coupling to internal Spring Boot classes

Better approach: use @AutoConfigureBefore with @ConditionalOnMissingBean to provide your beans before Spring Boot’s auto-configuration runs. Let Spring Boot do the heavy lifting.

Virtual Threads Conditional Duplication

Supporting both platform and virtual threads meant duplicating beans with @ConditionalOnThreading (Spring Boot 3.2+):

@Bean
@ConditionalOnThreading(Threading.PLATFORM)
public ConnectionFactory connectionFactory() { /*...*/ }

@Bean
@ConditionalOnThreading(Threading.VIRTUAL)
public ConnectionFactory connectionFactoryVirtualThreads() { /*...*/ }

Every bean that needed thread-aware configuration was duplicated. The code doubled in size. We should have abstracted the thread-aware parts into a strategy pattern or builder.

Too Many Database Property Files

We ended up with separate property files for MySQL, PostgreSQL, and their Hibernate settings:

application-sdk-mysql.properties
application-sdk-mysql-hibernate.properties
application-sdk-postgresql.properties
application-sdk-postgresql-hibernate.properties
application-sdk-mysql-postgresql-common-hibernate.properties

The conditional loading logic became fragile. A simpler approach: one property file per database with clear namespacing, loaded based on a single property like sdk.database.type=mysql.

Starters That Do Too Much

Some starters grew to handle authentication, caching, HTTP clients, and retry logic. They became hard to test and impossible to use partially. The fix: smaller, focused starters that compose well.

Conclusion

Building Spring Boot starters is deceptively simple. The mechanics are straightforward—auto-configuration, properties, conditionals. The hard part is designing starters that compose well, don’t fight each other, and stay maintainable as requirements evolve.

The patterns that held up:

  • Three-tier module structure (parent → BOM → starter-parent)

  • @ConditionalOnMissingBean on every bean

  • Default properties via @PropertySource

  • Small, focused starters over monolithic ones

  • Test starter for bundling test dependencies

The mistakes to avoid:

  • Copying framework internals instead of extending them

  • Bean duplication for conditional variants

  • Starters that try to do everything

A good starter disappears. Consumers don’t think about it—it just works. When they need to customize, the path is obvious.