单体 vs 微服务 vs 模块化单体:用 Spring Modulith 打破单体的局限性

12333社保查询网www.sz12333.net.cn 2026-02-12来源:人力资源和社会保障局

单体架构与微服务的基本定义

  单体架构(Monolith):一种架构模式,应用的所有组件最终作为一个独立的物理部署单元发布与运行。

  微服务架构(Microservices):将应用拆分为多个小型、独立的服务。每个服务在自己的进程中运行,通过轻量协议通信,并且可独立开发、部署、扩展。

什么是模块化单体架构?

  在项目初期,团队往往仍在学习领域知识、梳理边界,也在摸索技术栈与交付方式。如果一开始就上微服务,通常会过早引入分布式复杂度(部署、链路、容错、数据一致性、可观测性等),反而拖慢交付。

  更稳妥的路径是:先从单体开始,尤其是模块化单体(Modular Monolith)——依然是一个部署单元,但代码层面按模块组织,边界清晰、依赖可控。

  模块化单体可以理解为:单体的简单性 + 微服务的边界感与可演进性。如果我们在现有单体应用中引入领域驱动设计(DDD)的思想,把系统按领域/子域拆成清晰模块,并限制模块间耦合,就能逐步重构出“领域驱动的模块化单体”。

  模块化单体允许你在统一代码库中协作开发,同时拥有清晰的边界与独立模块(通过把相关功能聚合在一起)。这种结构既能保持模块自治,也能在未来需要时,更平滑地演进到微服务架构。

Spring Modulith 是什么?

  Spring Modulith 是一个 Spring 项目,用来帮助实现模块化单体应用。它把“模块边界”变成可验证、可测试、可文档化的约束,而不是只靠团队约定。

  在 Spring Modulith 里:

  • 应用模块(Application Module) 是一个功能单元(通常对应一个领域/一个“域模块”)
  • 模块会对外暴露少量 API
  • 模块内部实现默认不应被其他模块直接访问为什么传统“按层分包”容易失控?

      单体项目文件夹结构示例如下所示:

      单体项目文件夹结构示例

      很多单体项目的包结构是典型的 按层分包(package-by-layer):

  • controller 在一个包
  • service 在另一个包
  • repository 在另一个包
  • ……

      这种结构有一些常见问题:

  • 结构不等于业务内容:目录看不出系统“做什么”
  • service 容易开始依赖不相关的 repository
  • 公共类被到处引用,产生意外的跨模块耦合
  • 模块边界越来越模糊,代码逐渐“意大利面”
  • 改一个方法可能影响系统里看似无关的部分

      相对地,模块化单体更推崇 按功能分包(package-by-feature):在应用主包下,直接用业务模块作为一级子包(如 notes/ users/ common),把相关的 Controller/Service/Repository/Domain 收拢在同一个模块里。

    模块化单体鼓励的原则

      以下子文件夹是模块化单体项目中应用程序主包的直接子文件夹,它们按照按功能打包的结构进行组织:

      项目文件夹结构

      项目文件夹结构 — JetBrains

      采用模块化单体架构后,理想状态是:只能调用对方的公共 API,模块内部组件默认隐藏,只能由本模块的 API/实现使用。

      它鼓励:

  • 清晰的模块边界
  • 内部组件默认隐藏
  • 每个模块提供小而明确的公共 API
  • 不同模块由不同团队负责时,也能保持一定自治与隔离Spring Modulith 如何“强制”这些规则?

      Spring Modulith 的模块模型验证和底层分析基于 ArchUnit 实现。它可以:

  • 拒绝模块之间的循环依赖
  • 阻止对“内部类型”的访问
  • 可选:通过 @ApplicationModule(allowedDependencies = …) 启用依赖白名单(只允许依赖显式列出的模块/接口)allowedDependencies:依赖白名单(白名单约束)

      allowedDependencies 用来限制模块只能依赖一组指定模块或接口。如果包中的代码尝试从其他未列入白名单的模块导入类型,在验证阶段会被标记为违规。

      如果你不指定 allowedDependencies:

  • Spring Modulith 不会阻止你的代码
  • 你仍可导入其他模块的公共类
  • 编译和运行都正常
  • 但当你运行 @ApplicationModuleTest 或 ./mvnw modulith:verify 时,可能会给出警告或测试失败(取决于你启用验证的严格程度)

      重要提示:访问权限主要取决于 @NamedInterface,而不是 allowedDependencies。allowedDependencies 更像“我允许依赖谁”;@NamedInterface 决定“我对外暴露什么”。

    Spring Modulith 提供的能力总结
  • 架构规则自动化执行
  • 清晰的模块边界
  • 模块交互的编译时/运行时验证
  • 支持模块间事件驱动通信
  • 模块级测试
  • 模块依赖文档生成IntelliJ 支持(补充)

      如果你使用 IntelliJ IDEA,Spring Modulith 插件在 Ultimate 版本通常默认可用;Community 版本一般不可用(可自行搜索是否可安装)。启用后,IDE 会用“小锁”提示模块/内部组件的可见性:

  • 绿色开锁:顶层模块
  • 红色闭锁:内部组件关键概念速记
  • 顶级模块:根应用包的直接子包
  • 嵌套模块:显式标记为模块的子包
  • 提供的接口(公共 API):模块对外可见的 API(建议收敛为门面)
  • 内部组件:嵌套包内的内容(默认不对外)
  • 命名接口(Named Interface):显式公开的包/类
  • 开放模块(Open Module):公开所有类型(包括内部类型)的模块(仅影响可见性)演示:用 Spring Modulith 做一个 Notes 应用1)添加依赖

      使用 Maven,在 pom.xml 中添加:

       org.springframework.modulith spring-modulith-starter-core

      我创建一个笔记应用,在根应用包下添加三个模块文件夹: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 只影响“可见性”,不影响依赖白名单。

  • 如果你启用了 allowedDependencies,仍必须把 common 写进允许列表
  • 如果没有声明 allowedDependencies,则无需额外配置(默认是宽松依赖规则)3)如何公开模块 API:@NamedInterface(类级 / 包级)

      你可以用两种方式公开公共 API:

    1. 类级别:直接把 @NamedInterface 标在某个门面类上
    2. 包级别:在某个包的 package-info.java 上加 @NamedInterface,把该包作为公开 API

      除了直接公开模型、服务等,更推荐的方式是:在模块根目录创建一个公共 API 门面(Facade),刻意只暴露少量方法。Spring Modulith 更鼓励“单一公共入口点”,而不是把 service/repository/domain 直接暴露出去。

      门面类通常会这样标记:

      @NamedInterface("api")4)一个常见坑:JPA 双向关系导致模块依赖成环

      在 Notes 场景里:一条 Note 有一个 owner User,一个 User 可以有多条 Note。很多人会用 JPA 双向关系:

  • User -> List
  • Note -> User

      这不一定会带来传统意义上的编译/运行时循环依赖,但在 Spring Modulith 的模块分析里,它会形成模块依赖环,提示类似:

      Cycle detected: notes → users → notes

      模块应该以单方向依赖另一模块,而不应双向依赖。

      常见解决方案:

  • 如果 User 与 Note 属于同一 bounded context:放同一模块
  • 如果确实要分开:避免对象级双向引用,改用 ID 关联
  • Note 存 userId
  • User 不再持有 List5)示例:notes 依赖 users,并通过 Users 的门面按 ID 获取用户

      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 Optional getById(Long userId) { return usersService.findById(userId).map(userMapper::toDto); }}6)关于嵌套模块(Nested Modules)的重要说明

  • Spring Modulith 不会自动把子包当成独立模块
  • 如果需要嵌套模块,必须通过 package-info.java 或 @ApplicationModule 显式标记模块根
  • 定义嵌套模块后,allowedDependencies 里也要使用正确的 module-id :: named-interface 标识符,否则兄弟模块可能无法互相访问

      同时需要注意:嵌套模块过深可能会干扰 Spring 的组件扫描启发式规则;在一些情况下,package-info.java 的结构也会影响 Modulith 的可见性规则。实践上,Modulith 更适合扁平、清晰、浅层的模块边界,而不是过深嵌套。

    模块间交互:直接依赖 Spring Bean 或用事件

      模块间交互通常有两条路:

    1. 依赖对方模块暴露的 Spring Bean(例如门面 API)
    2. 使用事件进行通信

      Spring Modulith 鼓励尽可能使用 Spring Framework 的应用事件来保持模块解耦。

    @ApplicationModuleListener:异步 + 新事务 + 事务事件监听的组合

      对于异步事件处理,@ApplicationModuleListener 相当于组合了:

  • @Async
  • @Transactional
  • @TransactionalEventListener

      它的目标是:在不破坏原事务边界的前提下,让事件处理异步执行并在新事务中完成。

    持久化事件(内置 Outbox):避免事件丢失

      为了避免事件丢失,可以加入 JDBC 持久化支持:

       org.springframework.modulith spring-modulith-starter-jdbc

      引入后,Spring Modulith 会自动:

  • 将事件保存到数据库表中
  • 监听器成功处理后标记为已完成
  • 重启后可重新发布未完成事件

      带来的效果是:

  • 应用崩溃:事件不丢
  • 监听器异常:事件保持挂起
  • Modulith 定期重试投递

      这相当于在应用内部获得一套“发件箱机制”,不依赖外部消息系统也能做到更可靠的事件交付。

    PostgreSQL 依赖(运行时)

       org.postgresql postgresql runtime

      由于我们没有直接引用 PostgreSQL 驱动程序类,而是依赖于 Spring Data JPA 和 Hibernate,因此Postgresql 依赖项“作用域”将是“运行时”。

    发布事件到 RabbitMQ(AMQP 外部化)

      spring-modulith-events-amqp 是 Spring Modulith 事件系统的 AMQP(RabbitMQ)集成模块:

       org.springframework.modulith spring-modulith-events-amqp runtime

      (如果用 Kafka,可选 spring-modulith-events-kafka。)

      当你把 events-amqp 与 starter-jdbc 结合使用时,通常意味着:

  • 事件先落库(Outbox)
  • Modulith 可靠地把事件发布到 RabbitMQ
  • RabbitMQ 宕机:稍后重试ExternalEventSource / ExternalEventPublisher
  • ExternalEventSource:发出“需要对外发布”的领域事件的模块
  • ExternalEventPublisher:负责把事件传递到应用外部的策略接口
  • 添加 AMQP/Kafka 等集成后,Modulith 会自动将它们衔接起来

      你可以通过注解外部化事件:

      @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 可以:

  • 仅加载当前模块
  • mock 其他模块依赖
  • 验证外发事件
  • 使用 Scenario API 测试事件驱动行为(比 sleep 更可靠)

      验证事件发布:

      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 listAppender = new ListAppender<>(); listAppender.start(); logger.addAppender(listAppender); var event = new NoteCreatedEvent(999L, "Scenario Test Note"); // Act: publish and wait until the log appears (use Scenario to avoid sleeps) scenario.publish(event).andWaitForStateChange(() -> listAppender.list.stream().anyMatch(e -> e.getFormattedMessage().contains("Note created -> id=999") && e.getFormattedMessage().contains("Scenario Test Note")) ).andVerify(result -> assertThat(result).isTrue()); // Cleanup logger.detachAppender(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。

    小结

      模块化单体的关键不在于“把包拆得更细”,而在于:

  • 用清晰的模块边界组织业务能力
  • 收敛公共 API,隐藏内部实现
  • 用验证、测试与文档,把架构规则固化成工程手段
  • 通过事件驱动降低模块耦合,并用持久化事件实现更可靠投递
  • 为未来向微服务演进保留空间,但不过早引入分布式复杂度

       如果这篇内容对你有帮助,欢迎点赞 、收藏 、转发给需要的朋友

      我会持续分享:

  • Java 核心与高阶实战
  • AI / Agent / 前沿技术落地
  • 真实项目经验 & 架构思考
  • 企业数字化与产品实践

       关注我,一起把“技术”真正用在项目和业务里。

       你的每一次支持,都是我持续输出高质量内容的最大动力。

    本文标题:单体 vs 微服务 vs 模块化单体:用 Spring Modulith 打破单体的局限性本文网址:https://www.sz12333.net.cn/zhzx/kexue/53796.html 编辑:12333社保查询网
  • 本站是社保查询公益性网站链接,数据来自各地人力资源和社会保障局,具体内容以官网为准。
    定期更新查询链接数据 苏ICP备17010502号-11