单体架构与微服务的基本定义
单体架构(Monolith):一种架构模式,应用的所有组件最终作为一个独立的物理部署单元发布与运行。
微服务架构(Microservices):将应用拆分为多个小型、独立的服务。每个服务在自己的进程中运行,通过轻量协议通信,并且可独立开发、部署、扩展。
什么是模块化单体架构?在项目初期,团队往往仍在学习领域知识、梳理边界,也在摸索技术栈与交付方式。如果一开始就上微服务,通常会过早引入分布式复杂度(部署、链路、容错、数据一致性、可观测性等),反而拖慢交付。
更稳妥的路径是:先从单体开始,尤其是模块化单体(Modular Monolith)——依然是一个部署单元,但代码层面按模块组织,边界清晰、依赖可控。
模块化单体可以理解为:单体的简单性 + 微服务的边界感与可演进性。如果我们在现有单体应用中引入领域驱动设计(DDD)的思想,把系统按领域/子域拆成清晰模块,并限制模块间耦合,就能逐步重构出“领域驱动的模块化单体”。
模块化单体允许你在统一代码库中协作开发,同时拥有清晰的边界与独立模块(通过把相关功能聚合在一起)。这种结构既能保持模块自治,也能在未来需要时,更平滑地演进到微服务架构。
Spring Modulith 是什么?Spring Modulith 是一个 Spring 项目,用来帮助实现模块化单体应用。它把“模块边界”变成可验证、可测试、可文档化的约束,而不是只靠团队约定。
在 Spring Modulith 里:
单体项目文件夹结构示例如下所示:
单体项目文件夹结构示例
很多单体项目的包结构是典型的 按层分包(package-by-layer):
这种结构有一些常见问题:
相对地,模块化单体更推崇 按功能分包(package-by-feature):在应用主包下,直接用业务模块作为一级子包(如 notes/ users/ common),把相关的 Controller/Service/Repository/Domain 收拢在同一个模块里。
模块化单体鼓励的原则以下子文件夹是模块化单体项目中应用程序主包的直接子文件夹,它们按照按功能打包的结构进行组织:
项目文件夹结构
项目文件夹结构 — JetBrains
采用模块化单体架构后,理想状态是:只能调用对方的公共 API,模块内部组件默认隐藏,只能由本模块的 API/实现使用。
它鼓励:
Spring Modulith 的模块模型验证和底层分析基于 ArchUnit 实现。它可以:
allowedDependencies 用来限制模块只能依赖一组指定模块或接口。如果包中的代码尝试从其他未列入白名单的模块导入类型,在验证阶段会被标记为违规。
如果你不指定 allowedDependencies:
重要提示:访问权限主要取决于 @NamedInterface,而不是 allowedDependencies。allowedDependencies 更像“我允许依赖谁”;@NamedInterface 决定“我对外暴露什么”。
Spring Modulith 提供的能力总结如果你使用 IntelliJ IDEA,Spring Modulith 插件在 Ultimate 版本通常默认可用;Community 版本一般不可用(可自行搜索是否可安装)。启用后,IDE 会用“小锁”提示模块/内部组件的可见性:
使用 Maven,在 pom.xml 中添加:
我创建一个笔记应用,在根应用包下添加三个模块文件夹:notes、users、common(后续还会加 notifications)。
演示笔记应用程序 — IntelliJ 中的文件夹结构
2)把 common 标记为 Open Module(共享模块)在 common 模块的 package-info.java 中声明:
@ApplicationModule(type = ApplicationModule.Type.OPEN)package dev.nils.notes_modulith.common;import org.springframework.modulith.ApplicationModule;
这样 common 模块内的类型对其他模块可见。
但要注意:Open 只影响“可见性”,不影响依赖白名单。
你可以用两种方式公开公共 API:
除了直接公开模型、服务等,更推荐的方式是:在模块根目录创建一个公共 API 门面(Facade),刻意只暴露少量方法。Spring Modulith 更鼓励“单一公共入口点”,而不是把 service/repository/domain 直接暴露出去。
门面类通常会这样标记:
@NamedInterface("api")4)一个常见坑:JPA 双向关系导致模块依赖成环
在 Notes 场景里:一条 Note 有一个 owner User,一个 User 可以有多条 Note。很多人会用 JPA 双向关系:
这不一定会带来传统意义上的编译/运行时循环依赖,但在 Spring Modulith 的模块分析里,它会形成模块依赖环,提示类似:
Cycle detected: notes → users → notes
模块应该以单方向依赖另一模块,而不应双向依赖。
常见解决方案:
notes 模块 package-info.java(示意):
@ApplicationModule(id="notes", displayName = "Notes Module", allowedDependencies = {"users"})package dev.nils.notes_modulith.notes;import org.springframework.modulith.ApplicationModule;
users 模块提供一个公共 API 门面(UserApi),让 notes 只依赖公开入口点:
package dev.nils.notes_modulith.users;import dev.nils.notes_modulith.users.domain.UsersService;import dev.nils.notes_modulith.users.mappers.UserMapper;import dev.nils.notes_modulith.users.web.UserDto;import lombok.RequiredArgsConstructor;import org.springframework.modulith.NamedInterface;import org.springframework.stereotype.Component;import java.util.Optional;@RequiredArgsConstructor@NamedInterface("api")@Componentpublic class UserApi { private final UsersService usersService; private final UserMapper userMapper; public Optional6)关于嵌套模块(Nested Modules)的重要说明
同时需要注意:嵌套模块过深可能会干扰 Spring 的组件扫描启发式规则;在一些情况下,package-info.java 的结构也会影响 Modulith 的可见性规则。实践上,Modulith 更适合扁平、清晰、浅层的模块边界,而不是过深嵌套。
模块间交互:直接依赖 Spring Bean 或用事件模块间交互通常有两条路:
Spring Modulith 鼓励尽可能使用 Spring Framework 的应用事件来保持模块解耦。
@ApplicationModuleListener:异步 + 新事务 + 事务事件监听的组合对于异步事件处理,@ApplicationModuleListener 相当于组合了:
它的目标是:在不破坏原事务边界的前提下,让事件处理异步执行并在新事务中完成。
持久化事件(内置 Outbox):避免事件丢失为了避免事件丢失,可以加入 JDBC 持久化支持:
引入后,Spring Modulith 会自动:
带来的效果是:
这相当于在应用内部获得一套“发件箱机制”,不依赖外部消息系统也能做到更可靠的事件交付。
PostgreSQL 依赖(运行时)
由于我们没有直接引用 PostgreSQL 驱动程序类,而是依赖于 Spring Data JPA 和 Hibernate,因此Postgresql 依赖项“作用域”将是“运行时”。
发布事件到 RabbitMQ(AMQP 外部化)spring-modulith-events-amqp 是 Spring Modulith 事件系统的 AMQP(RabbitMQ)集成模块:
(如果用 Kafka,可选 spring-modulith-events-kafka。)
当你把 events-amqp 与 starter-jdbc 结合使用时,通常意味着:
你可以通过注解外部化事件:
@Externalized("exchange::routing-key")
Modulith 会自动完成事件序列化、发布,并与发布登记保持一致性。
你也可以自定义 ExternalEventPublisher,例如发布到 webhook:
@Componentclass WebhookEventPublisher implements ExternalEventPublisher { private final WebClient client = WebClient.create(); @Override public void publish(SerializedApplicationEvent event) { client.post() .uri("https://api.mycompany.com/events") .bodyValue(event.getSerializedPayload()) .retrieve() .bodyToMono(Void.class) .block(); }}示例:NoteCreatedEvent 外部化 + 路由配置
事件定义:
@Externalized("NoteExchange::notes.new")public record NoteCreatedEvent(Long noteId, String title) {}
说明:@Externalized 不支持直接从 application.properties 读取占位符/SpEL。如果你想动态配置路由信息,可以通过 EventExternalizationConfiguration 用代码来做路由。
配置示例:
@Configurationpublic class NotesEventRoutingConfig { @Autowired private RabbitMQProperties rabbitMQProperties; @Bean public EventExternalizationConfiguration externalizationConfig() { return EventExternalizationConfiguration.externalizing() .select(EventExternalizationConfiguration.annotatedAsExternalized()) .route( NoteCreatedEvent.class, evt -> RoutingTarget.forTarget(rabbitMQProperties.getExchange()) .andKey(rabbitMQProperties.getRoutingKey()) ) .build(); }}
也可以把事件简化为仅标注 @Externalized,路由完全交给配置处理:
@Externalizedpublic record NoteCreatedEvent(Long noteId, String title) {}在 NoteService 创建笔记时发布事件
@Slf4j@RequiredArgsConstructor@Servicepublic class NoteService { private final NoteRepository noteRepository; private final UserApi userApi; private final NoteMapper noteMapper; private final ApplicationEventPublisher eventPublisher; ... public NoteDto create(NoteCreateRequest req) { UserDto user; try { user = userApi.getById(req.userId()); } catch (Exception e) { log.error("[NOTES_SERVICE][CREATE][FAILED]: User not found with id={}", req.userId()); throw new NoteException(NoteErrorCode.NOTE_CREATION_FAILED_USER_NOT_FOUND_WITH_ID, req.userId()); } Note note = noteMapper.fromCreateRequest(req, user.id()); Note savedNote = noteRepository.save(note); log.info("[NOTES_SERVICE][CREATE]: Created a note with id={}", savedNote.getId()); NoteCreatedEvent event = new NoteCreatedEvent(savedNote.getId(), savedNote.getTitle()); eventPublisher.publishEvent(event); return noteMapper.toDto(savedNote); } ...}notifications 模块:事件监听示例
@Slf4j@Componentpublic class NoteEventNotificationHandler { @ApplicationModuleListener void handleEvent(NoteCreatedEvent event) { log.info("[NOTE_EVENT_NOTIFICATION_HANDLER][HANDLE_EVENT]: Note created -> id={}, title={}", event.noteId(), event.title()); }}测试:结构验证 + 模块级测试 + 场景测试1)全局模块结构验证
class ModulithStructureTest { static ApplicationModules modules = ApplicationModules.of(NotesModulithApplication.class); @Test void verifiesModularStructure() { modules.verify(); }}
它会扫描所有模块及 package-info.java 声明,确保模块遵守边界、依赖只指向允许的公共 API。
2)模块级测试:@ApplicationModuleTest使用 @ApplicationModuleTest 可以:
验证事件发布:
assertThat(events) .contains(OrderCreatedEvent.class) .matching(e -> e.customer().email(), "customer@test.com") .matching(OrderCreatedEvent::productCode, "P1");
验证事件处理结果(Scenario 示例):
@Testvoid handleOrderCreatedEvent(Scenario scenario) { var customer = new Customer("TestCustomer", "customer@test.com", "99887766"); String productCode = "P99"; var event = new OrderCreatedEvent(UUID.randomUUID().toString(), productCode, 2, customer); var stockLevelChange = scenario.publish(event).andWaitForStateChange(() -> inventoryService.getStockLevel(productCode) == 598); stockLevelChange.andVerify(result -> assertThat(result).isTrue());}3)Notes 项目:一个更贴近实战的集成测试写法
我更倾向用一个单独测试类专门放“事件集成链路”的测试:
@ApplicationModuleTest( extraIncludes = {"common", "users", "notifications"})@Import({TestContainersConfiguration.class})class NoteEventsIntegrationTest { @Autowired NoteService noteService; @Autowired UserRepository userRepository; @Test @DisplayName("create() should publish NoteCreatedEvent with expected fields") void create_shouldPublishNoteCreatedEvent(AssertablePublishedEvents events) { Long existingUserId = userRepository.findByEmail("jane.doe@example.com").orElseThrow().getId(); String title = "Event Test Note"; String content = "Verifying Spring Modulith event publishing."; noteService.create(new NoteCreateRequest(existingUserId, title, content)); events.assertThat() .contains(NoteCreatedEvent.class) .matching(NoteCreatedEvent::title, title) .matching(e -> e.noteId() != null, true); } @Test @DisplayName("Scenario: NoteCreatedEvent is handled by listener") void handleNoteCreatedEvent_logsExpectedMessage(Scenario scenario) { // Arrange a test ListAppender on the specific logger used by the handler Logger logger = (Logger) LoggerFactory.getLogger("dev.nils.notes_modulith.notifications.NoteEventNotificationHandler"); ListAppender
提醒:测试必须通过 extraIncludes 明确包含依赖模块,即使这些模块没有通过 @NamedInterface 暴露,也没有列在 allowedDependencies 中。
自动生成架构文档(C4 / UML / PlantUML)Spring Modulith 支持生成:
示例:
class ModulithStructureTest { static ApplicationModules modules = ApplicationModules.of(NotesModulithApplication.class); ... @Test void createModuleDocumentation() { new Documenter(modules).writeDocumentation(); }}
运行后会在 target/spring-modulith-docs/ 目录生成 .adoc 与 .puml 文件;如果要生成 png/svg,可以配置 PlantUML 渲染器或使用 IDE 插件渲染。
您也可以查看SivaLabs 的Spring Modulith Workshop 讲座——将单体应用转换为模块化单体应用。您也可以通过此链接查看该讲座中开发的项目。他的“ spring-modular-modulith ”项目也已更新,使用了 Spring Boot 4 和 Spring Modulith 2。
小结模块化单体的关键不在于“把包拆得更细”,而在于:
如果这篇内容对你有帮助,欢迎点赞 、收藏 、转发给需要的朋友
我会持续分享:
关注我,一起把“技术”真正用在项目和业务里。
你的每一次支持,都是我持续输出高质量内容的最大动力。
本站是社保查询公益性网站链接,数据来自各地人力资源和社会保障局,具体内容以官网为准。
定期更新查询链接数据 苏ICP备17010502号-11