当 Spring Boot 单体项目逐渐增长,真正令人头疼的通常不是“单体”本身,而是业务边界消失之后形成的无约束依赖:Service 相互调用、Repository 跨领域访问、实体被全局复用,最终任何修改都可能产生连锁影响。Spring Modulith 提供了一套围绕业务模块组织、验证、测试、事件通信和可观测性的机制,而 Java 的 JPMS 则从编译与运行时层面提供更底层的模块边界。本文结合 Spring Modulith 2.1、Spring Boot 4.1 与 Java 25,讨论如何构建真正可演进的模块化单体,以及 Spring Modulith、Maven 多模块和 JPMS 应该如何组合使用。
单体真正的问题不是“只有一个进程” 很多项目在规模还小时都会采用类似下面的结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 com.example.shop ├── controller │ ├── OrderController.java │ ├── InventoryController.java │ └── PaymentController.java ├── service │ ├── OrderService.java │ ├── InventoryService.java │ └── PaymentService.java ├── repository │ ├── OrderRepository.java │ ├── InventoryRepository.java │ └── PaymentRepository.java ├── entity │ ├── Order.java │ ├── Inventory.java │ └── Payment.java └── dto
这种按照技术职责划分包的方式在代码量较小时非常直接,但它没有表达业务边界。
例如 OrderService 可以轻易注入:
1 2 3 4 5 6 7 @Service public class OrderService { private final InventoryRepository inventoryRepository; private final PaymentRepository paymentRepository; private final UserRepository userRepository; }
编译器不会觉得这里有什么问题,Spring 也不会觉得有什么问题。
几个月后,调用关系可能变成:
flowchart LR
Order["OrderService"]
Inventory["InventoryService"]
Payment["PaymentService"]
User["UserService"]
Coupon["CouponService"]
Order --> Inventory
Order --> Payment
Order --> User
Order --> Coupon
Inventory --> Order
Payment --> Order
Coupon --> User
User --> Order
此时项目虽然仍然只有一个部署单元,却已经变成典型的 Big Ball of Mud(大泥球) 。
所以“单体”和“模块化”并不是反义词。
更准确的关系应该是:
单体描述的是部署边界,模块描述的是代码与业务边界。
一个系统完全可以:
1 2 3 4 5 6 7 8 9 一个 Git 仓库 + 一个 Spring Boot 应用 + 一个 JVM 进程 + 一个部署单元 + 多个边界明确的业务模块
这就是 Modular Monolith(模块化单体)希望解决的问题。
Spring Modulith、Maven Module 和 JPMS 不是一回事 在 Java 项目里讨论“模块化”,最容易混淆的就是 Module 这个词。
至少需要区分三个层次。
机制
主要解决的问题
边界
约束发生时间
Spring Modulith Application Module
业务模块边界
Java Package / Spring Bean
测试、验证、运行时
Maven / Gradle Module
构建和依赖边界
Artifact
编译期
JPMS Module
Java 平台模块边界
module-info.java
编译期 + 运行时
Spring Modulith 官方把自身定位为面向 Spring Boot、领域驱动的模块化应用工具集。它能够根据包结构建立 Application Module 模型,并在此基础上进行结构验证、模块级集成测试、架构文档生成以及运行时观测。当前稳定版为 Spring Modulith 2.1.0。
JPMS(Java Platform Module System)则属于 Java 语言与 JVM 层面的能力,自 Java 9 引入。
它解决的是另一类问题:
1 2 3 4 5 module com.acme.order { requires com.acme.inventory; exports com.acme.order.api; }
这里表达的是:
1 2 3 4 5 order 模块依赖 inventory 模块 同时 order 只向其他 Java Module 暴露 order.api
二者不是竞争关系。
更合适的理解是:
1 2 3 4 5 6 7 8 9 10 11 业务模块化 ↓ Spring Modulith 构建模块化 ↓ Maven / Gradle Java 平台级强模块化 ↓ JPMS
在大型项目中,它们甚至可以逐层叠加。
Spring Modulith 到底做了什么 Spring Modulith 最重要的抽象叫:
一个 Application Module 通常包含三部分:
1 2 3 4 Application Module ├── 对外 API ├── 内部实现 └── 对其他模块 API 的依赖
官方定义中,模块提供给其他模块的接口既可以是 Spring Bean,也可以是应用事件;模块内部实现不应该被其他模块直接访问。
例如一个电商应用可以按照业务能力组织:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 com.acme.shop ├── ShopApplication.java │ ├── order │ ├── OrderManagement.java │ ├── events │ └── internal │ ├── inventory │ ├── api │ └── internal │ ├── payment │ └── internal │ └── shipping └── internal
这和传统的:
1 2 3 4 controller service repository entity
有本质区别。
Spring Modulith 更鼓励:
1 2 3 按业务能力切分 ↓ 每个业务模块内部再自行分层
也就是所谓:
而不是:
Spring Modulith 如何识别模块 假设 Spring Boot 启动类位于:
1 2 3 4 5 6 7 8 9 package com.acme.shop;@SpringBootApplication public class ShopApplication { public static void main (String[] args) { SpringApplication.run(ShopApplication.class, args); } }
默认情况下,Spring Modulith 会把启动类所在包的直接子包 识别为 Application Module。
于是:
1 2 3 4 com.acme.shop.order com.acme.shop.inventory com.acme.shop.payment com.acme.shop.shipping
分别成为:
1 2 3 4 order inventory payment shipping
四个业务模块。
可以通过:
1 2 ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
获得完整的模块模型:
1 modules.forEach(System.out::println);
Spring Modulith 会分析:
1 2 3 4 5 6 模块名称 模块根包 Spring Bean 模块 API 模块内部类型 模块之间的依赖
这意味着模块不是只存在于架构图或者 README 里的“口头约定”,而是一个可以被程序分析的结构模型。
API Package 与 Internal Package Spring Modulith 有一个非常重要的默认规则。
假设:
1 2 3 4 5 6 7 order ├── OrderManagement.java ├── OrderQuery.java └── internal ├── OrderApplicationService.java ├── Order.java └── JpaOrderRepository.java
其中:
是模块根包。
那么根包中的 public 类型默认构成模块 API:
1 2 3 4 package com.acme.shop.order;public interface OrderManagement { }
而:
1 com.acme.shop.order.internal
以及其他子包默认属于模块内部实现。
因此:
1 2 3 inventory ↓ OrderManagement
合法。
但:
1 2 3 inventory ↓ order.internal.OrderApplicationService
属于非法模块访问。
这套约定非常适合 Spring 项目。
因为 Java 本身存在一个尴尬的问题:Java 的 package-private 只能限制同一个 package ,不能覆盖子包。
例如:
order 和 order.internal 在 Java 看来是两个完全不同的 package。
因此为了让:
调用:
1 order.internal.OrderApplicationService
后者往往不得不声明为 public。
但只要它变成 public:
也可以直接 import。
Spring Modulith 正是在这里补上了一层架构约束。官方文档也明确指出:对于这种高级包结构,单靠 Java 编译器无法阻止其他包引用那些虽然是 public、但逻辑上属于模块内部的类型。
从一个 Java 25 + Spring Boot 4.1 项目开始 截至本文编写时,Spring Boot 当前稳定版为 4.1.0,它要求至少 Java 17,并支持到 Java 26,因此 Java 25 可以直接作为运行和编译版本使用。Spring Modulith 当前稳定版为 2.1.0。
一个 Maven 项目可以这样配置:
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 <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 > 4.1.0</version > <relativePath /> </parent > <groupId > com.acme</groupId > <artifactId > modular-shop</artifactId > <version > 1.0.0-SNAPSHOT</version > <properties > <java.version > 25</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 > <type > pom</type > <scope > import</scope > </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.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 > com.h2database</groupId > <artifactId > h2</artifactId > <scope > runtime</scope > </dependency > <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-starter-test</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 2.1 提供了 starter-core、starter-jpa、starter-jdbc、starter-test、starter-insight 等不同 Starter。其中 starter-jpa 在核心模块能力之外还包含基于 JPA 的 Event Publication Registry,而 starter-test 提供模块测试与文档相关支持。
如果项目根本不需要事件持久化,可以只使用:
1 2 3 4 <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-starter-core</artifactId > </dependency >
不要为了“用了 Modulith”就把所有能力一次性装进去。
设计真正的业务包结构 以订单、库存、支付三个领域为例:
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 src/main/java └── com/acme/shop ├── ShopApplication.java │ ├── order │ ├── package-info.java │ ├── OrderManagement.java │ ├── events │ │ ├── package-info.java │ │ └── OrderPlaced.java │ └── internal │ ├── OrderApplicationService.java │ ├── Order.java │ ├── OrderRepository.java │ └── JpaOrderRepository.java │ ├── inventory │ ├── package-info.java │ ├── api │ │ ├── package-info.java │ │ ├── InventoryService.java │ │ └── ReservationResult.java │ └── internal │ ├── InventoryApplicationService.java │ ├── Inventory.java │ └── InventoryRepository.java │ └── payment ├── package-info.java └── internal ├── PaymentEventListener.java ├── PaymentApplicationService.java └── Payment.java
这里已经能看到一个关键变化。
以前:
1 Service → Service → Repository → Repository
现在变成:
内部实现隐藏在自己的边界中。
用 Named Interface 精确控制暴露面 仅仅规定“模块根包可以访问”有时仍然太宽。
Spring Modulith 提供了 Named Interface。
例如库存模块只希望向外暴露:
可以创建:
1 2 @org .springframework.modulith.NamedInterface("api" )package com.acme.shop.inventory.api;
文件:
1 inventory/api/package-info.java
那么:
1 2 3 4 5 6 7 8 9 package com.acme.shop.inventory.api;public interface InventoryService { ReservationResult reserve ( Long productId, int quantity ) ; }
订单模块明确声明自己只能依赖库存模块的 api:
1 2 3 4 @org .springframework.modulith.ApplicationModule( allowedDependencies = "inventory :: api" )package com.acme.shop.order;
Spring Modulith 使用:
表示精确接口。
例如:
意味着订单模块允许:
1 import com.acme.shop.inventory.api.InventoryService;
但不应该:
1 import com.acme.shop.inventory.internal.InventoryRepository;
甚至如果库存根包还有其他公开接口,也不属于这项显式授权。
Named Interface 与 allowedDependencies 正是把“依赖某个模块”进一步收紧成“依赖某个模块的某个接口集合”的关键机制。
将模块依赖显式化 继续给支付模块定义约束:
1 2 3 4 @org .springframework.modulith.ApplicationModule( allowedDependencies = "order :: events" )package com.acme.shop.payment;
订单事件包:
1 2 @org .springframework.modulith.NamedInterface("events" )package com.acme.shop.order.events;
这样可以得到非常清晰的依赖图:
flowchart LR
O["order"]
IA["inventory :: api"]
OE["order :: events"]
P["payment"]
S["shipping"]
O -->|"同步调用"| IA
P -->|"监听"| OE
S -->|"监听"| OE
注意方向。
订单需要实时知道库存是否可以预留,因此:
可以采用同步 API。
而支付初始化、发送通知、创建物流单等后续动作不一定需要成为订单主事务的一部分,因此可以围绕:
进行事件驱动。
这比简单规定:
所有模块之间都不许直接调用,只能发事件。
更符合实际工程。
模块化不是为了把所有方法调用改成消息队列。
真正应该考虑的是:
1 2 3 4 5 6 7 8 9 调用方是否必须立即拿到结果? │ ├─ 是 → 同步模块 API │ └─ 否 │ └─ 是否适合表达为已经发生的业务事实? │ └─ 是 → Domain Event
同步调用仍然应该是一等公民 例如订单创建时需要库存预占:
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 package com.acme.shop.order;import com.acme.shop.inventory.api.InventoryService;import org.springframework.stereotype.Service;@Service public class OrderManagement { private final InventoryService inventoryService; public OrderManagement (InventoryService inventoryService) { this .inventoryService = inventoryService; } public Long placeOrder ( Long productId, int quantity ) { var result = inventoryService.reserve(productId, quantity); if (!result.success()) { throw new IllegalStateException ( "Insufficient inventory" ); } return 10001L ; } }
这种依赖非常明确:
1 2 3 order ↓ inventory :: api
只要:
符合业务方向,并且不会进一步形成:
1 order → inventory → order
就没有必要为了“解耦”而强行异步化。
Domain Event 用来降低非必要的直接耦合 订单创建完成:
1 2 3 4 5 6 7 8 package com.acme.shop.order.events;public record OrderPlaced ( Long orderId, Long userId, long amount ) { }
订单模块发布事件:
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 package com.acme.shop.order;import com.acme.shop.order.events.OrderPlaced;import org.springframework.context.ApplicationEventPublisher;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;@Service public class OrderManagement { private final ApplicationEventPublisher events; public OrderManagement ( ApplicationEventPublisher events ) { this .events = events; } @Transactional public Long placeOrder () { Long orderId = createOrder(); events.publishEvent( new OrderPlaced ( orderId, 100L , 9999L ) ); return orderId; } private Long createOrder () { return 10001L ; } }
支付模块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.acme.shop.payment.internal;import com.acme.shop.order.events.OrderPlaced;import org.springframework.modulith.events.ApplicationModuleListener;import org.springframework.stereotype.Component;@Component class PaymentEventListener { @ApplicationModuleListener void on (OrderPlaced event) { } }
@ApplicationModuleListener 并不是一个普通的 @EventListener 别名。当前 Spring Modulith 中,它对应的是一种异步事务事件监听方式,并让监听逻辑在自己的事务中运行。Spring Modulith 同时提供 Event Publication Registry,用于记录事务事件的发布和处理状态,从而为失败恢复提供基础。
模块间的执行关系于是从:
1 2 3 4 5 6 7 OrderService | +----> PaymentService | +----> ShippingService | +----> NotificationService
逐渐变成:
sequenceDiagram
participant Client
participant Order as Order Module
participant Inventory as Inventory Module
participant Registry as Event Publication Registry
participant Payment as Payment Module
participant Shipping as Shipping Module
Client->>Order: placeOrder()
Order->>Inventory: reserve()
Inventory-->>Order: reservation result
Order->>Order: 保存订单
Order->>Registry: 记录 OrderPlaced publication
Order-->>Client: orderId
Registry-->>Payment: OrderPlaced
Registry-->>Shipping: OrderPlaced
Payment->>Payment: 创建支付记录
Shipping->>Shipping: 创建履约任务
这里已经非常接近分布式系统里的事件驱动架构,只不过:
1 2 3 4 网络边界 消息 Broker 独立数据库 独立 JVM
暂时都不存在。
这也是模块化单体很有价值的一点:
先把业务边界做对,再决定是否真的需要微服务。
Event Publication Registry 不等于“用了 MQ” 这里需要避免另一个误区。
Spring Modulith 的 Application Event:
1 2 3 不是 Kafka 不是 RocketMQ 不是 RabbitMQ
默认仍然发生在当前应用进程中。
Event Publication Registry 解决的是事务事件处理过程中的可靠性问题:在原业务事务中记录需要交付给事务监听器的 publication,监听成功后更新状态;如果监听失败,则保留相应记录,以便后续恢复或重试。当前版本还提供事件 publication 生命周期以及事件外部化能力。
因此可以把它理解成:
1 2 3 4 5 6 7 Domain Event ↓ Spring Application Event ↓ Transactional Event Listener ↓ Event Publication Registry
而不是:
1 2 3 Domain Event ↓ 自动变成分布式消息队列
如果以后模块真正拆成服务,再把某些领域事件外部化到 Kafka、AMQP 等消息基础设施,会比从一个高度耦合的单体直接硬拆服务自然得多。
用 verify() 把架构规则变成测试 Spring Modulith 最重要的价值之一不是“帮你分包”,而是:
它能够证明你目前的代码是否仍然符合模块边界。
测试非常简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package com.acme.shop;import org.junit.jupiter.api.Test;import org.springframework.modulith.core.ApplicationModules;class ModularityTests { private final ApplicationModules modules = ApplicationModules.of( ShopApplication.class ); @Test void verifiesModuleStructure () { modules.verify(); } }
verify() 默认会检查三类关键规则:
Application Module 之间不能形成循环依赖;
其他模块不能访问模块内部 package;
如果配置了 allowedDependencies,模块只能依赖允许的模块或 Named Interface。
例如有人偷偷写:
1 2 3 4 5 6 7 8 package com.acme.shop.payment.internal;import com.acme.shop.order.internal.OrderRepository;class PaymentApplicationService { private final OrderRepository orderRepository; }
Java:
Spring:
IDE:
但:
1 2 3 ApplicationModules.of( ShopApplication.class ).verify();
应该发现这种越界依赖。
这才是“架构规则”与“架构建议”的区别。
前者可以进入 CI。
后者通常只能进入 Wiki,然后安静地老去。
CI 中应该直接验证模块结构 例如:
只要:
属于测试套件的一部分,任何破坏模块结构的提交都会直接让构建失败。
于是架构约束可以变成:
1 2 3 4 5 6 7 8 9 10 开发者提交代码 ↓ CI ↓ Spring Modulith verify ↓ 依赖是否合法? │ ├─ 是 → 继续 └─ 否 → Build Failed
这比 Code Review 中人为检查:
可靠得多。
ApplicationModuleTest:不要每次都启动整个系统 普通 Spring Boot 集成测试经常使用:
它倾向于加载整个 ApplicationContext。
而 Spring Modulith 提供:
例如:
1 2 3 4 5 6 7 8 9 10 11 12 package com.acme.shop.order;import org.junit.jupiter.api.Test;import org.springframework.modulith.test.ApplicationModuleTest;@ApplicationModuleTest class OrderModuleTests { @Test void moduleStarts () { } }
测试类位于:
Spring Modulith 会识别当前测试针对 order 模块,并限制组件扫描、实体扫描和相关启动范围。
当前版本提供三种主要 Bootstrap Mode:
Mode
范围
STANDALONE
只启动当前模块
DIRECT_DEPENDENCIES
当前模块 + 直接依赖
ALL_DEPENDENCIES
当前模块 + 完整依赖树
默认是:
这种测试方式有一个非常好的副作用。
如果一个模块独立启动时立即出现:
1 NoSuchBeanDefinitionException
而原因是:
1 2 3 4 5 6 7 8 9 OrderService ↓ PaymentService ↓ UserService ↓ CouponService ↓ ……
那么问题往往不在测试。
而在架构。
官方文档同样指出,如果一个模块依赖大量其他模块的 Bean,这通常说明模块耦合度已经过高,应该重新考虑部分依赖是否更适合改成 Domain Event。
模块测试实际是在检测“可拆分性” 从架构角度看:
其实在不断回答一个问题:
如果未来我要把这个模块单独拿出来,它究竟依赖什么?
假设:
在 STANDALONE 模式完全无法启动,需要加入:
1 2 3 4 5 6 7 inventory payment customer coupon shipping notification settlement
才能运行。
这说明:
虽然目录叫一个模块,但实际上不是自治模块。
因此模块集成测试的意义不只是提高测试速度。
它实际上是一种:
1 Architecture Fitness Function
也就是持续验证架构健康程度的机制。
自动生成模块架构文档 很多架构图最终都会遇到一个问题:
半年后架构图就成了历史文物。
Spring Modulith 的 Documenter 可以直接根据当前代码解析出来的 Application Module 模型生成架构资料。
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 package com.acme.shop;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( ShopApplication.class ); @Test void generateDocumentation () { new Documenter (modules) .writeModulesAsPlantUml() .writeIndividualModulesAsPlantUml() .writeModuleCanvases(); } }
当前 Documenter 可以生成模块关系的 C4/UML 组件图,以及包含 Bean、Aggregate、发布/监听事件、配置属性等信息的 Application Module Canvas。
于是文档关系变成:
1 2 3 4 5 6 7 代码 ↓ ApplicationModules ↓ Documenter ↓ 架构图 / Module Canvas
而不是:
对于几十万行代码的长期项目,这种差距会非常明显。
运行时也可以观察模块 Spring Modulith 并不只在测试阶段工作。
加入:
1 2 3 4 5 <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-starter-insight</artifactId > <scope > runtime</scope > </dependency >
可以获得 Actuator 与 Observability 相关能力。
例如暴露:
后,可以查看运行中的 Application Module 信息。
Spring Modulith 也可以围绕模块交互产生 Observation,从而把 Micrometer 的 Metrics 与 Tracing 引入模块调用分析。
这意味着开发阶段设计出来的:
1 2 3 4 order inventory payment shipping
不再只是静态概念。
可以逐渐形成:
1 2 3 4 5 6 7 设计时模块 ↓ 编译/测试时验证 ↓ 集成测试隔离 ↓ 运行时观测
完整闭环。
到这里再看 Java 25 的 JPMS Spring Modulith 解决的是:
JPMS 解决的是:
JPMS 中最重要的文件是:
例如:
1 2 3 4 module com.acme.inventory.api { exports com.acme.inventory.api; }
订单模块:
1 2 3 4 5 6 module com.acme.order.domain { requires com.acme.inventory.api; exports com.acme.order.api; }
这里的边界由 Java 编译器和 JVM 理解。
如果:
内部还有:
1 com.acme.inventory.internal
但:
1 2 3 4 module com.acme.inventory.api { exports com.acme.inventory.api; }
没有:
1 exports com.acme.inventory.internal;
那么其他 JPMS Module 无法正常访问内部 package 中的公开类型。
这属于真正的平台级强封装。
JPMS 的核心指令 requires 声明当前模块依赖另一个模块:
1 2 3 4 module com.acme.order { requires com.acme.inventory; }
关系:
exports 将某个 package 暴露给其他模块:
1 2 3 4 module com.acme.inventory { exports com.acme.inventory.api; }
而:
1 com.acme.inventory.internal
不导出。
因此:
1 2 3 inventory ├── api ← 可以访问 └── internal ← 不能访问
JPMS 的 exports/opens 指令描述的是模块之间 的可访问关系;JLS 也允许通过带 to 的 qualified export/open 将 package 仅暴露给指定 friend module。
例如:
1 2 3 4 5 module com.acme.inventory { exports com.acme.inventory.api to com.acme.order; }
意味着只有:
可以看到该 package。
opens 和 exports 完全不是一回事 Spring、Hibernate、Jackson 等框架大量使用反射。
因此 JPMS 里还存在:
例如:
1 2 3 4 5 6 module com.acme.order { exports com.acme.order.api; opens com.acme.order.persistence; }
两者大体可以这样理解:
指令
普通代码访问
深层反射
exports
是
不因此自动开放
opens
否
是
exports + opens
是
是
也可以做 qualified opens:
1 2 opens com.acme.order.persistence to some.framework.module ;
比直接:
1 2 open module com.acme.order { }
更加克制。
在真正把 Spring 应用放到 module path 之前,应该先通过:
1 jar --describe-module --file some-library.jar
确认各个依赖实际使用的 Module Name,而不是从网上复制一份 opens ... to spring.xxx 就假设所有版本都一样。
一个 module-info.java 无法把一个单体拆成多个业务模块 这是 Spring Modulith 与 JPMS 结合时最重要的一点。
假设项目仍然只有:
1 2 3 4 5 6 src/main/java ├── module-info.java └── com/acme/shop ├── order ├── inventory └── payment
并声明:
1 2 3 4 5 module com.acme.shop { exports com.acme.shop.order; exports com.acme.shop.inventory; }
那么:
仍然全部属于:
这个同一个 JPMS Module 。
也就是说:
1 2 3 4 5 JPMS Module └── com.acme.shop ├── order ├── inventory └── payment
JPMS 的 exports 控制的是:
1 2 3 com.acme.shop ↓ 其他 Module
并不是:
在同一个 JPMS Module 内部,exports 不能充当 Spring Modulith 那种业务子模块隔离机制。
所以:
给一个巨大 Spring Boot 单体增加一个 module-info.java,并不会自动让它变成模块化单体。
这是两种 Module 最容易被混淆的地方。
真正利用 JPMS 隔离业务模块,需要多个 JPMS Module 如果希望 Java 编译器直接阻止:
1 2 3 order ↓ inventory.internal
那么需要真正拆成多个编译模块。
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 shop ├── inventory-api │ ├── pom.xml │ └── src/main/java │ ├── module-info.java │ └── com/acme/inventory/api │ ├── inventory-domain │ ├── pom.xml │ └── src/main/java │ ├── module-info.java │ └── com/acme/inventory/internal │ ├── order-domain │ ├── pom.xml │ └── src/main/java │ ├── module-info.java │ └── com/acme/order │ └── application
此时:
1 2 3 Maven Module + JPMS Module
可以形成真正的编译级边界。
例如:
1 2 3 4 module com.acme.inventory { exports com.acme.inventory.api; }
订单:
1 2 3 4 5 6 module com.acme.order { requires com.acme.inventory; exports com.acme.order.api; }
如果订单试图:
1 import com.acme.inventory.internal.InventoryEntity;
那么 JPMS 可以直接在编译阶段拒绝这种依赖。
Spring Modulith 和 JPMS 可以建立近似映射 两者不是一对一对应,但概念上可以这样理解:
Spring Modulith
JPMS
Application Module
named module
Module API
exports
internal package
未 exports 的 package
allowedDependencies
requires
Named Interface
更细粒度的 exports
模块结构验证
Java 编译器 / Module Resolution
Application Event
无直接对应能力
ApplicationModuleTest
无直接对应能力
Event Publication Registry
无直接对应能力
Module Observability
无直接对应能力
这也解释了为什么它们适合组合。
JPMS 更强的是:
Spring Modulith 更强的是:
1 2 3 4 5 6 领域模块建模 Spring Bean 依赖分析 事件通信 模块测试 模块文档 运行时观测
Java 25 的 Module Import Declaration 是什么 Java 25 在模块相关语法上还有一个非常容易产生误解的新特性:
1 import module java.base;
这是 JEP 511:Module Import Declarations,在 JDK 25 中正式定稿。它允许一个源文件通过:
一次性按需导入目标模块所导出的 package 中的 public 顶层类型,并考虑相应可读模块所带来的导出关系。
例如过去可能需要:
1 2 3 4 import java.util.List;import java.util.Map;import java.util.Set;import java.util.stream.Stream;
Java 25 可以写:
1 import module java.base;
然后:
1 2 3 4 List<String> names; Map<String, Integer> scores; Set<Long> ids; Stream<String> stream;
但这里一定要注意:
import module 不是 requires。
也不是:
更不是:
它本质上是:
1 Source Import Convenience
而不是:
1 Module Dependency Declaration
import module 不会创建模块边界 下面:
和:
1 2 3 4 module com.acme.app { requires java.sql; }
承担的是完全不同的职责。
前者解决:
后者解决:
1 当前 JPMS Module 可以读取哪些 Module
可以简单记成:
1 2 3 4 5 6 7 import module ↓ 名字可见性 requires ↓ 模块可读性
所以不要看到 Java 25 出现:
就认为 JPMS 的 module-info.java 被替代了。
完全没有。
Module Import Declaration 还有名称冲突问题 如果同时:
1 2 import module java.base;import module java.desktop;
可能从不同 package 导入具有相同 simple name 的类型。
例如:
1 2 java.util.List java.awt.List
此时仍然可能产生编译歧义。
Oracle Java 25 文档给出的解决方式就是继续用明确的 single-type import 消除歧义。
例如:
1 2 3 4 import module java.base;import module java.desktop;import java.util.List;
因此对于大型企业项目,我更倾向于:
而不是为了少写几行 import,在大量源文件中广泛使用:
IDE 本来就会自动管理 import。
企业项目更重要的是:
而不是节省 import 行数。
Java 25 并没有改变 JPMS 的核心架构价值 Java 25 对模块语法进行了改善,但 JPMS 真正重要的能力仍然是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 module-info.java requires requires transitive requires static exports exports ... to opens opens ... to uses provides ... with
其中:
1 requires transitive com.acme.common;
表示:
1 2 A requires B B requires transitive C
那么依赖 A 的模块在模块可读性上也可以获得对 C 的传递关系。
这很像 API 中重新暴露依赖。
但企业项目里应该谨慎使用。
如果大量:
最终可能重新形成:
1 2 3 4 5 依赖传播 ↓ 边界扩大 ↓ 所有模块都能看到所有模块
这和建立模块边界的初衷正好相反。
Classpath、Module Path 与 Automatic Module 一个 JAR 是否真正参与 JPMS,还与它怎么运行有关。
Java 25 的 JAR 规范仍然区分:
1 2 3 Modular JAR Automatic Module Unnamed Module
包含:
的 JAR 放在:
上时属于显式模块。
没有 module-info.class 的普通 JAR 放在 module path 上,则可能作为 Automatic Module 参与模块系统。
而 modular JAR 如果只是被放到传统:
上,也不会因为自己有 module-info.class 就自动获得完整的 named-module 运行方式。
因此:
和:
仍然不能简单画等号。
Spring 项目为什么不应该一上来就全面 JPMS 化 JPMS 很强。
但强意味着成本也更高。
Spring 项目广泛依赖:
1 2 3 4 5 6 Reflection Component Scanning Proxy JPA Entity Enhancement Jackson Reflection Configuration Binding
这些能力和强模块封装组合时,需要更认真地设计:
1 2 3 4 5 6 7 exports opens module path 第三方依赖模块名 Automatic Module 测试模块配置 构建插件
如果你的实际问题只是:
1 OrderService 为什么能直接调用 PaymentRepository?
那么立即全面引入:
1 2 3 4 5 6 7 8 9 Maven Multi-Module + JPMS + module-path + opens + jlink
很可能属于拿火箭发动机修自行车链条。
Spring Modulith 的优势恰恰是:
可以在几乎不改变 Spring Boot 原有开发模型的情况下,先建立业务模块边界。
更实际的模块化演进路线 对于绝大多数正在增长的 Spring Boot 单体,我更推荐这样演进:
flowchart TD
A["传统分层单体"]
B["Package by Feature"]
C["Spring Modulith Application Module"]
D["allowedDependencies + Named Interface"]
E["模块测试 + Domain Event"]
F["Maven / Gradle 多模块"]
G["选择性 JPMS"]
H["必要时拆分微服务"]
A --> B
B --> C
C --> D
D --> E
E --> F
F --> G
E --> H
G --> H
注意:
和:
都不是必经终点。
一个边界清晰的 Spring Modulith 单体完全可以长期运行。
第一阶段:先改变包结构 假设现状:
1 2 3 4 controller service repository entity
先不要急着拆 Maven Module。
优先变成:
1 2 3 4 5 order inventory payment customer settlement
模块内部再组织:
1 2 3 4 5 order ├── api ├── application ├── domain └── infrastructure
或者采用 Spring Modulith 更自然的:
1 2 3 4 5 6 7 order ├── OrderManagement.java ├── OrderView.java └── internal ├── application ├── domain └── infrastructure
关键不是目录名字。
关键是:
代码首先属于某个业务能力,然后才属于某个技术层。
第二阶段:建立模块 API 逐渐消除:
1 import xxx.othermodule.internal.*;
跨模块只能调用:
例如:
1 2 3 4 5 6 7 public interface InventoryService { ReservationResult reserve ( Long productId, int quantity ) ; }
而不是暴露:
1 2 3 4 InventoryRepository InventoryEntity InventoryMapper InventoryDO
这会形成一个重要原则:
模块之间应该传递:
1 2 3 4 业务参数 DTO Value Object Event
而不是互相共享内部持久化对象。
第三阶段:加入 verify() 马上把:
1 2 3 ApplicationModules.of( ShopApplication.class ).verify();
放进测试。
即使项目暂时还有问题,也应该尽早知道:
1 2 当前到底有多少循环依赖? 多少跨模块 internal 访问?
这相当于先给架构做一次 CT。
结果可能不太好看,但总比继续闭眼长大强。
第四阶段:通过 allowedDependencies 收紧边界 一开始:
可能能够访问:
1 2 3 4 inventory payment customer shipping
等多个模块。
逐渐明确真正允许的依赖:
1 2 3 4 5 6 7 @ApplicationModule( allowedDependencies = { "inventory :: api", "customer :: api" } ) package com.acme.shop.order;
目标不是让:
越来越长。
恰恰相反。
如果出现:
应该开始怀疑:
第五阶段:消灭循环依赖 最危险的结构通常不是:
而是:
1 2 3 order → inventory ↑ ↓ └─────────┘
例如:
1 2 3 4 5 OrderService -> InventoryService InventoryService -> OrderService
这种结构说明领域职责本身没有切清。
常见处理方式有:
1 2 3 4 重新调整聚合职责 提取真正的上层业务编排 建立稳定 API 将“通知型反向调用”改成 Domain Event
例如:
保留同步库存检查。
库存不再:
而是监听:
事件释放库存。
这样:
就可以从双向变成单向。
第六阶段:模块级集成测试 为核心业务模块建立:
测试。
建议优先验证:
1 2 3 4 5 模块是否能独立启动 核心 Use Case 数据库访问 领域事件 关键外部接口 Adapter
理想状态应该是:
1 2 3 4 5 6 7 8 9 10 11 单元测试 ↓ Domain / Value Object 模块测试 ↓ Application Module 少量全局测试 ↓ Whole Application
而不是所有业务场景都依赖:
启动整个应用。
第七阶段:再考虑 Maven Multi-Module 当某些业务模块已经十分稳定:
可以进一步物理拆分:
1 2 3 4 5 shop ├── shop-order ├── shop-inventory ├── shop-payment └── shop-application
这里的收益是编译期依赖更清晰。
例如:
1 2 3 4 <dependency > <groupId > com.acme</groupId > <artifactId > shop-inventory</artifactId > </dependency >
不存在,就无法 import。
相比 Spring Modulith:
Maven Module 增加的是:
但 Maven 多模块也有代价:
1 2 3 4 5 更多 pom.xml 依赖版本管理 循环依赖直接导致无法构建 测试 Fixture 共享更复杂 IDE 构建模型更复杂
因此不需要为了“架构看起来高级”把二十个业务包立刻变成二十个 Maven Artifact。
第八阶段:对真正值得的部分引入 JPMS 如果进一步需要:
1 2 3 4 5 编译器级强封装 运行时 Module Resolution 受控 exports ServiceLoader 模块机制 定制 Runtime Image
再考虑 JPMS。
一个比较实际的方式是优先让稳定、纯 Java 的核心领域模块 JPMS 化:
1 2 3 4 5 inventory-api inventory-domain order-domain pricing-core rule-engine
而不是第一天就强制整个:
1 2 3 4 5 6 Spring Boot Hibernate Jackson Web Security Observability
适配层全部转入 module path。
例如:
1 2 3 4 5 6 7 8 9 ┌────────────────────────────┐ │ Spring Boot App │ │ Web / JPA / Security │ │ Spring Modulith │ ├────────────────────────────┤ │ order-domain JPMS │ │ inventory JPMS │ │ pricing-core JPMS │ └────────────────────────────┘
这是一种比较务实的渐进方案。
使用 jdeps 分析 Java 模块依赖 Java 25 仍然提供:
用于分析 class/package/module 之间的依赖。
例如:
1 jdeps target/order-domain.jar
如果准备迁移旧 JAR:
1 2 3 jdeps \ --generate-module-info target/generated-module-info \ libs/legacy-library.jar
可以辅助生成模块描述建议。
对已经模块化的产物可以使用:
1 2 3 jdeps \ --module-path target/mods \ --check com.acme.shop.order
或者:
1 2 3 4 jdeps \ --module-path target/mods \ --print-module-deps \ target/order.jar
Java 25 的 jdeps 仍然提供 --module-path、--generate-module-info、--check 和 --print-module-deps 等模块分析能力。
这里的定位也要分清:
1 2 3 4 5 6 7 Spring Modulith ↓ 分析业务模块 jdeps ↓ 分析 Java 字节码 / JPMS 依赖
两者观察的是不同层级。
使用 jlink 创建定制 Java Runtime 真正使用 JPMS 后,还可以进入另一个 Java 模块化能力:
jlink 根据模块及其依赖组装一个定制 Runtime Image。
例如一个已经真正 JPMS 化的应用:
1 2 3 4 5 6 7 jlink \ --module-path "$JAVA_HOME /jmods:target/mods" \ --add-modules com.acme.shop.app \ --strip-debug \ --no-header-files \ --no-man-pages \ --output target/runtime
得到:
1 2 3 4 5 target/runtime ├── bin ├── conf ├── legal └── lib
运行:
1 2 target/runtime/bin/java \ -m com.acme.shop.app/com.acme.shop.Main
但这里有一个前提:
应用和相关依赖必须真的满足 jlink 所要求的模块化输入模型。
不要把:
1 Spring Boot executable fat JAR
和:
想成完全相同的打包体系。
如果项目并没有准备全面进入 module path,那么完全没有必要为了使用 jlink 而倒逼整个应用架构。
Spring Modulith 与 JPMS 最推荐的组合方式 如果是一个正在发展的企业 Spring Boot 项目,我会优先采用这样的层次:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ┌───────────────────────────────────────┐ │ Modular Monolith │ │ │ │ order inventory payment customer │ │ │ │ Spring Modulith │ │ │ │ Application Module / Named Interface │ │ verify / event / module test │ ├───────────────────────────────────────┤ │ Maven / Gradle Build │ │ │ │ 必要时进一步拆成多个 Artifact │ ├───────────────────────────────────────┤ │ Java 25 │ │ │ │ JPMS / jdeps / jlink(按需) │ └───────────────────────────────────────┘
也就是说:
负责:
Maven / Gradle Module 负责:
JPMS 负责:
1 “JVM 和编译器应该允许哪些 Java Module 相互访问?”
这三个问题并不相同。
不建议把 Shared 模块做成垃圾桶 模块化单体很容易出现:
1 2 3 4 5 common shared core base util
然后所有不知道放哪里的东西都扔进去:
1 2 3 4 5 6 7 8 9 common ├── dto ├── entity ├── util ├── enums ├── service ├── mapper ├── exception └── constants
结果:
1 2 3 4 5 order ─────┐ inventory ─┤ payment ───┼──> common customer ──┤ shipping ──┘
随后 common 又开始反向引用业务模块。
最终:
变成整个系统真正的“大泥球中心”。
更好的原则是:
真正适合共享的通常是非常稳定、非常基础的概念:
1 2 3 4 5 Money PageRequest Identifier Clock abstraction 基础异常协议
而:
1 2 3 4 OrderDTO PaymentStatus InventoryEntity CustomerService
通常都不应该因为“其他模块也想用”就被移动到 common。
共享越多,模块自治越弱。
数据库也需要模块边界 Spring Modulith 主要验证的是 Java 代码结构。
它不会自动阻止:
直接执行:
1 2 SELECT * FROM payment_record
因此模块化单体如果共享一个数据库,还需要人为建立数据库层面的规则。
比较推荐:
1 2 3 4 5 6 7 8 9 10 11 order owns order_* inventory owns inventory_* payment owns payment_*
原则是:
例如订单需要支付状态。
不应该默认:
1 2 orderRepository -> payment_record
而应该考虑:
或者通过事件维护自己需要的业务视图。
否则:
但:
最终仍然很难拆分。
不要把 Entity 当模块 API 例如:
1 2 3 @Entity public class InventoryEntity { }
直接被订单模块使用:
1 2 3 4 public Order create ( InventoryEntity inventory ) { }
实际上等于把库存模块的持久化模型泄漏给订单模块。
更合理的是:
1 2 3 4 5 public record ReservationResult ( boolean success, String reservationId ) { }
订单只知道:
而不需要知道:
1 2 3 4 inventory_table Hibernate Entity InventoryRepository 内部状态字段
这也是 DDD 与模块化单体结合时很重要的一点:
模块边界应该是业务契约边界,而不是 ORM Entity 共享边界。
什么时候使用同步 API,什么时候使用事件 可以采用下面这套判断。
场景
推荐方式
调用方必须立即知道结果
同步 API
强业务前置条件
同步 API
查询另一个模块能力
同步 API
已经发生的业务事实
Domain Event
一个动作触发多个独立副作用
Domain Event
不要求与主事务同时完成
Domain Event
通知、审计、积分等后处理
Domain Event
为了避免循环反向通知
通常考虑 Domain Event
例如:
同步:
比较合理。
而:
1 2 3 4 5 6 7 订单创建成功 ↓ 通知 积分 统计 营销 履约
更适合:
事件。
不要把所有调用都改成 Event 事件也会增加复杂度:
1 2 3 4 5 6 7 执行顺序不再直观 调试链路变长 最终一致性 失败重试 幂等 事件版本 事务边界
如果只是:
1 Money calculatePrice (Product product)
强行设计:
1 2 3 PriceCalculationRequested ↓ PriceCalculated
往往只会让代码更难理解。
好的模块化并不追求:
而追求:
Open Application Module 只适合作为迁移工具 对于老项目,Spring Modulith 允许把模块暂时声明为 Open Module:
1 2 3 4 @org .springframework.modulith.ApplicationModule( type = org.springframework.modulith.ApplicationModule.Type.OPEN )package com.acme.shop.legacy;
Open Module 会放宽对模块内部 package 的访问限制。
这对下面这种项目很实用:
可以先:
1 2 3 4 5 6 7 8 9 识别模块 ↓ Open ↓ 逐步迁移 ↓ 收紧 API ↓ 最终 Closed
但官方同样强调,Open Application Module 主要面向遗留系统的渐进式迁移;如果一个“已经完成模块化”的系统还大量依赖 Open Module,通常说明模块结构仍然需要改善。
所以:
应该是:
而不是:
一个更完整的目标依赖关系 最终,一个相对健康的电商模块化单体可能是:
flowchart LR
API["Web/API"]
ORDER["Order"]
INVENTORY["Inventory"]
CUSTOMER["Customer"]
PAYMENT["Payment"]
SHIPPING["Shipping"]
NOTIFY["Notification"]
API --> ORDER
ORDER -->|"Inventory API"| INVENTORY
ORDER -->|"Customer API"| CUSTOMER
ORDER -.->|"OrderPlaced"| PAYMENT
ORDER -.->|"OrderPlaced"| SHIPPING
ORDER -.->|"OrderPlaced"| NOTIFY
PAYMENT -.->|"PaymentCompleted"| ORDER
这里最后一条:
1 PaymentCompleted → Order
需要特别理解。
在 Java 类型依赖层面,通常是:
1 2 3 4 5 6 7 order 暴露 OrderPlaced payment 引用 OrderPlaced
以及:
1 2 3 4 5 6 7 payment 暴露 PaymentCompleted order 引用 PaymentCompleted
这仍可能形成静态模块依赖环。
所以更严格的架构可能需要:
1 integration event contract
被放到合适的提供方接口中,或者重新思考业务依赖方向。
这也是 Spring Modulith verify() 非常有用的地方:
业务流程看起来是双向的,不代表代码模块依赖也应该是双向的。
Spring Modulith、JPMS 和微服务的关系 可以把三种架构理解成不同级别的边界成本。
普通单体 1 2 3 4 5 process boundary: 一个 module boundary: 基本没有
优点:
缺点:
Spring Modulith 模块化单体 1 2 3 4 5 process boundary: 一个 business module boundary: 多个
优点:
1 2 3 4 5 6 部署仍然简单 本地事务仍然简单 模块边界可验证 可以事件驱动 容易测试 更容易未来拆分
JPMS + 模块化单体 1 2 3 4 5 6 7 8 process boundary: 一个 business boundary: 多个 compiler/runtime module boundary: 多个
优点:
1 2 3 4 更强封装 编译器级依赖检查 更明确模块图 可使用 jlink 等能力
代价:
1 2 3 构建复杂度提高 反射配置增加 第三方库模块化程度需要评估
微服务 1 2 3 4 5 6 7 8 process boundary: 多个 network boundary: 多个 deployment boundary: 多个
得到:
同时也引入:
1 2 3 4 5 6 7 分布式事务 网络故障 服务治理 链路追踪 消息一致性 部署运维 数据一致性
所以:
“以后可能拆微服务”并不是今天就应该拆微服务的理由。
反而应该先把模块边界建立起来。
Java 25 项目中的推荐落地方案 如果今天新建一个 Java 25 + Spring Boot 项目,我更推荐下面的默认策略:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 Java 25 │ ├── 使用现代 Java Language / JVM 能力 │ └── JPMS 按真实需求使用 │ ▼ Spring Boot 4.1 │ ▼ Spring Modulith 2.1 │ ├── Package by Feature ├── Application Module ├── Named Interface ├── allowedDependencies ├── Domain Event ├── verify() ├── @ApplicationModuleTest ├── Documenter └── Observability
也就是默认:
1 2 3 一个 Spring Boot Maven Module + 多个 Spring Modulith Application Module
而不是一开始:
1 2 3 20 个 Maven Module + 20 个 module-info.java
什么情况下应该进一步拆 Maven Module 出现下面这些现象时,可以考虑:
1 2 3 4 5 6 模块由独立团队维护 模块需要单独发布 模块被多个应用复用 模块非常稳定 希望编译器直接阻止非法 Artifact 依赖 项目已经大到单 Maven Module 构建效率明显受影响
此时:
可以进一步映射为:
例如:
1 2 3 4 5 6 7 order ↓ shop-order.jar inventory ↓ shop-inventory.jar
不过这仍然不意味着必须使用 JPMS。
Maven 多模块:
JPMS 多模块。
什么情况下值得进一步使用 JPMS JPMS 更适合有明确需求的场景:
1 2 3 4 5 6 7 需要 JVM 级强封装 需要严格控制 exports 开发模块化 SDK / 平台 插件体系 大量 ServiceLoader 使用 希望构建定制 JRE 对运行时模块图有明确要求
如果核心诉求只是:
Spring Modulith 往往已经能够用更低成本解决主要问题。
一张表决定选什么
需求
Spring Modulith
Maven Module
JPMS
按业务领域划分模块
★★★★★
★★★
★★
检测跨业务模块调用
★★★★★
★★★★
★★★★
循环依赖检查
★★★★★
★★★★
★★★★
Spring Bean 依赖理解
★★★★★
★
★
Domain Event
★★★★★
-
-
模块集成测试
★★★★★
★★
★
自动架构文档
★★★★★
★
★
编译期强隔离
★★
★★★★
★★★★★
JVM 运行时封装
-
-
★★★★★
控制 package exports
★★★
★★
★★★★★
jlink Runtime Image
-
-
★★★★★
老 Spring 项目渐进改造
★★★★★
★★★
★★
引入成本
低
中
中到高
对于典型 Spring Boot 企业单体:
1 2 3 4 5 6 7 8 第一选择: Spring Modulith 边界进一步成熟: Spring Modulith + Maven Multi-Module 确有 JVM Module 需求: Spring Modulith + Maven Multi-Module + JPMS
通常是更自然的演进顺序。
一个健康的模块化单体应该满足什么 项目做到最后,不应该只检查:
真正应该检查的是:
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 业务模块是否按领域能力划分? 一个模块是否拥有自己的业务模型? 其他模块是否只能通过稳定 API 访问? 是否存在直接访问其他模块 Repository 的情况? 是否共享 JPA Entity? 模块依赖图是否是 DAG? 是否存在大量双向调用? 是否有 common/shared 万能模块? 同步 API 与 Domain Event 是否合理区分? 模块能否单独完成集成测试? 架构约束是否进入 CI? 数据库是否也有数据所有权边界? 模块图能否从代码自动生成? 如果未来拆服务,模块是否具有足够自治性?
如果这些答案都比较健康,那么即使整个应用最终仍然:
它也已经是一套高质量的模块化架构。
最后的架构结论 Spring Modulith 最有价值的地方,并不是给 Spring Boot 又增加了几个 Annotation。
它真正解决的是:
1 2 3 4 5 Java Package ↓ 升级为 ↓ 可理解、可验证、可测试、可观测的业务模块
Spring Modulith 负责回答:
一个 Spring Boot 单体内部,业务边界应该在哪里?
JPMS 负责回答:
不同 Java Module 之间,编译器和 JVM 应该允许什么依赖和访问?
Java 25 新增的:
则只是在语言层面简化模块导出类型的导入方式,它不会替代 module-info.java,更不会替代 Spring Modulith。JDK 25 已将这一能力正式纳入语言。
因此,在 Java 25 时代构建一个大型 Spring 单体,一个比较稳健的路线不是:
而是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 传统单体 ↓ 按业务能力重新组织 Package ↓ Spring Modulith ↓ Named Interface ↓ allowedDependencies ↓ verify() ↓ 模块集成测试 ↓ Domain Event ↓ 架构文档与 Observability ↓ 必要时 Maven Multi-Module ↓ 必要时 JPMS ↓ 业务和组织真的需要时再拆服务
这样得到的系统依然拥有单体架构最大的优势:
但不必继续承担传统大泥球单体最大的代价:
好的模块化单体并不是“还没来得及拆成微服务的系统”。
它本身就是一种完整的架构选择。
而 Spring Modulith 与 Java 25 JPMS 的最佳关系,也不是二选一:
1 2 Spring Modulith 管业务边界, JPMS 管 Java 平台边界。
先让业务边界正确,再决定需要多强的物理隔离,通常比一开始就追求最重的模块化机制更可靠。