Spring Boot multi-module: dependency module beans not loaded after mvn package (decorator bean ignored)

Summary

The core issue is the Spring Boot fat jar’s classloader hierarchy and how it discovers @Configuration classes. When running from an IDE, the classpath is flat and contains all modules, allowing the component scan to easily pick up module-3. However, when packaged, Spring Boot’s LaunchedURLClassLoader prioritizes the jar’s own BOOT-INF/classes and nested jars over the root classpath. If module-3 is bundled as a standard jar inside BOOT-INF/lib, it is accessible, but if the application context fails to scan the specific packages (due to restrictive scanBasePackages or auto-configuration exclusion), the beans won’t load. The “ignored decorator” symptom suggests the bean definition exists but was not processed by the container during the jar execution, often because the @Configuration class was not registered or @ComponentScan missed the dependency’s package.

Root Cause

The root cause is twofold: inadequate Component Scan coverage and Spring Boot Maven Plugin packaging configuration.

  • Restrictive Component Scan: The @SpringBootApplication(scanBasePackages = {"com.product", "com.company"}) explicitly limits scanning to these two packages. If module-3 resides in a different package (e.g., com.shared or com.infrastructure), the Spring container will never see the @Configuration class during startup.
  • Jar Packaging Structure: Spring Boot repackages the application into a fat jar with a specific structure (BOOT-INF/classes, BOOT-INF/lib). While module-3 ends up in BOOT-INF/lib, the LaunchedURLClassLoader ensures that BOOT-INF/classes is loaded first, and nested jars are loaded in isolation. If the main application (module-1) does not explicitly expose the dependency’s package or if the plugin configuration fails to include module-3‘s resources correctly, the runtime environment differs from the IDE environment.
  • Start-Class Mismatch (Less Common): If the spring-boot-maven-plugin is not configured correctly in a multi-module hierarchy, it might pick up a different Start-Class (perhaps from a module higher in the hierarchy), causing the wrong application context to load.

Why This Happens in Real Systems

In real-world distributed systems, this behavior is standard but often misunderstood:

  • Dependency Isolation: Maven dependencies are transitive. Developers often assume that if module-1 depends on module-3, all beans in module-3 are automatically visible. However, visibility is not the same as discovery. Just because the classes are in the jar doesn’t mean Spring has registered them as beans.
  • Build vs. Runtime: The IDE usually adds all build dependencies to the classpath root. The Maven build creates a nested structure. This disparity hides the fact that the application relies on “accidental” classpath visibility rather than explicit configuration.
  • “It Works in IDE” Trap: This is a classic symptom of Implicit Behavior Dependency. The developer relies on default scanning behavior that exists in the IDE but is suppressed or altered by the strict classloading rules of the packaged jar.

Real-World Impact

  • Feature Flag Failure: If the decorator is meant to enable/disable features based on configuration (as seen in the featureEnabled variable), the fallback behavior (original service) will execute, potentially bypassing critical logic like audit logging, security checks, or performance tracking.
  • Silent Degradation: The application starts successfully, but the logic behaves differently. There are no startup errors, making this a “silent” production issue that is hard to trace.
  • Inconsistent Environments: Code works in Dev (where run configurations are loose) but fails in Staging/Prod (where jars are strictly executed), leading to deployment rollbacks and loss of trust in the CI/CD pipeline.

Example or Code

// module-3/src/main/java/com/shared/config/Module3Config.java
@Configuration
// CRITICAL FIX: Ensure this matches the missing package in the main app's scan
// OR remove scanBasePackages entirely from @SpringBootApplication
public class Module3Config {
    @Bean
    @Primary
    public SomeService decoratedService(
            @Qualifier("originalService") SomeService delegate,
            @Value("${feature.enabled:false}") boolean featureEnabled) {
        return new DecoratedService(delegate, featureEnabled);
    }
}


    
        
            org.springframework.boot
            spring-boot-maven-plugin
            
                
                com.product.module1.Module1Application
            
            
                
                    
                        repackage
                    
                
            
        
    

How Senior Engineers Fix It

Senior engineers enforce Explicit Dependency Management and Defensive Configuration:

  1. Refactor Component Scanning: The most robust fix is to remove the restrictive scanBasePackages in @SpringBootApplication and rely on Classpath Scanning. If specificity is required, move the @Configuration classes to sub-packages of the main application or ensure the main application’s scan range covers the dependency.
    • Fix: Change scanBasePackages to include com.shared (wherever module-3 lives) or simply delete the attribute.
  2. Use Auto-Configuration: The “Spring Way” is to convert module-3 into a starter. Create src/main/resources/META-INF/spring.factories (or org.springframework.boot.autoconfigure.AutoConfiguration.imports in Spring Boot 2.7+) and register Module3Config there. This ensures the configuration is loaded regardless of package scanning.
  3. Verify Maven Repackaging: Explicitly set the <mainClass> in the spring-boot-maven-plugin configuration in the final module. This guarantees that the entry point is correct and the Start-Class property is set accurately.

Why Juniors Miss It

  • Classpath vs. Component Scan: Juniors often conflate having a jar on the classpath with Spring scanning that jar for annotations. They don’t realize that @ComponentScan is restricted to specific packages unless told otherwise.
  • IDE Magic: IDEs (IntelliJ/Eclipse) automatically add all source modules to the runtime classpath, masking the need for proper @Configuration registration.
  • Configuration Oversights: They often copy-paste @SpringBootApplication attributes (like scanBasePackages) from other projects without understanding the consequences, creating artificial boundaries that break modularity.
  • Lack of Repackage Understanding: They may not understand that mvn package creates a completely different artifact layout (nested jars) than a simple mvn compile, leading to the “works in IDE, fails in jar” scenario.