Starter Module Structure and Auto-Configuration Registration
Starter Module Structure and Auto-Configuration Registration
A Spring Boot starter is not one module. It is two. This is not a suggestion. It is a structural requirement with specific technical reasons that, when ignored, produce starters that pollute the classpath, break IDE support, and make version management painful.
The Two-Module Convention
Every official Spring Boot starter follows this structure:
spring-boot-starter-data-jpa: dependency-only module. Contains no code. Itspom.xmlpulls inspring-boot-autoconfigure(which containsDataSourceAutoConfiguration,HibernateJpaAutoConfiguration, etc.), the Hibernate JPA implementation, and the JDBC driver API.spring-boot-autoconfigure: contains the auto-configuration classes, the@Conditionalguards, the properties classes, and theAutoConfiguration.importsregistration.
The separation exists because autoconfigure modules have optional dependencies. The autoconfigure module depends on javax.sql.DataSource, but that dependency is optional. The starter module makes it required. Consuming applications depend on the starter, which transitively brings in everything needed to make the auto-configuration conditions pass.
For the SaaS audit library, the structure is:
saas-audit/
├── saas-audit-spring-boot-autoconfigure/
│ ├── pom.xml
│ └── src/main/
│ ├── java/com/saas/audit/autoconfigure/
│ │ ├── SaasAuditAutoConfiguration.java
│ │ ├── AuditProperties.java
│ │ ├── AuditService.java
│ │ ├── JdbcAuditService.java
│ │ ├── TenantAwareAuditInterceptor.java
│ │ └── OnMultiTenantCondition.java
│ └── resources/META-INF/
│ ├── spring/
│ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports
│ └── spring-configuration-metadata.json
├── saas-audit-spring-boot-starter/
│ └── pom.xml
└── pom.xml (parent)
The Autoconfigure Module
The autoconfigure module contains all the code and all the conditional logic. Its dependencies are marked optional so they do not leak into consuming applications transitively:
<!-- saas-audit-spring-boot-autoconfigure/pom.xml -->
<project>
<groupId>com.saas</groupId>
<artifactId>saas-audit-spring-boot-autoconfigure</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
<relativePath/>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<!-- Optional: only needed when DataSource is on the classpath -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<optional>true</optional>
</dependency>
<!-- Optional: only needed for @ConfigurationProperties metadata generation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!-- Optional: servlet API for the interceptor -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
The optional keyword is critical. When a dependency is optional, Maven does not include it transitively. The autoconfigure module compiles against spring-jdbc but does not force consuming applications to include it. The @ConditionalOnClass(DataSource.class) annotation handles the case where the class is absent at runtime.
For Gradle, the equivalent is compileOnly:
// saas-audit-spring-boot-autoconfigure/build.gradle
plugins {
id 'java-library'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-autoconfigure'
compileOnly 'org.springframework:spring-jdbc'
compileOnly 'org.springframework:spring-webmvc'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
The Starter Module
The starter module contains no code. Zero Java files. Its only purpose is to aggregate the correct set of required dependencies:
<!-- saas-audit-spring-boot-starter/pom.xml -->
<project>
<groupId>com.saas</groupId>
<artifactId>saas-audit-spring-boot-starter</artifactId>
<version>1.0.0</version>
<dependencies>
<!-- The autoconfigure module -->
<dependency>
<groupId>com.saas</groupId>
<artifactId>saas-audit-spring-boot-autoconfigure</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Required runtime dependencies that make the conditions pass -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
</dependencies>
</project>
Consuming applications add one dependency:
<dependency>
<groupId>com.saas</groupId>
<artifactId>saas-audit-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
This single line brings in the autoconfigure module, the JDBC starter (which brings in HikariCP, spring-jdbc, and spring-boot-autoconfigure), and ensures all @ConditionalOnClass checks pass.
The Broken Structure
// BROKEN: Auto-configuration class in the starter module
saas-audit-spring-boot-starter/
├── pom.xml
└── src/main/java/com/saas/audit/
├── SaasAuditAutoConfiguration.java // Wrong location
├── AuditService.java
└── JdbcAuditService.java
Three failures result from this structure:
Problem 1: All dependencies become required. Because the auto-configuration class is in the starter, every dependency it uses must be a compile dependency of the starter. spring-jdbc, spring-webmvc, any NoSQL driver you add later. They all become transitive. Every consuming application gets them whether needed or not.
Problem 2: No independent autoconfigure testing. You cannot test the auto-configuration in isolation. The starter pulls in all dependencies, so your tests always run with a full classpath. You cannot verify that @ConditionalOnClass correctly skips the configuration when a dependency is absent because the dependency is always present.
Problem 3: Version conflicts. When consuming applications need a different version of a transitive dependency (say, a specific spring-jdbc patch for a bug fix), the starter’s hard dependency creates a conflict. With the two-module structure, the autoconfigure module declares it optional, and the consuming application controls the version.
The Correct Structure
// CORRECT: Autoconfigure module holds all code, starter holds only dependencies
saas-audit-spring-boot-autoconfigure/
├── pom.xml (optional dependencies)
└── src/main/
├── java/com/saas/audit/autoconfigure/ (all code here)
└── resources/META-INF/ (registration + metadata)
saas-audit-spring-boot-starter/
└── pom.xml (required dependencies, no code)
AutoConfiguration.imports Registration
The autoconfigure module must register its auto-configuration classes. Create this file:
// src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.saas.audit.autoconfigure.SaasAuditAutoConfiguration
One class per line. No comments. No blank lines between entries (trailing newline at end of file is acceptable). This file is read by ImportCandidates.load() inside AutoConfigurationImportSelector.
If you have multiple auto-configuration classes (for example, one for JDBC audit storage and one for MongoDB audit storage), list them all:
com.saas.audit.autoconfigure.SaasAuditJdbcAutoConfiguration
com.saas.audit.autoconfigure.SaasAuditMongoAutoConfiguration
Each class handles its own conditions independently. SaasAuditJdbcAutoConfiguration has @ConditionalOnClass(DataSource.class). SaasAuditMongoAutoConfiguration has @ConditionalOnClass(MongoClient.class). The application includes whichever dependencies it needs, and the correct auto-configuration activates.
Common Registration Mistakes
Mistake 1: Using the old spring.factories for auto-configuration. Spring Boot 3.0 removed support for registering auto-configuration classes in spring.factories. The key org.springframework.boot.autoconfigure.EnableAutoConfiguration is ignored. If your starter targets Spring Boot 3.x, you must use AutoConfiguration.imports. The spring.factories file is still valid for other SPI registrations like EnvironmentPostProcessor or ApplicationListener.
Mistake 2: Putting the auto-configuration class on the component scan path. If SaasAuditAutoConfiguration is in a package that the consuming application scans (e.g., com.saas.audit), it gets registered twice: once through component scanning and once through AutoConfiguration.imports. The component-scanned version ignores the @AutoConfiguration ordering guarantees. Condition evaluation may run before dependencies are registered. Use a sub-package like com.saas.audit.autoconfigure that is not on the consuming application’s scan path.
Mistake 3: Forgetting @AutoConfiguration(after = ...). Without explicit ordering, your auto-configuration runs in an undefined order relative to Spring Boot’s built-in auto-configurations. If your configuration depends on beans from DataSourceAutoConfiguration, it must declare @AutoConfiguration(after = DataSourceAutoConfiguration.class). Without this, @ConditionalOnBean(DataSource.class) may evaluate before the DataSource bean definition is registered, causing the condition to fail even though the DataSource will eventually exist.
Configuration Metadata for IDE Support
Spring Boot IDEs (IntelliJ IDEA, VS Code with Spring Boot Tools) provide auto-completion for application.properties and application.yml. This works through META-INF/spring-configuration-metadata.json. The spring-boot-configuration-processor annotation processor generates this file at compile time from @ConfigurationProperties classes.
Add the processor to the autoconfigure module:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
After compilation, the generated file looks like:
{
"groups": [
{
"name": "saas.audit",
"type": "com.saas.audit.autoconfigure.AuditProperties",
"sourceType": "com.saas.audit.autoconfigure.AuditProperties"
}
],
"properties": [
{
"name": "saas.audit.enabled",
"type": "java.lang.Boolean",
"description": "Enable or disable the audit starter.",
"defaultValue": true
},
{
"name": "saas.audit.table-name",
"type": "java.lang.String",
"description": "Database table name for audit events.",
"defaultValue": "audit_events"
},
{
"name": "saas.audit.retention-days",
"type": "java.lang.Integer",
"description": "Number of days to retain audit events.",
"defaultValue": 90
},
{
"name": "saas.audit.async",
"type": "java.lang.Boolean",
"description": "Write audit events asynchronously.",
"defaultValue": false
}
]
}
You can also provide additional metadata manually for properties that the processor cannot infer. Create META-INF/additional-spring-configuration-metadata.json:
{
"properties": [
{
"name": "saas.audit.enabled",
"description": "Enable or disable the audit starter. When false, no audit beans are registered.",
"defaultValue": true
}
],
"hints": [
{
"name": "saas.audit.table-name",
"values": [
{ "value": "audit_events", "description": "Default table name" },
{ "value": "audit_log", "description": "Alternative table name" }
]
}
]
}
The processor merges both files during compilation. The result is that developers using your starter get full auto-completion, type checking, and documentation in their IDE when configuring saas.audit.* properties.
The @AutoConfiguration Annotation
@AutoConfiguration was introduced in Spring Boot 2.7 to replace the combination of @Configuration + @AutoConfigureAfter + @AutoConfigureBefore. It is a composed annotation:
// Spring Boot internal: @AutoConfiguration is defined as
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Configuration(proxyBeanMethods = false)
@AutoConfigureBefore
@AutoConfigureAfter
public @interface AutoConfiguration {
String[] before() default {};
String[] beforeName() default {};
String[] after() default {};
String[] afterName() default {};
}
Note proxyBeanMethods = false. Auto-configuration classes use lite mode by default. @Bean methods do not go through CGLIB proxying, which means calling one @Bean method from another does not return the singleton. This is intentional: auto-configuration classes should not have inter-bean dependencies resolved through method calls. Use constructor parameters instead.
// BROKEN: Calling @Bean method directly in lite mode
@AutoConfiguration
public class SaasAuditAutoConfiguration {
@Bean
public AuditService auditService(DataSource ds, AuditProperties props) {
return new JdbcAuditService(ds, props);
}
@Bean
public TenantAwareAuditInterceptor interceptor() {
// This creates a NEW AuditService instance, not the bean
return new TenantAwareAuditInterceptor(auditService(null, null));
}
}
// CORRECT: Use method parameters for bean dependencies
@AutoConfiguration
public class SaasAuditAutoConfiguration {
@Bean
public AuditService auditService(DataSource ds, AuditProperties props) {
return new JdbcAuditService(ds, props);
}
@Bean
public TenantAwareAuditInterceptor interceptor(AuditService auditService) {
return new TenantAwareAuditInterceptor(auditService);
}
}
The two-module structure, proper AutoConfiguration.imports registration, optional dependencies in the autoconfigure module, and configuration metadata: these are the structural requirements for a production-quality starter. Testing the auto-configuration logic is covered in CH6-S2.