Spring Modulith 与 Java 25 JPMS:在单体应用中构建可验证的模块化架构

当 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 最重要的抽象叫:

1
Application Module

一个 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
按业务能力切分

每个业务模块内部再自行分层

也就是所谓:

1
Package by Feature

而不是:

1
Package by Layer

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

其中:

1
com.acme.shop.order

是模块根包。

那么根包中的 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,不能覆盖子包。

例如:

1
2
order
└── internal

orderorder.internal 在 Java 看来是两个完全不同的 package。

因此为了让:

1
OrderManagement

调用:

1
order.internal.OrderApplicationService

后者往往不得不声明为 public

但只要它变成 public:

1
inventory

也可以直接 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-corestarter-jpastarter-jdbcstarter-teststarter-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

现在变成:

1
2
3
4
5
业务模块

公开接口

另一个业务模块

内部实现隐藏在自己的边界中。


用 Named Interface 精确控制暴露面

仅仅规定“模块根包可以访问”有时仍然太宽。

Spring Modulith 提供了 Named Interface。

例如库存模块只希望向外暴露:

1
inventory.api

可以创建:

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
模块名 :: Named Interface

表示精确接口。

例如:

1
inventory :: api

意味着订单模块允许:

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

注意方向。

订单需要实时知道库存是否可以预留,因此:

1
order → inventory

可以采用同步 API。

而支付初始化、发送通知、创建物流单等后续动作不一定需要成为订单主事务的一部分,因此可以围绕:

1
OrderPlaced

进行事件驱动。

这比简单规定:

所有模块之间都不许直接调用,只能发事件。

更符合实际工程。

模块化不是为了把所有方法调用改成消息队列。

真正应该考虑的是:

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

符合业务方向,并且不会进一步形成:

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:

1
能编译

Spring:

1
能注入

IDE:

1
也不会阻止

但:

1
2
3
ApplicationModules.of(
ShopApplication.class
).verify();

应该发现这种越界依赖。

这才是“架构规则”与“架构建议”的区别。

前者可以进入 CI。

后者通常只能进入 Wiki,然后安静地老去。


CI 中应该直接验证模块结构

例如:

1
./mvnw test

只要:

1
modules.verify();

属于测试套件的一部分,任何破坏模块结构的提交都会直接让构建失败。

于是架构约束可以变成:

1
2
3
4
5
6
7
8
9
10
开发者提交代码

CI

Spring Modulith verify

依赖是否合法?

├─ 是 → 继续
└─ 否 → Build Failed

这比 Code Review 中人为检查:

1
“这个 import 是不是跨模块了?”

可靠得多。


ApplicationModuleTest:不要每次都启动整个系统

普通 Spring Boot 集成测试经常使用:

1
@SpringBootTest

它倾向于加载整个 ApplicationContext。

而 Spring Modulith 提供:

1
@ApplicationModuleTest

例如:

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() {
}
}

测试类位于:

1
com.acme.shop.order

Spring Modulith 会识别当前测试针对 order 模块,并限制组件扫描、实体扫描和相关启动范围。

当前版本提供三种主要 Bootstrap Mode:

Mode 范围
STANDALONE 只启动当前模块
DIRECT_DEPENDENCIES 当前模块 + 直接依赖
ALL_DEPENDENCIES 当前模块 + 完整依赖树

默认是:

1
STANDALONE

这种测试方式有一个非常好的副作用。

如果一个模块独立启动时立即出现:

1
NoSuchBeanDefinitionException

而原因是:

1
2
3
4
5
6
7
8
9
OrderService

PaymentService

UserService

CouponService

……

那么问题往往不在测试。

而在架构。

官方文档同样指出,如果一个模块依赖大量其他模块的 Bean,这通常说明模块耦合度已经过高,应该重新考虑部分依赖是否更适合改成 Domain Event。


模块测试实际是在检测“可拆分性”

从架构角度看:

1
@ApplicationModuleTest

其实在不断回答一个问题:

如果未来我要把这个模块单独拿出来,它究竟依赖什么?

假设:

1
order

STANDALONE 模式完全无法启动,需要加入:

1
2
3
4
5
6
7
inventory
payment
customer
coupon
shipping
notification
settlement

才能运行。

这说明:

1
order

虽然目录叫一个模块,但实际上不是自治模块。

因此模块集成测试的意义不只是提高测试速度。

它实际上是一种:

1
Architecture Fitness Function

也就是持续验证架构健康程度的机制。


自动生成模块架构文档

很多架构图最终都会遇到一个问题:

1
2
代码变了
图没变

半年后架构图就成了历史文物。

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

而不是:

1
2
3
4
5
开发者

手画架构图

忘记更新

对于几十万行代码的长期项目,这种差距会非常明显。


运行时也可以观察模块

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 相关能力。

例如暴露:

1
/actuator/modulith

后,可以查看运行中的 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 解决的是:

1
Spring Boot 业务架构

JPMS 解决的是:

1
Java Module System

JPMS 中最重要的文件是:

1
module-info.java

例如:

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
inventory

内部还有:

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;
}

关系:

1
2
3
order

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;
}

意味着只有:

1
com.acme.order

可以看到该 package。


opens 和 exports 完全不是一回事

Spring、Hibernate、Jackson 等框架大量使用反射。

因此 JPMS 里还存在:

1
opens

例如:

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;
}

那么:

1
2
3
order
inventory
payment

仍然全部属于:

1
com.acme.shop

这个同一个 JPMS Module

也就是说:

1
2
3
4
5
JPMS Module
└── com.acme.shop
├── order
├── inventory
└── payment

JPMS 的 exports 控制的是:

1
2
3
com.acme.shop

其他 Module

并不是:

1
order ↔ inventory

在同一个 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 更强的是:

1
2
3
强封装
编译期依赖
运行时模块解析

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 中正式定稿。它允许一个源文件通过:

1
import module M;

一次性按需导入目标模块所导出的 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
新的 module-info.java

更不是:

1
新的架构模块机制

它本质上是:

1
Source Import Convenience

而不是:

1
Module Dependency Declaration

import module 不会创建模块边界

下面:

1
import module java.sql;

和:

1
2
3
4
module com.acme.app {

requires java.sql;
}

承担的是完全不同的职责。

前者解决:

1
源代码中如何引用类型

后者解决:

1
当前 JPMS Module 可以读取哪些 Module

可以简单记成:

1
2
3
4
5
6
7
import module

名字可见性

requires

模块可读性

所以不要看到 Java 25 出现:

1
import module

就认为 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;

因此对于大型企业项目,我更倾向于:

1
业务代码继续保持明确的普通 import

而不是为了少写几行 import,在大量源文件中广泛使用:

1
import module ...

IDE 本来就会自动管理 import。

企业项目更重要的是:

1
2
3
可读性
依赖来源清晰
代码审查友好

而不是节省 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
requires transitive

最终可能重新形成:

1
2
3
4
5
依赖传播

边界扩大

所有模块都能看到所有模块

这和建立模块边界的初衷正好相反。


Classpath、Module Path 与 Automatic Module

一个 JAR 是否真正参与 JPMS,还与它怎么运行有关。

Java 25 的 JAR 规范仍然区分:

1
2
3
Modular JAR
Automatic Module
Unnamed Module

包含:

1
module-info.class

的 JAR 放在:

1
--module-path

上时属于显式模块。

没有 module-info.class 的普通 JAR 放在 module path 上,则可能作为 Automatic Module 参与模块系统。

而 modular JAR 如果只是被放到传统:

1
-classpath

上,也不会因为自己有 module-info.class 就自动获得完整的 named-module 运行方式。

因此:

1
写了 module-info.java

和:

1
整个应用已经真正按照 JPMS 运行

仍然不能简单画等号。


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

注意:

1
JPMS

和:

1
Microservice

都不是必经终点。

一个边界清晰的 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
公开业务接口

例如:

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
模块拥有自己的模型

模块之间应该传递:

1
2
3
4
业务参数
DTO
Value Object
Event

而不是互相共享内部持久化对象。


第三阶段:加入 verify()

马上把:

1
2
3
ApplicationModules.of(
ShopApplication.class
).verify();

放进测试。

即使项目暂时还有问题,也应该尽早知道:

1
2
当前到底有多少循环依赖?
多少跨模块 internal 访问?

这相当于先给架构做一次 CT。

结果可能不太好看,但总比继续闭眼长大强。


第四阶段:通过 allowedDependencies 收紧边界

一开始:

1
order

可能能够访问:

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
allowedDependencies

越来越长。

恰恰相反。

如果出现:

1
2
order
允许依赖 12 个模块

应该开始怀疑:

1
order 是不是承担了过多编排职责?

第五阶段:消灭循环依赖

最危险的结构通常不是:

1
order → inventory

而是:

1
2
3
order → inventory
↑ ↓
└─────────┘

例如:

1
2
3
4
5
OrderService
-> InventoryService

InventoryService
-> OrderService

这种结构说明领域职责本身没有切清。

常见处理方式有:

1
2
3
4
重新调整聚合职责
提取真正的上层业务编排
建立稳定 API
将“通知型反向调用”改成 Domain Event

例如:

1
order → inventory

保留同步库存检查。

库存不再:

1
inventory → order

而是监听:

1
OrderCancelled

事件释放库存。

这样:

1
Java 类型依赖

就可以从双向变成单向。


第六阶段:模块级集成测试

为核心业务模块建立:

1
@ApplicationModuleTest

测试。

建议优先验证:

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

而不是所有业务场景都依赖:

1
@SpringBootTest

启动整个应用。


第七阶段:再考虑 Maven Multi-Module

当某些业务模块已经十分稳定:

1
2
3
order
inventory
payment

可以进一步物理拆分:

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:

1
逻辑边界

Maven Module 增加的是:

1
构建边界

但 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 仍然提供:

1
jdeps

用于分析 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 依赖

两者观察的是不同层级。


真正使用 JPMS 后,还可以进入另一个 Java 模块化能力:

1
jlink

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

和:

1
JPMS modular image

想成完全相同的打包体系。

如果项目并没有准备全面进入 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(按需) │
└───────────────────────────────────────┘

也就是说:

1
Spring Modulith

负责:

1
“我的业务应该怎么分?”

Maven / Gradle Module 负责:

1
“这些代码应该如何独立构建和声明依赖?”

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
common

变成整个系统真正的“大泥球中心”。

更好的原则是:

1
2
3
能属于具体业务模块

不要放 shared

真正适合共享的通常是非常稳定、非常基础的概念:

1
2
3
4
5
Money
PageRequest
Identifier
Clock abstraction
基础异常协议

而:

1
2
3
4
OrderDTO
PaymentStatus
InventoryEntity
CustomerService

通常都不应该因为“其他模块也想用”就被移动到 common。

共享越多,模块自治越弱。


数据库也需要模块边界

Spring Modulith 主要验证的是 Java 代码结构。

它不会自动阻止:

1
order module

直接执行:

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
模块可以共享数据库实例
但不要共享数据所有权

例如订单需要支付状态。

不应该默认:

1
2
orderRepository
-> payment_record

而应该考虑:

1
2
3
order

payment API

或者通过事件维护自己需要的业务视图。

否则:

1
Java 代码看起来模块化

但:

1
数据库层已经全部耦合

最终仍然很难拆分。


不要把 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
ReservationResult

而不需要知道:

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
创建订单

检查库存是否足够

同步:

1
order → inventory

比较合理。

而:

1
2
3
4
5
6
7
订单创建成功

通知
积分
统计
营销
履约

更适合:

1
OrderPlaced

事件。


不要把所有调用都改成 Event

事件也会增加复杂度:

1
2
3
4
5
6
7
执行顺序不再直观
调试链路变长
最终一致性
失败重试
幂等
事件版本
事务边界

如果只是:

1
Money calculatePrice(Product product)

强行设计:

1
2
3
PriceCalculationRequested

PriceCalculated

往往只会让代码更难理解。

好的模块化并不追求:

1
零依赖

而追求:

1
2
3
4
方向正确
接口稳定
边界明确
依赖可验证

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
十年历史
几千个跨包调用
无法一次重构

可以先:

1
2
3
4
5
6
7
8
9
识别模块

Open

逐步迁移

收紧 API

最终 Closed

但官方同样强调,Open Application Module 主要面向遗留系统的渐进式迁移;如果一个“已经完成模块化”的系统还大量依赖 Open Module,通常说明模块结构仍然需要改善。

所以:

1
OPEN

应该是:

1
迁移状态

而不是:

1
最终架构。

一个更完整的目标依赖关系

最终,一个相对健康的电商模块化单体可能是:

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:
基本没有

优点:

1
2
3
简单
事务直接
部署简单

缺点:

1
容易无序耦合

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
独立部署
独立扩缩容
技术自治
故障隔离

同时也引入:

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
Application Module

可以进一步映射为:

1
Maven Module

例如:

1
2
3
4
5
6
7
order

shop-order.jar

inventory

shop-inventory.jar

不过这仍然不意味着必须使用 JPMS。

Maven 多模块:

1
!=

JPMS 多模块。


什么情况下值得进一步使用 JPMS

JPMS 更适合有明确需求的场景:

1
2
3
4
5
6
7
需要 JVM 级强封装
需要严格控制 exports
开发模块化 SDK / 平台
插件体系
大量 ServiceLoader 使用
希望构建定制 JRE
对运行时模块图有明确要求

如果核心诉求只是:

1
不让业务开发跨模块乱调用 Repository

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
“有没有用 Spring 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
业务模块是否按领域能力划分?

一个模块是否拥有自己的业务模型?

其他模块是否只能通过稳定 API 访问?

是否存在直接访问其他模块 Repository 的情况?

是否共享 JPA Entity?

模块依赖图是否是 DAG?

是否存在大量双向调用?

是否有 common/shared 万能模块?

同步 API 与 Domain Event 是否合理区分?

模块能否单独完成集成测试?

架构约束是否进入 CI?

数据库是否也有数据所有权边界?

模块图能否从代码自动生成?

如果未来拆服务,模块是否具有足够自治性?

如果这些答案都比较健康,那么即使整个应用最终仍然:

1
java -jar shop.jar

它也已经是一套高质量的模块化架构。


最后的架构结论

Spring Modulith 最有价值的地方,并不是给 Spring Boot 又增加了几个 Annotation。

它真正解决的是:

1
2
3
4
5
Java Package

升级为

可理解、可验证、可测试、可观测的业务模块

Spring Modulith 负责回答:

一个 Spring Boot 单体内部,业务边界应该在哪里?

JPMS 负责回答:

不同 Java Module 之间,编译器和 JVM 应该允许什么依赖和访问?

Java 25 新增的:

1
import module ...

则只是在语言层面简化模块导出类型的导入方式,它不会替代 module-info.java,更不会替代 Spring Modulith。JDK 25 已将这一能力正式纳入语言。

因此,在 Java 25 时代构建一个大型 Spring 单体,一个比较稳健的路线不是:

1
2
3
4
5
单体

嫌大

直接微服务

而是:

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

业务和组织真的需要时再拆服务

这样得到的系统依然拥有单体架构最大的优势:

1
2
3
4
部署简单
调用简单
事务简单
调试简单

但不必继续承担传统大泥球单体最大的代价:

1
2
3
边界模糊
依赖失控
修改牵一发动全身

好的模块化单体并不是“还没来得及拆成微服务的系统”。

它本身就是一种完整的架构选择。

而 Spring Modulith 与 Java 25 JPMS 的最佳关系,也不是二选一:

1
2
Spring Modulith 管业务边界,
JPMS 管 Java 平台边界。

先让业务边界正确,再决定需要多强的物理隔离,通常比一开始就追求最重的模块化机制更可靠。


Spring Modulith 与 Java 25 JPMS:在单体应用中构建可验证的模块化架构
https://allendericdalexander.github.io/2026/08/16/java/spring/Spring-Modulith-Java-25-JPMS/
作者
AtLuoFu
发布于
2026年8月16日
许可协议