Spring多模块(非Java 9模块)间松耦合实现方案咨询
Hey there! Let's tackle this module decoupling problem head-on—smart move wanting to avoid the tight coupling pitfalls of @Import, since it does lock you into specific implementations and hides clear APIs from consumer modules. Here are some battle-tested, Spring-native approaches to keep your modules loosely coupled while maintaining clear, replaceable boundaries:
1. Interface-Based API Contracts + Spring Auto-Configuration
This is the gold standard for Spring Boot-style modularity, focusing on abstract contracts over concrete implementations:
Step 1: Create a shared API module
This module only contains public interfaces, DTOs, exceptions, and any other contract definitions—no implementation code. Think of it as the "language" your modules use to communicate. For example:// Shared API module public interface UserService { UserDto getUserById(Long id); } public record UserDto(Long id, String name) {}Step 2: Implement the service in a dedicated module
The implementation module depends on the shared API, but keeps its implementation classes in internal packages (not exported to consumers). Use a@Configurationclass to register the implementation as a bean for the interface, and leverage Spring's auto-configuration to make it discoverable:// Implementation module @Configuration @ConditionalOnClass(UserService.class) // Only load if API is present public class UserServiceAutoConfiguration { @Bean public UserService userService() { return new DefaultUserService(); // Internal implementation } }Then register this auto-configuration by adding its fully qualified name to
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importsin the implementation module.Step 3: Consume the API without knowing the implementation
Consumer modules only depend on the shared API module. They inject the interface directly, and Spring will auto-wire the implementation from the classpath:// Consumer module @Service public class OrderService { private final UserService userService; public OrderService(UserService userService) { // No dependency on DefaultUserService this.userService = userService; } }To switch implementations, just swap the implementation module dependency in your build file—no changes needed in consumer code.
2. Qualifiers & Conditional Beans for Multiple Implementation Options
If you need to support multiple interchangeable implementations of the same API, use Spring's qualifiers and conditional beans to let consumers (or config) choose which one to use:
Mark implementations with custom qualifiers
// Implementation 1: Cloud-based @Service @Qualifier("cloud") @ConditionalOnProperty(name = "user.service.type", havingValue = "cloud") public class CloudUserService implements UserService { // ... } // Implementation 2: Database-based @Service @Qualifier("database") @ConditionalOnProperty(name = "user.service.type", havingValue = "database", matchIfMissing = true) public class DatabaseUserService implements UserService { // ... }Consumers select via qualifier or config
Consumers can either explicitly pick a qualifier, or let config drive the choice:@Service public class OrderService { private final UserService userService; // Explicit qualifier public OrderService(@Qualifier("cloud") UserService userService) { this.userService = userService; } // Or let config decide (no qualifier needed if @Primary is set, or conditional beans handle it) public OrderService(UserService userService) { this.userService = userService; } }
3. Event-Driven Communication for Full Decoupling
For scenarios where modules don't need direct method calls, use Spring's event system to let modules communicate via events—this eliminates any direct dependency between modules:
Define shared event contracts
Create a shared events module with event classes:// Shared events module public class UserCreatedEvent extends ApplicationEvent { private final UserDto user; public UserCreatedEvent(Object source, UserDto user) { super(source); this.user = user; } public UserDto getUser() { return user; } }Publish events from the producer module
The producer module depends on the events module and publishes events:@Service public class UserManagementService { private final ApplicationEventPublisher publisher; public UserManagementService(ApplicationEventPublisher publisher) { this.publisher = publisher; } public void createUser(UserDto user) { // Save user logic publisher.publishEvent(new UserCreatedEvent(this, user)); } }Listen for events in the consumer module
The consumer module also depends on the events module and listens for events:@Service public class NotificationService { @EventListener public void handleUserCreated(UserCreatedEvent event) { // Send welcome email logic } }Modules here only depend on the shared event contract—you can swap out either module as long as it adheres to the event format.
Key Takeaway
All these approaches center on programming to abstractions, not implementations. By separating your API/contracts from concrete code, you make it trivial to swap modules without rewriting consumer logic—exactly what you're looking for to avoid the limitations of @Import.
内容的提问来源于stack exchange,提问作者Thai

