欢迎你来读这篇博客,这篇博客主要是关于 Spring Modulith 的深度介绍与工程实战。
如果你正在维护一个越来越大的 Spring Boot 单体项目,或者你想做 DDD、COLA、模块化单体、未来可拆微服务的架构演进,那么 Spring Modulith 非常值得认真研究。
序言 很多团队在系统变复杂之后,第一反应是:
单体太大了,要不要拆微服务?
但现实往往很残酷:
单体内部边界都没有拆清楚;
业务模块之间互相调用,Controller 调 Repository,Service 互相注入;
一个订单逻辑里顺手调用结算、库存、支付、会员、消息、优惠券;
代码层面没有边界,数据库层面没有边界,团队协作层面也没有边界;
最后强行拆微服务,得到的不是微服务,而是“分布式单体”。
微服务不是架构腐化的解药。边界不清晰,拆出去只会让问题从本地方法调用升级成网络调用、消息调用、分布式事务和链路追踪。坏消息是:坑会变多。好消息是:锅也会变分布式,大家一起背,特别公平。
Spring Modulith 的核心价值就在这里:
在不立刻拆微服务的情况下,先把一个 Spring Boot 单体应用治理成模块边界清晰、依赖关系可验证、模块交互可观测、未来可平滑拆分的模块化单体。
本文会围绕一个 finance-modulith-demo 财务系统案例展开,重点不是“会用几个注解”,而是讲清楚:
Spring Modulith 解决的真实问题是什么;
它和 ArchUnit、DDD、微服务有什么关系;
如何设计包结构和模块边界;
如何禁止模块访问彼此的 internal 实现;
如何用事件替代跨模块 Service 注入;
如何做模块级集成测试;
如何生成架构文档;
如何启用事件发布表、失败重试和生产观测;
老项目如何渐进式迁移到 Spring Modulith。
本文基于 Spring Boot 3.x 与 Spring Modulith 2.1.0 编写。Spring Modulith 官方文档将其定义为一个用于构建领域驱动、模块化 Spring Boot 应用的工具套件。
正文 一、Spring Modulith 是什么? Spring Modulith 是 Spring 官方生态中的模块化单体架构工具。
它不是微服务框架,也不是 RPC 框架,更不是 Nacos、Dubbo、Spring Cloud 的替代品。它关注的问题更靠近代码架构本身:
一个 Spring Boot 应用内部,到底有哪些业务模块?这些模块之间能不能乱依赖?哪些类型是模块对外 API?哪些实现必须只在模块内部使用?模块之间的事件调用能不能被测试、记录和观测?
Spring Modulith 主要提供以下能力:
能力
说明
模块识别
根据包结构识别 application module
边界验证
检查模块之间是否存在非法依赖、循环依赖、访问 internal 包等问题
显式依赖声明
通过 @ApplicationModule(allowedDependencies = ...) 声明模块允许依赖谁
Named Interface
让模块额外暴露特定 API 包,例如 spi、api
模块级测试
通过 @ApplicationModuleTest 只启动某个模块及其依赖
事件驱动集成
通过 ApplicationEventPublisher 和 @ApplicationModuleListener 解耦模块
事件发布注册表
持久化事件发布记录,支持失败重试、补偿、清理
文档生成
生成模块依赖图、C4/UML 组件图、Application Module Canvas
生产观测
提供 actuator 端点、模块级 metrics/traces
运行时初始化
按模块依赖拓扑顺序执行初始化逻辑
一句话总结:
Spring Modulith 让你的 Spring Boot 单体应用“像微服务一样有边界”,但又不需要承担微服务的分布式复杂度。
二、为什么需要模块化单体? 2.1 普通单体的问题 一个典型的传统 Spring Boot 项目,早期可能是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 com.example.finance ├── controller │ ├── OrderController.java │ ├── SettlementController.java │ └── PaymentController.java ├── service │ ├── OrderService.java │ ├── SettlementService.java │ └── PaymentService.java ├── repository │ ├── OrderRepository.java │ ├── SettlementRepository.java │ └── PaymentRepository.java ├── entity ├── dto └── config
这叫按技术分层组织代码。
它不是完全错误,小项目这么写没问题。但项目一大,业务会变成这样:
graph LR
OrderService --> SettlementService
OrderService --> PaymentService
OrderService --> InventoryService
SettlementService --> OrderRepository
SettlementService --> PaymentRepository
PaymentService --> OrderService
InventoryService --> SettlementService
Controller --> Repository
这种结构有几个典型问题:
业务边界消失 :订单、支付、结算、库存都只是 service 包里的几个类。
依赖关系不可控 :A 可以调用 B,B 也可以调用 A,循环依赖迟早出现。
内部实现暴露 :一个模块的 Repository、Entity、内部 Service 被其他模块随便注入。
测试困难 :测试订单逻辑时被迫启动支付、结算、库存等一大坨 Bean。
微服务拆分困难 :没有清晰边界,根本不知道应该按什么拆。
技术分层解决的是代码职责问题,但没有解决业务边界问题。
2.2 模块化单体的核心思想 模块化单体不是不要分层,而是把“业务模块”放到一等公民的位置。
例如财务系统可以这么组织:
1 2 3 4 5 6 7 com.example.finance ├── FinanceApplication.java ├── order ├── settlement ├── payment ├── customer └── shared
每个业务模块内部再自己分层:
1 2 3 4 5 6 7 8 order ├── OrderApi.java # 对外 API ├── OrderSubmittedEvent.java # 对外事件 └── internal # 内部实现 ├── OrderService.java ├── OrderEntity.java ├── OrderRepository.java └── OrderMapper.java
也就是说:
推荐结构如下:
graph TB
App[finance-modulith-demo]
App --> Order[order 模块]
App --> Settlement[settlement 模块]
App --> Payment[payment 模块]
App --> Customer[customer 模块]
App --> Shared[shared 模块]
Order --> OrderAPI[对外 API / Event]
Order --> OrderInternal[internal 内部实现]
Settlement --> SettlementAPI[对外 API / Event]
Settlement --> SettlementInternal[internal 内部实现]
Payment --> PaymentAPI[对外 API / Event]
Payment --> PaymentInternal[internal 内部实现]
模块化单体的目标是:
每个模块拥有自己的领域逻辑;
每个模块只暴露有限 API;
其他模块不能访问 internal;
模块依赖方向清晰;
模块之间尽量通过事件交互;
可以单独测试某个模块;
未来可以按模块拆微服务。
三、Spring Modulith 和 ArchUnit 的关系 你可以把 ArchUnit 看成“通用架构规则测试工具”,而 Spring Modulith 是“Spring Boot 模块化单体治理工具”。
对比项
ArchUnit
Spring Modulith
定位
通用 Java 架构测试
Spring Boot 模块化单体工具套件
核心对象
类、包、依赖规则
Application Module
规则定义
需要自己写规则
内置模块边界验证
是否依赖 Spring
不依赖
面向 Spring Boot
模块测试
需要自己组织
提供 @ApplicationModuleTest
文档生成
需要自己扩展
提供 Documenter
事件发布治理
不提供
提供事件发布注册表、重试、外部化
生产观测
不提供
actuator、metrics、traces
二者不是替代关系,而是互补关系。
实际项目里可以这样组合:
1 2 Spring Modulith:治理业务模块边界 ArchUnit:补充团队自定义规则
比如 Spring Modulith 负责检查:
1 2 3 settlement 不能访问 order.internal 模块之间不能循环依赖 模块只能依赖 allowedDependencies 声明的模块
ArchUnit 负责补充:
1 2 3 4 Controller 不能直接访问 Repository Domain 层不能依赖 Spring Web Entity 不能出现在 Controller 参数里 Mapper 只能位于 infrastructure/internal 包下
这套组合非常适合中大型 Spring Boot 项目。一个管业务模块边界,一个管细粒度代码规则。左右护法,别让架构在需求迭代里原地融化。
四、Spring Modulith 的核心概念 4.1 Application Module Application Module 是 Spring Modulith 的核心概念。
在 Spring Boot 应用中,默认情况下,主启动类所在包的直接子包会被识别为应用模块。
例如:
1 2 3 4 5 6 com.example.finance ├── FinanceApplication.java ├── order ├── settlement ├── payment └── customer
这里的 order、settlement、payment、customer 都会被识别成应用模块。
一个模块通常包含三部分:
部分
说明
Provided Interface
模块对外暴露的 API、事件、配置属性
Internal Implementation
模块内部实现,不允许被其他模块访问
Required Interface
当前模块依赖其他模块暴露出来的 API、事件、配置
4.2 API 包和 internal 包 在高级模块结构中,模块根包下的 public 类型默认是模块 API,子包默认视为 internal。
例如:
1 2 3 4 5 6 7 order ├── OrderApi.java # 可以被其他模块访问 ├── OrderSubmittedEvent.java # 可以被其他模块监听 └── internal ├── OrderService.java # 不能被其他模块访问 ├── OrderRepository.java # 不能被其他模块访问 └── OrderEntity.java # 不能被其他模块访问
其他模块允许依赖:
1 2 com.example.finance.order.OrderApi com.example.finance.order.OrderSubmittedEvent
其他模块不允许依赖:
1 2 3 com.example.finance.order.internal.OrderService com.example.finance.order.internal.OrderRepository com.example.finance.order.internal.OrderEntity
如果有人在 settlement 模块中直接注入 OrderRepository:
1 2 3 4 5 6 7 8 9 package com.example.finance.settlement.internal;import com.example.finance.order.internal.OrderRepository;@Service public class SettlementService { private final OrderRepository orderRepository; }
Spring Modulith 的验证测试会失败。
4.3 @ApplicationModule @ApplicationModule 用来显式声明模块元信息,通常写在模块根包的 package-info.java 中。
例如:
1 2 3 4 @org .springframework.modulith.ApplicationModule( allowedDependencies = {"order" , "payment" } )package com.example.finance.settlement;
这表示:
1 settlement 模块只允许依赖 order 和 payment 模块暴露出来的 API。
如果 settlement 依赖 customer 模块,测试会失败。
4.4 Named Interface 默认情况下,只有模块根包是对外 API。如果你想额外暴露一个子包,例如 order.spi,需要使用 @NamedInterface。
目录结构:
1 2 3 4 5 6 order ├── OrderApi.java ├── spi │ ├── package-info.java │ └── OrderQueryPort.java └── internal
order/spi/package-info.java:
1 2 @org .springframework.modulith.NamedInterface("spi" )package com.example.finance.order.spi;
其他模块可以精确声明依赖这个 named interface:
1 2 3 4 @org .springframework.modulith.ApplicationModule( allowedDependencies = "order :: spi" )package com.example.finance.settlement;
这表示 settlement 只允许依赖 order 模块的 spi 命名接口,而不是整个 order API。
这在复杂系统里很重要。因为“能访问 order API”和“只能访问 order 的查询 SPI”不是一回事。权限开大了,迟早有人顺手用了不该用的接口。
4.5 Open Module 老项目迁移时,可能一上来就严格关闭模块边界会炸一片。这时可以用开放模块:
1 2 3 4 @org .springframework.modulith.ApplicationModule( type = org.springframework.modulith.ApplicationModule.Type.OPEN )package com.example.finance.order;
Open Module 会放宽 internal 访问限制,适合老项目渐进式迁移。
但注意:
Open Module 是迁移用的拐杖,不是新项目的最佳实践。
新项目建议默认使用 closed module 思路,API 放根包,内部实现放 internal。
五、实战案例:finance-modulith-demo 下面我们构建一个简化的财务系统。
业务模块如下:
模块
职责
order
订单创建、提交订单、发布订单事件
settlement
监听订单提交事件,生成待结算单
payment
处理付款、发布付款完成事件
customer
客户信息维护
shared
通用值对象、异常、工具类
模块交互规则:
1 2 3 4 5 6 order 不直接调用 settlement order 不直接调用 payment settlement 可以依赖 order 暴露的事件或查询 API payment 可以依赖 order 暴露的 API customer 不依赖业务模块 shared 可以被所有模块依赖
整体架构:
graph LR
Customer[customer]
Order[order]
Payment[payment]
Settlement[settlement]
Shared[shared]
Order --> Shared
Payment --> Shared
Settlement --> Shared
Customer --> Shared
Order -- publishes OrderSubmittedEvent --> Settlement
Order -- publishes OrderSubmittedEvent --> Payment
Payment -- publishes PaymentCompletedEvent --> Settlement
注意:上图里的箭头并不一定都是 Java Bean 依赖。事件驱动下,模块之间可以通过事件产生业务协作,但代码依赖仍然保持很轻。
六、创建项目与依赖配置 6.1 Maven 依赖 pom.xml 示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <parent > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-parent</artifactId > <version > 3.5.0</version > <relativePath /> </parent > <groupId > com.example</groupId > <artifactId > finance-modulith-demo</artifactId > <version > 1.0.0</version > <packaging > jar</packaging > <properties > <java.version > 21</java.version > <spring-modulith.version > 2.1.0</spring-modulith.version > </properties > <dependencyManagement > <dependencies > <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-bom</artifactId > <version > ${spring-modulith.version}</version > <scope > import</scope > <type > pom</type > </dependency > </dependencies > </dependencyManagement > <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-jpa</artifactId > </dependency > <dependency > <groupId > org.postgresql</groupId > <artifactId > postgresql</artifactId > <scope > runtime</scope > </dependency > <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-starter-jpa</artifactId > </dependency > <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-starter-insight</artifactId > <scope > runtime</scope > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-actuator</artifactId > </dependency > <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > <optional > true</optional > </dependency > <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-starter-test</artifactId > <scope > test</scope > </dependency > <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-docs</artifactId > <scope > test</scope > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > <scope > test</scope > </dependency > </dependencies > <build > <plugins > <plugin > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-maven-plugin</artifactId > </plugin > </plugins > </build > </project >
几点说明:
推荐使用 spring-modulith-bom 统一管理版本。
spring-modulith-starter-test 用于模块结构验证和模块级测试。
spring-modulith-docs 用于生成模块架构文档。
spring-modulith-starter-jpa 会启用基于 JPA 的事件发布注册表。
如果你项目使用 MyBatis-Plus,也可以选择 spring-modulith-starter-jdbc,让事件表走 JDBC,不依赖 JPA。
6.2 application.yml 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 spring: application: name: finance-modulith-demo datasource: url: jdbc:postgresql://localhost:5432/finance_modulith username: finance password: finance driver-class-name: org.postgresql.Driver jpa: hibernate: ddl-auto: update open-in-view: false properties: hibernate: format_sql: true modulith: events: completion-mode: UPDATE republish-outstanding-events-on-restart: true staleness: published: 10m processing: 10m resubmitted: 10m runtime: verification-enabled: false management: endpoints: web: exposure: include: health,info,metrics,modulith endpoint: health: show-details: always logging: level: org.springframework.modulith: DEBUG
七、定义应用入口 FinanceApplication.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 package com.example.finance;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.modulith.Modulithic;@Modulithic( systemName = "Finance Modulith Demo", sharedModules = "shared" ) @SpringBootApplication public class FinanceApplication { public static void main (String[] args) { SpringApplication.run(FinanceApplication.class, args); } }
@Modulithic 不是必须的,但建议加上。它可以表达这是一个模块化单体应用,并且可以配置:
属性
说明
systemName
生成文档时展示的系统名称
sharedModules
共享模块,模块级测试时默认包含
additionalPackages
额外扫描其他根包下的模块
这里我们把 shared 声明成共享模块,因为通用值对象、异常、工具类可能被所有模块使用。
八、设计项目目录结构 完整目录建议:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 src/main/java/com/example/finance ├── FinanceApplication.java ├── shared │ ├── Money.java │ ├── BusinessException.java │ └── package-info.java ├── order │ ├── OrderApi.java │ ├── OrderSubmittedEvent.java │ ├── OrderStatusChangedEvent.java │ ├── package-info.java │ └── internal │ ├── Order.java │ ├── OrderController.java │ ├── OrderJpaRepository.java │ ├── OrderService.java │ └── SubmitOrderCommand.java ├── payment │ ├── PaymentApi.java │ ├── PaymentCompletedEvent.java │ ├── package-info.java │ └── internal │ ├── Payment.java │ ├── PaymentJpaRepository.java │ ├── PaymentOrderListener.java │ └── PaymentService.java ├── settlement │ ├── SettlementApi.java │ ├── package-info.java │ └── internal │ ├── SettlementBill.java │ ├── SettlementJpaRepository.java │ ├── SettlementOrderListener.java │ └── SettlementService.java └── customer ├── CustomerApi.java ├── package-info.java └── internal ├── Customer.java ├── CustomerJpaRepository.java └── CustomerService.java
关键原则:
模块根包放对外 API 和事件。
模块内部实现全部放 internal。
Controller 是否放 internal?建议放。因为 Controller 是模块的入口适配器,不应该被其他模块当 API 依赖。
Repository、Entity、Mapper 必须放 internal。
其他模块如果需要能力,只能依赖根包 API 或事件。
很多人会问:Controller 是对外 HTTP API,为什么也放 internal?
因为这里的“对外”要区分两层:
1 2 HTTP 对外:给前端、外部系统访问 Java 模块对外:给其他模块依赖
Controller 可以暴露 HTTP 接口,但不应该暴露成 Java 模块 API。否则其他模块可能直接注入 Controller,这个画面太美,不建议观看。
九、定义模块元信息 9.1 shared 模块 shared/package-info.java:
1 2 @org .springframework.modulith.ApplicationModulepackage com.example.finance.shared;
shared 被 @Modulithic(sharedModules = "shared") 声明为共享模块,所以其他模块可以使用里面的基础类型。
Money.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 package com.example.finance.shared;import java.math.BigDecimal;import java.math.RoundingMode;public record Money (BigDecimal amount) { public Money { if (amount == null ) { throw new IllegalArgumentException ("amount must not be null" ); } amount = amount.setScale(2 , RoundingMode.HALF_UP); } public static Money of (String amount) { return new Money (new BigDecimal (amount)); } public Money add (Money other) { return new Money (this .amount.add(other.amount)); } }
9.2 order 模块 order/package-info.java:
1 2 3 4 @org .springframework.modulith.ApplicationModule( allowedDependencies = "shared" )package com.example.finance.order;
订单模块只依赖 shared,不直接依赖 settlement 和 payment。
这很重要。订单提交之后,支付和结算要做什么,不应该由订单模块硬编码调用。订单模块只发布事件。
9.3 payment 模块 payment/package-info.java:
1 2 3 4 @org .springframework.modulith.ApplicationModule( allowedDependencies = {"shared" , "order" } )package com.example.finance.payment;
payment 可以依赖 order 暴露的事件,比如 OrderSubmittedEvent。
9.4 settlement 模块 settlement/package-info.java:
1 2 3 4 @org .springframework.modulith.ApplicationModule( allowedDependencies = {"shared" , "order" , "payment" } )package com.example.finance.settlement;
settlement 可以监听订单提交事件和支付完成事件。
9.5 customer 模块 customer/package-info.java:
1 2 3 4 @org .springframework.modulith.ApplicationModule( allowedDependencies = "shared" )package com.example.finance.customer;
customer 模块保持独立,不反向依赖订单、支付、结算。
十、实现 order 模块 10.1 对外 API OrderApi.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package com.example.finance.order;import java.util.Optional;public interface OrderApi { Long submitOrder (SubmitOrderRequest request) ; Optional<OrderSummary> findSummary (Long orderId) ; record SubmitOrderRequest (Long customerId, String orderNo, String amount) { } record OrderSummary (Long id, Long customerId, String orderNo, String amount, String status) { } }
这里的 OrderApi 是其他模块可以依赖的 Java API。
注意:不要把 JPA Entity 作为 API 返回值。推荐用 record DTO 或查询模型。
10.2 对外事件 OrderSubmittedEvent.java:
1 2 3 4 5 6 7 8 9 10 11 12 package com.example.finance.order;import java.time.Instant;public record OrderSubmittedEvent ( Long orderId, Long customerId, String orderNo, String amount, Instant occurredAt ) { }
事件放在模块根包,因为其他模块需要监听它。
事件设计建议:
使用不可变对象,推荐 record。
事件字段尽量使用基础类型或值对象,不暴露 Entity。
带上 occurredAt。
不要把整个聚合根塞进事件里。
事件名用过去式:OrderSubmittedEvent、PaymentCompletedEvent。
10.3 内部 Entity internal/Order.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 package com.example.finance.order.internal;import jakarta.persistence.Column;import jakarta.persistence.Entity;import jakarta.persistence.GeneratedValue;import jakarta.persistence.GenerationType;import jakarta.persistence.Id;import jakarta.persistence.Table;import java.math.BigDecimal;@Entity @Table(name = "fin_order") class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private Long customerId; @Column(nullable = false, unique = true) private String orderNo; @Column(nullable = false, precision = 18, scale = 2) private BigDecimal amount; @Column(nullable = false) private String status; protected Order () { } Order(Long customerId, String orderNo, BigDecimal amount) { this .customerId = customerId; this .orderNo = orderNo; this .amount = amount; this .status = "CREATED" ; } void submit () { if (!"CREATED" .equals(status)) { throw new IllegalStateException ("Only CREATED order can be submitted." ); } this .status = "SUBMITTED" ; } Long getId () { return id; } Long getCustomerId () { return customerId; } String getOrderNo () { return orderNo; } BigDecimal getAmount () { return amount; } String getStatus () { return status; } }
这里我故意把类和方法做成 package-private 为主,减少内部实现被外部误用的机会。
10.4 内部 Repository internal/OrderJpaRepository.java:
1 2 3 4 5 6 7 8 9 10 package com.example.finance.order.internal;import org.springframework.data.jpa.repository.JpaRepository;import java.util.Optional;interface OrderJpaRepository extends JpaRepository <Order, Long> { Optional<Order> findByOrderNo (String orderNo) ; }
Repository 不应该被其他模块访问,所以放 internal。
10.5 内部 Service 实现对外 API internal/OrderService.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 package com.example.finance.order.internal;import com.example.finance.order.OrderApi;import com.example.finance.order.OrderSubmittedEvent;import lombok.RequiredArgsConstructor;import org.springframework.context.ApplicationEventPublisher;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import java.math.BigDecimal;import java.time.Instant;import java.util.Optional;@Service @RequiredArgsConstructor class OrderService implements OrderApi { private final OrderJpaRepository orderRepository; private final ApplicationEventPublisher eventPublisher; @Override @Transactional public Long submitOrder (SubmitOrderRequest request) { orderRepository.findByOrderNo(request.orderNo()) .ifPresent(order -> { throw new IllegalArgumentException ("Order already exists: " + request.orderNo()); }); Order order = new Order ( request.customerId(), request.orderNo(), new BigDecimal (request.amount()) ); order.submit(); orderRepository.save(order); eventPublisher.publishEvent(new OrderSubmittedEvent ( order.getId(), order.getCustomerId(), order.getOrderNo(), order.getAmount().toPlainString(), Instant.now() )); return order.getId(); } @Override @Transactional(readOnly = true) public Optional<OrderSummary> findSummary (Long orderId) { return orderRepository.findById(orderId) .map(order -> new OrderSummary ( order.getId(), order.getCustomerId(), order.getOrderNo(), order.getAmount().toPlainString(), order.getStatus() )); } }
这里有一个关键设计:
1 OrderService 实现 OrderApi,但 OrderService 自己放在 internal。
其他模块依赖的是:
而不是:
这就是接口暴露和实现隐藏。
10.6 Controller 放 internal internal/OrderController.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 package com.example.finance.order.internal;import com.example.finance.order.OrderApi;import lombok.RequiredArgsConstructor;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.PathVariable;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestController @RequestMapping("/api/orders") @RequiredArgsConstructor class OrderController { private final OrderApi orderApi; @PostMapping("/submit") Long submit (@RequestBody OrderApi.SubmitOrderRequest request) { return orderApi.submitOrder(request); } @GetMapping("/{orderId}") Object findSummary (@PathVariable Long orderId) { return orderApi.findSummary(orderId) .orElseThrow(() -> new IllegalArgumentException ("Order not found: " + orderId)); } }
Controller 是 HTTP 适配器,不是模块 API,所以放 internal。
十一、实现 payment 模块 11.1 Payment API PaymentApi.java:
1 2 3 4 5 6 package com.example.finance.payment;public interface PaymentApi { Long pay (Long orderId, String amount) ; }
11.2 PaymentCompletedEvent PaymentCompletedEvent.java:
1 2 3 4 5 6 7 8 9 10 11 package com.example.finance.payment;import java.time.Instant;public record PaymentCompletedEvent ( Long paymentId, Long orderId, String amount, Instant occurredAt ) { }
11.3 Payment Entity internal/Payment.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 package com.example.finance.payment.internal;import jakarta.persistence.Column;import jakarta.persistence.Entity;import jakarta.persistence.GeneratedValue;import jakarta.persistence.GenerationType;import jakarta.persistence.Id;import jakarta.persistence.Table;import java.math.BigDecimal;@Entity @Table(name = "fin_payment") class Payment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private Long orderId; @Column(nullable = false, precision = 18, scale = 2) private BigDecimal amount; @Column(nullable = false) private String status; protected Payment () { } Payment(Long orderId, BigDecimal amount) { this .orderId = orderId; this .amount = amount; this .status = "PAID" ; } Long getId () { return id; } Long getOrderId () { return orderId; } BigDecimal getAmount () { return amount; } }
11.4 Payment Repository 1 2 3 4 5 6 package com.example.finance.payment.internal;import org.springframework.data.jpa.repository.JpaRepository;interface PaymentJpaRepository extends JpaRepository <Payment, Long> { }
11.5 Payment Service 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 package com.example.finance.payment.internal;import com.example.finance.payment.PaymentApi;import com.example.finance.payment.PaymentCompletedEvent;import lombok.RequiredArgsConstructor;import org.springframework.context.ApplicationEventPublisher;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import java.math.BigDecimal;import java.time.Instant;@Service @RequiredArgsConstructor class PaymentService implements PaymentApi { private final PaymentJpaRepository paymentRepository; private final ApplicationEventPublisher eventPublisher; @Override @Transactional public Long pay (Long orderId, String amount) { Payment payment = new Payment (orderId, new BigDecimal (amount)); paymentRepository.save(payment); eventPublisher.publishEvent(new PaymentCompletedEvent ( payment.getId(), payment.getOrderId(), payment.getAmount().toPlainString(), Instant.now() )); return payment.getId(); } }
11.6 监听订单提交事件 PaymentOrderListener.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package com.example.finance.payment.internal;import com.example.finance.order.OrderSubmittedEvent;import lombok.RequiredArgsConstructor;import org.springframework.modulith.events.ApplicationModuleListener;import org.springframework.stereotype.Component;@Component @RequiredArgsConstructor class PaymentOrderListener { private final PaymentService paymentService; @ApplicationModuleListener void on (OrderSubmittedEvent event) { paymentService.pay(event.orderId(), event.amount()); } }
@ApplicationModuleListener 可以理解为 Spring Modulith 对异步事务事件监听的快捷封装。它用于模块之间的事件集成,比直接@EventListener 更适合模块化场景。
关键点:
order 模块不注入 payment;
payment 监听 order 发布的事件;
payment 内部调用自己的 PaymentService;
订单提交流程不再像滚雪球一样吸入所有后续业务。
十二、实现 settlement 模块 12.1 Settlement API 1 2 3 4 5 6 7 8 9 10 11 package com.example.finance.settlement;import java.util.List;public interface SettlementApi { List<SettlementSummary> findByOrderId (Long orderId) ; record SettlementSummary (Long id, Long orderId, String amount, String status) { } }
12.2 Settlement Entity 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 package com.example.finance.settlement.internal;import jakarta.persistence.Column;import jakarta.persistence.Entity;import jakarta.persistence.GeneratedValue;import jakarta.persistence.GenerationType;import jakarta.persistence.Id;import jakarta.persistence.Table;import java.math.BigDecimal;@Entity @Table(name = "fin_settlement_bill") class SettlementBill { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private Long orderId; @Column(nullable = false, precision = 18, scale = 2) private BigDecimal amount; @Column(nullable = false) private String status; protected SettlementBill () { } SettlementBill(Long orderId, BigDecimal amount) { this .orderId = orderId; this .amount = amount; this .status = "WAITING_PAYMENT" ; } void markPaid () { this .status = "PAID" ; } Long getId () { return id; } Long getOrderId () { return orderId; } BigDecimal getAmount () { return amount; } String getStatus () { return status; } }
12.3 Settlement Repository 1 2 3 4 5 6 7 8 9 10 package com.example.finance.settlement.internal;import org.springframework.data.jpa.repository.JpaRepository;import java.util.List;interface SettlementJpaRepository extends JpaRepository <SettlementBill, Long> { List<SettlementBill> findByOrderId (Long orderId) ; }
12.4 Settlement Service 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 package com.example.finance.settlement.internal;import com.example.finance.settlement.SettlementApi;import lombok.RequiredArgsConstructor;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import java.math.BigDecimal;import java.util.List;@Service @RequiredArgsConstructor class SettlementService implements SettlementApi { private final SettlementJpaRepository settlementRepository; @Transactional void createWaitingBill (Long orderId, String amount) { SettlementBill bill = new SettlementBill (orderId, new BigDecimal (amount)); settlementRepository.save(bill); } @Transactional void markPaid (Long orderId) { List<SettlementBill> bills = settlementRepository.findByOrderId(orderId); bills.forEach(SettlementBill::markPaid); } @Override @Transactional(readOnly = true) public List<SettlementSummary> findByOrderId (Long orderId) { return settlementRepository.findByOrderId(orderId) .stream() .map(bill -> new SettlementSummary ( bill.getId(), bill.getOrderId(), bill.getAmount().toPlainString(), bill.getStatus() )) .toList(); } }
12.5 监听订单提交和支付完成事件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package com.example.finance.settlement.internal;import com.example.finance.order.OrderSubmittedEvent;import com.example.finance.payment.PaymentCompletedEvent;import lombok.RequiredArgsConstructor;import org.springframework.modulith.events.ApplicationModuleListener;import org.springframework.stereotype.Component;@Component @RequiredArgsConstructor class SettlementOrderListener { private final SettlementService settlementService; @ApplicationModuleListener void on (OrderSubmittedEvent event) { settlementService.createWaitingBill(event.orderId(), event.amount()); } @ApplicationModuleListener void on (PaymentCompletedEvent event) { settlementService.markPaid(event.orderId()); } }
这里 settlement 通过事件实现了对订单和支付状态变化的响应。
十三、模块边界验证 Spring Modulith 最重要的测试之一:验证模块结构。
src/test/java/com/example/finance/ModularityTests.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package com.example.finance;import org.junit.jupiter.api.Test;import org.springframework.modulith.core.ApplicationModules;class ModularityTests { private final ApplicationModules modules = ApplicationModules.of(FinanceApplication.class); @Test void printModules () { modules.forEach(System.out::println); } @Test void verifyModularStructure () { modules.verify(); } }
执行:
1 mvn test -Dtest=ModularityTests
如果结构正确,会通过。
如果你在 settlement 模块里写了这种代码:
1 2 3 4 5 6 7 import com.example.finance.order.internal.OrderJpaRepository;@Service class BadSettlementService { private final OrderJpaRepository orderRepository; }
测试会失败,因为 settlement 访问了 order 的 internal 包。
Spring Modulith 内置验证主要包括:
模块之间不能有循环依赖 。
模块只能访问其他模块的 API 包,不能访问 internal 包 。
如果声明了 allowedDependencies,只能依赖白名单里的模块 。
这就是把架构文档变成自动化测试。
十四、生成架构文档 加上 spring-modulith-docs 后,可以生成模块图和模块说明。
DocumentationTests.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package com.example.finance;import org.junit.jupiter.api.Test;import org.springframework.modulith.core.ApplicationModules;import org.springframework.modulith.docs.Documenter;class DocumentationTests { private final ApplicationModules modules = ApplicationModules.of(FinanceApplication.class); @Test void writeDocumentationSnippets () { new Documenter (modules) .writeModulesAsPlantUml() .writeIndividualModulesAsPlantUml() .writeModuleCanvases(); } }
执行:
1 mvn test -Dtest=DocumentationTests
默认会在构建目录下生成类似:
1 2 3 4 5 6 target/spring-modulith-docs ├── components.puml ├── module-order.puml ├── module-payment.puml ├── module-settlement.puml └── module-canvas.adoc
这些文档可以放到团队架构文档里,也可以结合 Asciidoctor 渲染成 HTML。
它的价值很实际:
新人不用靠口口相传理解系统;
架构图不是手画一次就过期,而是从代码生成;
代码结构变化后,文档可以自动更新;
code review 时可以直接看模块依赖是否变复杂。
十五、模块级集成测试 普通 @SpringBootTest 往往启动整个应用上下文,大项目会很慢。
Spring Modulith 提供了 @ApplicationModuleTest,可以按模块启动测试。
15.1 测试 order 模块 src/test/java/com/example/finance/order/OrderModuleTests.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 package com.example.finance.order;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.modulith.test.ApplicationModuleTest;import static org.assertj.core.api.Assertions.assertThat;@ApplicationModuleTest class OrderModuleTests { @Autowired OrderApi orderApi; @Test void shouldSubmitOrder () { Long orderId = orderApi.submitOrder(new OrderApi .SubmitOrderRequest( 1001L , "SO202606120001" , "199.00" )); assertThat(orderId).isNotNull(); assertThat(orderApi.findSummary(orderId)) .isPresent() .get() .extracting(OrderApi.OrderSummary::status) .isEqualTo("SUBMITTED" ); } }
@ApplicationModuleTest 默认只启动当前模块。这样测试更快,也更能暴露模块边界问题。
15.2 测试包含直接依赖的模块 如果一个模块确实依赖其他模块,可以配置启动模式:
1 2 3 4 5 6 7 8 9 package com.example.finance.settlement;import org.springframework.modulith.test.ApplicationModuleTest;@ApplicationModuleTest( mode = ApplicationModuleTest.BootstrapMode.DIRECT_DEPENDENCIES ) class SettlementModuleTests { }
常用模式:
模式
说明
STANDALONE
默认,只启动当前模块
DIRECT_DEPENDENCIES
启动当前模块和直接依赖模块
ALL_DEPENDENCIES
启动当前模块和所有传递依赖模块
建议:能用 STANDALONE 就别扩大范围。如果必须启很多依赖,说明模块耦合可能偏高。
15.3 使用 Scenario 测试异步事件 异步事件测试经常麻烦,因为你不知道监听器什么时候执行完成。
Spring Modulith 提供了 Scenario API。
示例:测试订单提交后,settlement 生成结算单。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 package com.example.finance.settlement;import com.example.finance.order.OrderSubmittedEvent;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.modulith.test.ApplicationModuleTest;import org.springframework.modulith.test.Scenario;import java.time.Instant;import static org.assertj.core.api.Assertions.assertThat;@ApplicationModuleTest( mode = ApplicationModuleTest.BootstrapMode.DIRECT_DEPENDENCIES ) class SettlementEventTests { @Autowired SettlementApi settlementApi; @Test void shouldCreateSettlementBillWhenOrderSubmitted (Scenario scenario) { Long orderId = 10001L ; scenario.publish(new OrderSubmittedEvent ( orderId, 20001L , "SO202606120002" , "299.00" , Instant.now() )) .andWaitForStateChange(() -> settlementApi.findByOrderId(orderId)) .andVerify(bills -> { assertThat(bills).hasSize(1 ); assertThat(bills.getFirst().status()).isEqualTo("WAITING_PAYMENT" ); }); } }
这个测试表达的是:
1 2 3 发布 OrderSubmittedEvent 等待 settlement 模块状态变化 验证结算单被创建
它比手写 Awaitility、事务回调、事件处理器更清晰。
十六、事件驱动不是银弹 Spring Modulith 鼓励模块之间通过事件解耦,但事件驱动不是万能药。
16.1 什么时候适合用事件? 适合事件的场景:
场景
示例
一个业务动作完成后,多个模块需要响应
订单提交后,支付初始化、结算单生成、消息通知
发起方不关心响应方结果
订单模块不关心结算单如何生成
后续动作可以异步
发消息、记日志、生成统计、同步搜索索引
想减少跨模块 Bean 注入
order 不直接注入 settlement/payment
16.2 什么时候不适合用事件? 不适合事件的场景:
场景
说明
发起方需要立即拿到结果
订单创建时必须同步校验客户额度
强一致事务边界非常明确
同一个聚合内状态变更
查询类接口
查询订单摘要没必要发事件
业务流程需要明确编排
长流程 Saga 或审批流建议单独建流程编排模型
一个简单判断:
1 2 如果调用方必须立刻知道结果,用 API。 如果调用方只是宣布“某事已发生”,用事件。
16.3 事件命名建议 推荐:
1 2 3 4 OrderSubmittedEvent PaymentCompletedEvent SettlementBillGeneratedEvent CustomerCreditChangedEvent
不推荐:
1 2 3 SubmitOrderEvent DoPaymentEvent CreateSettlementCommandEvent
事件描述的是已经发生的事实,不是命令。
十七、事件发布注册表:防止事件丢失 普通异步事件有一个风险:
1 2 事务提交之后,异步监听器还没执行,应用宕机了怎么办? 监听器执行失败了怎么办?
Spring Modulith 的 Event Publication Registry 就是为这个问题设计的。
它会在原始业务事务中记录事件发布日志。监听器执行成功后,记录会被标记为完成;如果执行失败,记录保留,后续可以重试。
简化流程:
sequenceDiagram
participant Order as order模块
participant DB as 数据库
participant Registry as Event Publication Registry
participant Settlement as settlement监听器
Order ->> DB: 保存订单状态
Order ->> Registry: 记录待投递事件
Order ->> DB: 提交事务
Registry ->> Settlement: 投递 OrderSubmittedEvent
Settlement ->> DB: 创建结算单
Settlement ->> Registry: 标记事件完成
如果 settlement 监听器失败:
sequenceDiagram
participant Registry as Event Publication Registry
participant Settlement as settlement监听器
Registry ->> Settlement: 投递事件
Settlement --x Registry: 处理失败
Registry ->> Registry: 保留未完成/失败状态
Registry ->> Settlement: 后续重试或人工补偿
17.1 使用 JPA 事件注册表 前面已经加过:
1 2 3 4 5 <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-starter-jpa</artifactId > </dependency >
启动后会创建事件发布相关表,具体表结构以当前版本为准。
17.2 使用 JDBC 事件注册表 如果你的项目主体不是 JPA,而是 MyBatis/MyBatis-Plus,推荐用 JDBC:
1 2 3 4 5 <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-starter-jdbc</artifactId > </dependency >
这对大多数企业项目更友好:业务持久化可以继续走 MyBatis-Plus,Spring Modulith 的事件发布表单独走 JDBC。
17.3 事件重试和清理 生产环境要关注两个问题:
失败事件如何重试;
已完成事件如何清理。
可以引入事件 API:
1 2 3 4 5 <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-events-api</artifactId > </dependency >
示例:定时清理已完成事件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package com.example.finance.shared;import lombok.RequiredArgsConstructor;import org.springframework.modulith.events.CompletedEventPublications;import org.springframework.scheduling.annotation.Scheduled;import org.springframework.stereotype.Component;import java.time.Duration;@Component @RequiredArgsConstructor class EventPublicationCleaner { private final CompletedEventPublications completedEventPublications; @Scheduled(cron = "0 0 3 * * ?") void purgeOldCompletedEvents () { completedEventPublications.deletePublicationsOlderThan(Duration.ofDays(7 )); } }
示例:重试失败事件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 package com.example.finance.shared;import lombok.RequiredArgsConstructor;import org.springframework.modulith.events.core.FailedEventPublications;import org.springframework.modulith.events.core.ResubmissionOptions;import org.springframework.scheduling.annotation.Scheduled;import org.springframework.stereotype.Component;import java.time.Duration;@Component @RequiredArgsConstructor class FailedEventResubmitter { private final FailedEventPublications failedEventPublications; @Scheduled(fixedDelay = 60_000) void resubmitFailedEvents () { failedEventPublications.resubmit( ResubmissionOptions.defaults() .withBatchSize(100 ) .withMinAge(Duration.ofMinutes(1 )) ); } }
注意:具体包名可能会随 Spring Modulith 小版本调整。生产项目中以 IDE 自动导入和官方 Javadoc 为准。
17.4 completion-mode 怎么选? spring.modulith.events.completion-mode 常见选择:
模式
说明
适合场景
UPDATE
成功后更新完成状态,保留记录
需要审计、排查事件链路
DELETE
成功后删除记录
事件量大,不需要保留完成记录
生产建议:
初期用 UPDATE,方便排查。
事件量上来后,配合定时清理。
对高吞吐系统,可以考虑 DELETE,但要有其他业务审计日志。
十八、事件外部化:从内部事件到 Kafka/RabbitMQ 模块化单体内部事件和外部消息不是一回事。
内部事件用于模块之间解耦;外部消息用于和其他系统集成。
Spring Modulith 支持把选定事件 externalize 到消息中间件,例如 Kafka、AMQP、JMS、Spring Messaging。
18.1 Kafka 依赖 1 2 3 4 5 6 7 8 9 10 <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-events-kafka</artifactId > </dependency > <dependency > <groupId > org.springframework.kafka</groupId > <artifactId > spring-kafka</artifactId > </dependency >
18.2 标记事件外部化 1 2 3 4 5 6 7 8 9 10 11 12 13 14 package com.example.finance.payment;import org.springframework.modulith.events.Externalized;import java.time.Instant;@Externalized("finance.payment.completed::#{#this.orderId()}") public record PaymentCompletedEvent ( Long paymentId, Long orderId, String amount, Instant occurredAt ) { }
含义:
1 2 topic 或 logical target: finance.payment.completed routing key/message key: orderId
18.3 不要滥用外部化 不是所有内部事件都应该发到 Kafka。
推荐外部化:
其他系统确实需要消费的业务事实;
审计、风控、报表、搜索、数据同步等跨系统事件;
语义稳定、不频繁变更的事件。
不推荐外部化:
纯内部流程事件;
字段还不稳定的事件;
包含敏感数据的事件;
只是为了“看起来很事件驱动”的事件。
架构不是贴纸,不是贴上 Kafka 就高级。Kafka 只是把你的设计放大:设计好,它放大优雅;设计烂,它放大事故。
十九、生产观测:Actuator 与模块视图 引入:
1 2 3 4 5 6 <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-starter-insight</artifactId > <scope > runtime</scope > </dependency >
配置:
1 2 3 4 5 management: endpoints: web: exposure: include: health,info,metrics,modulith
启动后访问:
1 curl http://localhost:8080/actuator/modulith
可以看到模块结构、模块依赖等信息。
这对生产排查非常有帮助,尤其是当系统越来越大时,你可以从运行时视角观察模块之间的交互。
建议配合:
Micrometer;
OpenTelemetry;
Zipkin / Tempo / Jaeger;
Prometheus + Grafana;
日志 TraceId。
模块化不是只在代码目录里好看,更要能在运行时看得见。
二十、运行时初始化 有些模块启动时需要初始化数据,例如:
字典缓存;
结算规则;
支付渠道配置;
客户等级规则;
模块内本地缓存。
Spring Modulith 提供 ApplicationModuleInitializer,并且会按模块依赖顺序执行。
依赖:
1 2 3 4 5 6 <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-runtime</artifactId > <scope > runtime</scope > </dependency >
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.example.finance.settlement.internal;import lombok.extern.slf4j.Slf4j;import org.springframework.modulith.runtime.ApplicationModuleInitializer;import org.springframework.stereotype.Component;@Slf4j @Component class SettlementInitializer implements ApplicationModuleInitializer { @Override public void initialize () { log.info("Initialize settlement module rules cache." ); } }
如果 settlement 依赖 order,那么 order 相关初始化会先执行。
这比用一堆 @Order 更贴近架构语义。
二十一、与 DDD / COLA / 六边形架构的结合 Spring Modulith 不强制你使用 DDD,但它非常适合和 DDD 一起使用。
21.1 推荐模块内部结构 如果你喜欢 DDD,可以这样组织:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 order ├── OrderApi.java ├── OrderSubmittedEvent.java └── internal ├── application │ └── OrderApplicationService.java ├── domain │ ├── Order.java │ ├── OrderStatus.java │ └── OrderDomainService.java ├── infrastructure │ ├── OrderJpaRepository.java │ └── OrderRepositoryAdapter.java └── web └── OrderController.java
如果你喜欢 COLA 风格,可以这样组织:
1 2 3 4 5 6 7 8 order ├── OrderApi.java ├── OrderSubmittedEvent.java └── internal ├── adapter ├── app ├── domain └── infrastructure
注意:Spring Modulith 关心模块边界,不强制模块内部怎么分层。
你可以用:
DDD;
Clean Architecture;
Hexagonal Architecture;
COLA;
传统 application/domain/infrastructure;
简化版 service/repository。
但核心原则不变:
1 模块内部怎么复杂都可以,别把内部复杂度泄漏给其他模块。
21.2 模块边界与聚合边界 不要把模块边界和聚合边界混为一谈。
概念
粒度
示例
聚合
领域模型一致性边界
Order、Payment、SettlementBill
模块
业务能力边界
order、payment、settlement
服务
部署边界
order-service、payment-service
关系通常是:
1 2 3 一个模块可以包含多个聚合。 一个微服务也可以包含多个模块。 一个模块未来也可能拆成一个微服务。
模块不是越小越好。太细会导致事件满天飞,查询困难,协作复杂。模块边界应该按业务能力划分,而不是按数据库表划分。
二十二、与 MyBatis-Plus 项目如何结合? 很多企业项目不是 JPA,而是 MyBatis / MyBatis-Plus。
Spring Modulith 不要求你必须使用 JPA。它的模块验证、文档生成、模块测试都可以和 MyBatis-Plus 一起使用。
推荐方式:
1 2 3 业务数据:MyBatis-Plus 事件发布注册表:spring-modulith-starter-jdbc 模块验证/文档/测试:spring-modulith-starter-test + spring-modulith-docs
依赖:
1 2 3 4 5 <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-starter-jdbc</artifactId > </dependency >
模块结构:
1 2 3 4 5 6 7 settlement ├── SettlementApi.java └── internal ├── SettlementService.java ├── SettlementMapper.java ├── SettlementEntity.java └── SettlementRepository.java
注意:Mapper 也放 internal。
错误示例:
1 2 3 4 package com.example.finance.order.internal;public interface OrderMapper extends BaseMapper <OrderEntity> { }
如果这个 Mapper 被 settlement 直接注入,就破坏模块边界。
正确方式:
1 2 settlement 需要订单信息:依赖 OrderApi 查询摘要,或者监听 OrderSubmittedEvent 缓存必要快照。 settlement 不应该跨模块直接查 order 表。
当然,现实项目里跨表查询不可避免。建议分两类处理:
业务写操作 :严格通过模块 API 或事件,不允许跨模块改数据。
复杂查询/报表 :可以单独建 query/reporting 模块,读模型可以跨表,但不要把它反向塞回核心业务模块。
二十三、老项目如何渐进式迁移? 老项目千万不要一上来就全量改包结构。容易一天雄心壮志,三天全员回滚。
推荐四阶段迁移。
阶段一:识别模块 先不动代码,画出现有业务模块:
1 2 3 4 5 6 7 8 order settlement payment customer inventory invoice report system
然后梳理:
每个模块负责什么;
当前有哪些包和类属于它;
哪些类被其他模块依赖;
哪些依赖明显不合理;
哪些模块未来可能拆服务。
阶段二:建立模块包 逐步把代码移动到模块包下:
1 2 3 com.example.finance.order com.example.finance.settlement com.example.finance.payment
先不要追求完美。
可以先用 Open Module:
1 2 3 4 @org .springframework.modulith.ApplicationModule( type = org.springframework.modulith.ApplicationModule.Type.OPEN )package com.example.finance.order;
这样先让 Spring Modulith 识别模块,同时不立刻拦截所有 internal 访问。
阶段三:收紧边界 逐步把模块对外 API 提炼出来:
1 2 3 OrderApi SettlementApi PaymentApi
然后把内部实现移动到 internal:
1 2 3 order.internal.OrderService order.internal.OrderRepository order.internal.OrderEntity
每次只治理一个模块。
阶段四:CI 强制验证 加入测试:
1 2 3 4 @Test void verifyModularStructure () { ApplicationModules.of(FinanceApplication.class).verify(); }
在 CI 中执行:
1 mvn test -Dtest=ModularityTests
后续所有破坏边界的改动,直接红灯。
建议策略:
1 2 3 4 第 1 周:只打印模块结构,不失败 第 2 周:治理核心模块 order/payment/settlement 第 3 周:禁止新增非法依赖 第 4 周:CI 强制模块验证
架构治理不要指望一口吃成胖子。胖子也不是一天胖的,屎山也不是一天堆的,拆起来也得讲节奏。
二十四、CI/CD 集成 24.1 GitHub Actions .github/workflows/build.yml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 name: build on: push: branches: [ "main" , "master" ] pull_request: branches: [ "main" , "master" ]jobs: test: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up JDK 21 uses: actions/setup-java@v4 with: distribution: temurin java-version: 21 cache: maven - name: Run tests run: mvn -B test - name: Generate Modulith Docs run: mvn -B test -Dtest=DocumentationTests - name: Upload Modulith Docs uses: actions/upload-artifact@v4 with: name: spring-modulith-docs path: target/spring-modulith-docs
24.2 GitLab CI .gitlab-ci.yml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 stages: - test variables: MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository" cache: paths: - .m2/repository modularity-test: image: eclipse-temurin:21 stage: test script: - ./mvnw -B test artifacts: when: always paths: - target/spring-modulith-docs expire_in: 7 days
24.3 推荐 CI 策略
每个 MR/PR 必跑 ModularityTests。
每天定时生成模块文档。
如果模块图依赖突然变多,要在 code review 里解释。
禁止新增跨模块 internal 访问。
老项目迁移期可以先允许已知违规,但禁止新增违规。
二十五、常见坑和最佳实践 25.1 不要把所有类都放根包 错误:
1 2 3 4 5 6 order ├── OrderApi.java ├── OrderService.java ├── OrderRepository.java ├── OrderEntity.java └── OrderController.java
这样这些 public 类型都可能变成模块 API。
推荐:
1 2 3 4 5 6 7 8 order ├── OrderApi.java ├── OrderSubmittedEvent.java └── internal ├── OrderService.java ├── OrderRepository.java ├── OrderEntity.java └── OrderController.java
25.2 不要跨模块注入 internal Service 错误:
1 2 3 4 5 @Service class SettlementService { private final OrderService orderService; }
正确:
1 2 3 4 5 @Service class SettlementService { private final OrderApi orderApi; }
更解耦:
1 2 3 4 @ApplicationModuleListener void on (OrderSubmittedEvent event) { }
25.3 不要把 Entity 当模块 API 错误:
1 2 3 public interface OrderApi { Order findById (Long id) ; }
正确:
1 2 3 public interface OrderApi { Optional<OrderSummary> findSummary (Long id) ; }
Entity 是模块内部一致性模型,不是跨模块 DTO。
25.4 不要为了事件驱动而事件驱动 如果 settlement 必须同步校验订单是否存在,可以调用 OrderApi。
如果 settlement 只是响应订单提交生成结算单,可以监听 OrderSubmittedEvent。
API 和 Event 不是谁高级谁低级,而是语义不同。
25.5 不要让 shared 变成垃圾桶 shared 模块很容易变成:
1 2 3 4 5 6 7 8 shared ├── utils ├── common ├── helper ├── dto ├── constants ├── enums └── misc
这就危险了。
shared 只适合放:
通用异常;
基础值对象;
技术无关的基础类型;
少量真正跨模块共享的常量。
不要把业务 API 放 shared。否则所有模块都会通过 shared 间接耦合。
25.6 不要过度拆模块 模块不是越多越好。
错误拆法:
1 2 3 4 5 order-create order-update order-query order-status order-cancel
这不是模块化,这是把代码切成薯片。
模块应该围绕业务能力,而不是围绕 CRUD 动作。
25.7 模块间数据库访问要谨慎 严格模式下,模块不应该直接访问其他模块的数据表。
现实中复杂报表可能需要跨模块查询。建议做法:
1 2 3 核心业务写模型:严格模块边界 报表查询读模型:单独 report/query 模块 数据同步:事件生成读模型快照
这样不会让核心业务模块互相污染。
二十六、Spring Modulith 和微服务拆分路径 Spring Modulith 特别适合作为微服务拆分前的中间态。
推荐路径:
graph LR
A[传统单体] --> B[按业务包重构]
B --> C[Spring Modulith 验证模块边界]
C --> D[事件驱动解耦]
D --> E[模块级测试和文档]
E --> F[识别高价值拆分模块]
F --> G[按模块拆微服务]
什么时候可以考虑把模块拆成微服务?
判断条件
说明
模块边界稳定
API 和事件长期稳定
数据边界清晰
不再频繁跨模块改表
团队边界清晰
有独立团队维护
部署节奏不同
模块需要独立发布
性能/扩缩容不同
某模块压力明显独立
故障隔离有价值
某模块故障不能影响主链路
不要因为“代码多”就拆微服务。
应该因为:
1 边界稳定 + 独立演进 + 独立部署 + 独立扩展 + 故障隔离
才拆。
Spring Modulith 的意义就是让你先证明边界,再决定是否服务化。
二十七、推荐工程规范 27.1 包结构规范 1 2 3 4 5 6 7 8 9 10 com.example.finance ├── module-name │ ├── ModuleApi.java │ ├── XxxEvent.java │ ├── package-info.java │ └── internal │ ├── application │ ├── domain │ ├── infrastructure │ └── web
27.2 命名规范
类型
命名建议
模块 API
OrderApi、SettlementApi
事件
OrderSubmittedEvent、PaymentCompletedEvent
内部服务
OrderService、SettlementService
监听器
SettlementOrderListener、PaymentOrderListener
Repository
OrderRepository / OrderJpaRepository / OrderMapper
DTO
OrderSummary、SubmitOrderRequest
27.3 依赖规范
模块之间默认不直接依赖。
必须依赖时,只依赖根包 API 或 Named Interface。
禁止访问其他模块 internal。
写操作优先事件驱动。
查询操作可以通过 API。
报表跨表查询放 report/query 模块。
shared 严格控制,不放业务逻辑。
27.4 测试规范 每个项目至少包含:
1 2 3 4 ModularityTests # 模块结构验证 DocumentationTests # 模块文档生成 XxxModuleTests # 模块级集成测试 XxxEventTests # 事件协作测试
27.5 CI 规范 1 2 3 mvn test mvn test -Dtest=ModularityTests mvn test -Dtest=DocumentationTests
MR/PR 必须通过模块验证。
二十八、完整实战流程回顾 把 Spring Modulith 落地到项目,可以按这个流程:
flowchart TB
A[创建 Spring Boot 项目] --> B[引入 Spring Modulith BOM]
B --> C[按业务模块设计包结构]
C --> D[根包暴露 API 和 Event]
D --> E[internal 放内部实现]
E --> F[package-info 声明 allowedDependencies]
F --> G[使用 ApplicationModules.verify 验证]
G --> H[使用事件解耦模块]
H --> I[使用 ApplicationModuleTest 测试模块]
I --> J[使用 Documenter 生成文档]
J --> K[启用事件发布注册表]
K --> L[启用 actuator/modulith 生产观测]
L --> M[接入 CI 防止架构腐化]
最终你得到的是:
业务模块清晰;
依赖方向可控;
内部实现不外泄;
模块协作有事件记录;
模块测试更快;
文档从代码生成;
生产可观测;
未来可以按模块拆微服务。
二十九、适合与不适合的场景 29.1 适合使用 Spring Modulith 的场景
中大型 Spring Boot 单体应用;
准备做 DDD 或模块化单体;
暂时不想拆微服务,但想治理边界;
业务模块比较清晰,例如订单、支付、结算、库存、客户;
团队多人协作,代码容易互相污染;
希望未来能平滑拆服务;
希望架构规则进入 CI。
29.2 不适合的场景
很小的 CRUD 项目;
项目生命周期很短;
业务边界极不稳定,还在快速试错;
团队完全不愿意遵守模块规范;
已经是成熟微服务架构,且服务边界清晰。
不过即使是微服务项目,单个服务内部也可能继续用 Spring Modulith 做模块治理。
三十、总结 Spring Modulith 的核心不是“多了几个注解”,而是给 Spring Boot 单体项目提供一套完整的模块化治理能力。
它解决的问题非常现实:
在我看来,Spring Modulith 最有价值的点有五个:
让业务模块成为架构一等公民 。
把模块边界从口头约定变成自动化测试 。
用事件降低模块间直接耦合 。
用模块级测试提升大单体可维护性 。
为未来拆微服务提供可验证的边界基础 。
如果你现在手上的 Spring Boot 项目已经开始出现这些症状:
Service 互相注入;
Repository 被跨模块调用;
包结构越来越混乱;
新人看不懂业务边界;
一改订单影响支付、结算、库存;
想拆微服务但不知道从哪拆;
那 Spring Modulith 值得引入。
最后给一个非常实用的建议:
新项目:直接按 Spring Modulith 的模块化结构设计。 老项目:先识别模块,再用 Open Module 过渡,最后逐步收紧 internal 和 allowedDependencies。
架构治理不是一场轰轰烈烈的大重构,而是一套持续防腐机制。Spring Modulith 做的事情,就是把这套机制接进你的代码、测试、文档和运行时里。
参考资料
启示录 富贵岂由人,时会高志须酬。
能成功于千载者,必以近察远。