基于 Spring Boot 3 的电商模块化单体实战:整合 ArchUnit、Spring Modulith 与 jMolecules

欢迎你来读这篇博客,这篇博客主要围绕 Spring Boot 3 电商系统,深入讲解如何把 ArchUnit、Spring Modulith、jMolecules 三个工具整合到一个可落地的模块化单体架构中。

如果只看单个工具,ArchUnit 像“架构测试工具”,Spring Modulith 像“模块化单体治理框架”,jMolecules 像“DDD 语义标记库”。但真正有价值的地方,不是把三个依赖加进 pom.xml,而是把它们组合成一套持续生效的工程治理机制:

  • Spring Modulith:定义并验证业务模块边界;
  • jMolecules:把聚合根、值对象、领域事件、仓储、层次结构这些架构语义显式写进代码;
  • ArchUnit:把团队约定的架构红线变成自动化测试,在 CI 中持续拦截架构腐化。

序言

很多 Spring Boot 项目一开始都很清爽:

1
controller -> service -> mapper -> database

但项目做大之后,尤其是电商、财务、库存、订单这类业务系统,很容易变成:

1
2
3
4
5
6
OrderService 调 InventoryMapper
InventoryService 调 PaymentService
PaymentListener 反查 OrderController
PromotionService 直接改 OrderEntity
Controller 直接注入 Repository
Domain 对象里塞满 Spring、JPA、MyBatis 注解

最后不是单体,而是一锅“模块乱炖”。

拆微服务能解决吗?不一定。单体内部边界没想清楚,拆成微服务后只是把本地方法调用换成 RPC,把一锅乱炖变成分布式乱炖。锅更多了,洗锅的人也更多了。

更稳的路线是:

1
普通单体 -> 模块化单体 -> 边界稳定 -> 按需拆微服务

本文以一个电商系统为例,设计一个模块化单体:

  • 商品模块:catalog
  • 客户模块:customer
  • 促销模块:promotion
  • 库存模块:inventory
  • 订单模块:order
  • 支付模块:payment
  • 履约/物流模块:shipping
  • 共享内核:shared

然后把 ArchUnit、Spring Modulith、jMolecules 串起来,形成一套能在真实项目里使用的架构治理方案。

正文

一、三个工具分别负责什么?

先不要急着上代码。三者的边界要分清楚,否则很容易出现“依赖加了,架构还是乱”的情况。

工具 核心定位 在本文中的职责
Spring Modulith Spring Boot 模块化单体治理工具 识别应用模块、验证模块依赖、保护 internal、模块测试、生成模块文档、事件发布注册表
jMolecules 架构语义表达库 用注解或类型表达 DDD、事件、分层架构、CQRS 等架构角色
ArchUnit Java 架构测试工具 自定义架构规则,检查包依赖、层依赖、命名规范、领域层纯净性、模块内部访问规则

可以用一个三角关系理解:

flowchart TB
    A[Spring Modulith<br/>模块边界治理] --> D[模块化单体架构]
    B[jMolecules<br/>架构语义显式化] --> D
    C[ArchUnit<br/>自动化架构测试] --> D

    A --> A1[识别模块]
    A --> A2[验证 allowedDependencies]
    A --> A3[保护 internal]
    A --> A4[模块文档]
    A --> A5[模块级测试]

    B --> B1[@AggregateRoot]
    B --> B2[@ValueObject]
    B --> B3[@DomainEvent]
    B --> B4[@Repository]
    B --> B5[@DomainLayer]

    C --> C1[禁止 Controller 直连 Repository]
    C --> C2[禁止 domain 依赖 Spring/JPA]
    C --> C3[禁止跨模块访问 internal]
    C --> C4[禁止循环依赖]

一句话总结:

Spring Modulith 管模块,jMolecules 给代码贴架构身份证,ArchUnit 负责持续执法。

这三者结合起来,才是真正能长期防止架构腐化的组合。

二、版本选择:基于 Spring Boot 3 的推荐组合

本文面向 Spring Boot 3.x,推荐使用下面这组版本思路:

技术 推荐版本线 说明
JDK 21 Spring Boot 3.x 推荐使用现代 JDK,JDK 21 更适合长期项目
Spring Boot 3.5.x 本文以 Spring Boot 3.5.x 为主线
Spring Modulith 1.4.x Spring Modulith 1.4.x 主要面向 Spring Boot 3.5 兼容线
ArchUnit 1.4.x 架构测试库,测试作用域即可
jMolecules BOM 2025.0.x 管理 jMolecules DDD、events、architecture 等模块版本

注意:Spring Modulith 2.x 已经进入 Spring Boot 4.x / Spring Framework 7.x 时代。本文既然基于 Spring Boot 3,就优先采用 Spring Modulith 1.4.x 这条线。不要为了“版本数字看起来新”硬上不匹配的组合,架构治理工具不是显卡驱动,不是越新越香。

三、完整 Maven 依赖

下面是一份可以直接落到 Spring Boot 3 电商项目里的 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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
<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.14</version>
<relativePath/>
</parent>

<groupId>com.cybermall</groupId>
<artifactId>cybermall-modulith</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>cybermall-modulith</name>

<properties>
<java.version>21</java.version>
<spring-modulith.version>1.4.12</spring-modulith.version>
<jmolecules.version>2025.0.2</jmolecules.version>
<archunit.version>1.4.2</archunit.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>

<dependency>
<groupId>org.jmolecules</groupId>
<artifactId>jmolecules-bom</artifactId>
<version>${jmolecules.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<!-- Spring Boot 基础能力 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<!-- Spring Modulith 核心:模块识别、验证、事件基础能力 -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-core</artifactId>
</dependency>

<!-- Spring Modulith JDBC 事件发布注册表:用于可靠记录模块事件投递情况 -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jdbc</artifactId>
</dependency>

<!-- Spring Modulith 运行时洞察:Actuator + Observability -->
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-insight</artifactId>
<scope>runtime</scope>
</dependency>

<!-- jMolecules:DDD 建模语义 -->
<dependency>
<groupId>org.jmolecules</groupId>
<artifactId>jmolecules-ddd</artifactId>
</dependency>

<!-- jMolecules:领域事件语义 -->
<dependency>
<groupId>org.jmolecules</groupId>
<artifactId>jmolecules-events</artifactId>
</dependency>

<!-- jMolecules:分层架构语义 -->
<dependency>
<groupId>org.jmolecules</groupId>
<artifactId>jmolecules-layered-architecture</artifactId>
</dependency>

<!-- jMolecules:CQRS 语义,可选,但电商命令/查询模型很适合使用 -->
<dependency>
<groupId>org.jmolecules</groupId>
<artifactId>jmolecules-cqrs-architecture</artifactId>
</dependency>

<!-- 数据库驱动,实际项目换成自己的数据库 -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>

<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>${archunit.version}</version>
<scope>test</scope>
</dependency>

<!-- 可选:jMolecules 与 ArchUnit 的预置规则。本文主要使用自定义规则,更便于团队理解和维护。 -->
<dependency>
<groupId>org.jmolecules.integrations</groupId>
<artifactId>jmolecules-archunit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

如果你的项目不使用 JPA,而是 MyBatis / MyBatis-Plus,也没问题。本文的重点不是 JPA,而是模块边界和架构规则。JPA 只是为了让示例完整。

四、电商模块设计

本文示例项目名叫 cybermall-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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
src/main/java/com/cybermall
├── MallApplication.java
├── shared
│ ├── kernel
│ │ ├── Money.java
│ │ ├── Quantity.java
│ │ └── BusinessReference.java
│ └── package-info.java
├── customer
│ ├── CustomerApi.java
│ ├── CustomerId.java
│ ├── package-info.java
│ └── internal
│ ├── application
│ ├── domain
│ ├── infrastructure
│ └── adapter
├── catalog
│ ├── ProductCatalogApi.java
│ ├── ProductId.java
│ ├── ProductSnapshot.java
│ ├── package-info.java
│ └── internal
├── promotion
│ ├── PromotionApi.java
│ ├── package-info.java
│ └── internal
├── inventory
│ ├── InventoryApi.java
│ ├── InventoryReserveCommand.java
│ ├── package-info.java
│ └── internal
├── order
│ ├── OrderApi.java
│ ├── OrderId.java
│ ├── PlaceOrderCommand.java
│ ├── events
│ │ ├── OrderPlacedEvent.java
│ │ └── package-info.java
│ ├── package-info.java
│ └── internal
│ ├── adapter
│ │ └── web
│ ├── application
│ ├── domain
│ └── infrastructure
├── payment
│ ├── PaymentApi.java
│ ├── package-info.java
│ └── internal
└── shipping
├── ShippingApi.java
├── package-info.java
└── internal

这里有一个重要规则:

每个业务模块根包下放对外 API、DTO、事件;internal 下放内部实现,其他模块不允许访问。

比如:

1
2
3
4
5
6
order.OrderApi                          允许其他模块访问
order.PlaceOrderCommand 允许其他模块访问
order.events.OrderPlacedEvent 允许被订阅方访问
order.internal.domain.Order 不允许其他模块访问
order.internal.application.OrderService 不允许其他模块访问
order.internal.infrastructure.* 不允许其他模块访问

模块依赖关系如下:

flowchart LR
    order[order 订单模块]
    catalog[catalog 商品模块]
    customer[customer 客户模块]
    promotion[promotion 促销模块]
    inventory[inventory 库存模块]
    payment[payment 支付模块]
    shipping[shipping 履约模块]
    shared[shared kernel 共享内核]

    order --> catalog
    order --> customer
    order --> promotion
    order --> inventory
    order --> shared

    catalog --> shared
    customer --> shared
    promotion --> shared
    inventory --> shared

    payment --> orderEvents[order :: events]
    shipping --> orderEvents
    payment --> shared
    shipping --> shared

注意,订单模块不直接依赖支付模块。订单创建后发布 OrderPlacedEvent,支付模块监听事件并创建支付单。这样可以避免:

1
2
order -> payment
payment -> order

这种循环依赖一旦出现,后面拆服务基本就要开始“考古挖坟”。

五、使用 Spring Modulith 定义模块边界

Spring Modulith 默认会把 Spring Boot 主启动类所在包下的一级子包识别为应用模块。例如:

1
2
3
4
5
6
7
8
9
10
11
12
package com.cybermall;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MallApplication {

public static void main(String[] args) {
SpringApplication.run(MallApplication.class, args);
}
}

此时 com.cybermall.ordercom.cybermall.catalogcom.cybermall.inventory 等一级子包都会被识别为模块。

5.1 order 模块定义

src/main/java/com/cybermall/order/package-info.java

1
2
3
4
5
6
7
8
9
10
11
12
13
@ApplicationModule(
displayName = "Order",
allowedDependencies = {
"catalog",
"customer",
"promotion",
"inventory",
"shared :: kernel"
}
)
package com.cybermall.order;

import org.springframework.modulith.ApplicationModule;

这表示:

  • order 可以依赖 catalog
  • order 可以依赖 customer
  • order 可以依赖 promotion
  • order 可以依赖 inventory
  • order 可以依赖 shared 模块暴露的 kernel 命名接口;
  • order 不应该依赖 payment
  • order 不应该依赖 shipping
  • order 不应该访问任何其他模块的 internal 包。

5.2 shared kernel 命名接口

shared 模块不应该变成垃圾桶。建议只放非常稳定、跨模块共享的基础概念,比如金额、数量、业务引用。

1
2
3
4
5
shared
└── kernel
├── Money.java
├── Quantity.java
└── BusinessReference.java

src/main/java/com/cybermall/shared/kernel/package-info.java

1
2
3
4
@NamedInterface("kernel")
package com.cybermall.shared.kernel;

import org.springframework.modulith.NamedInterface;

这样其他模块可以通过:

1
allowedDependencies = "shared :: kernel"

依赖共享内核,而不是依赖整个 shared 模块。

5.3 order 事件命名接口

支付、履约模块需要监听订单事件,但不应该依赖订单模块内部实现。

src/main/java/com/cybermall/order/events/package-info.java

1
2
3
4
@NamedInterface("events")
package com.cybermall.order.events;

import org.springframework.modulith.NamedInterface;

payment 模块只允许依赖订单事件:

1
2
3
4
5
6
7
8
9
10
@ApplicationModule(
displayName = "Payment",
allowedDependencies = {
"order :: events",
"shared :: kernel"
}
)
package com.cybermall.payment;

import org.springframework.modulith.ApplicationModule;

这样支付模块可以监听 OrderPlacedEvent,但不能访问:

1
2
3
order.internal.domain.Order
order.internal.application.DefaultOrderApi
order.internal.infrastructure.JpaOrderRepository

这就是模块边界。

六、使用 jMolecules 表达 DDD 语义

jMolecules 的价值不是“让代码跑起来”,而是让代码自己说明架构角色。

普通代码:

1
2
public class Order {
}

别人不知道它是实体、聚合根、DTO,还是数据库对象。

使用 jMolecules 后:

1
2
3
@AggregateRoot
public class Order {
}

它的角色就清楚了:这是一个聚合根。

6.1 共享值对象:Money

src/main/java/com/cybermall/shared/kernel/Money.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
package com.cybermall.shared.kernel;

import org.jmolecules.ddd.annotation.ValueObject;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
import java.util.Objects;

@ValueObject
public record Money(BigDecimal amount, Currency currency) {

public Money {
Objects.requireNonNull(amount, "amount must not be null");
Objects.requireNonNull(currency, "currency must not be null");

amount = amount.setScale(2, RoundingMode.HALF_UP);

if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("金额不能为负数");
}
}

public static Money yuan(String amount) {
return new Money(new BigDecimal(amount), Currency.getInstance("CNY"));
}

public Money add(Money other) {
assertSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}

public Money subtract(Money other) {
assertSameCurrency(other);
return new Money(this.amount.subtract(other.amount), this.currency);
}

public Money multiply(int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("数量必须大于 0");
}
return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
}

private void assertSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("币种不一致");
}
}
}

这里 Money 是值对象,它应该:

  • 不依赖 Spring;
  • 不依赖数据库;
  • 不依赖 Web;
  • 尽量不可变;
  • 把金额计算逻辑封装在自己内部。

不要把金额写成:

1
2
BigDecimal amount;
String currency;

这种写法后期很容易出现精度、币种、负数、舍入规则散落到各个 Service 里的问题。钱这种东西,散养会咬人。

6.2 订单 ID

src/main/java/com/cybermall/order/OrderId.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.cybermall.order;

import org.jmolecules.ddd.annotation.ValueObject;
import org.jmolecules.ddd.types.Identifier;

@ValueObject
public record OrderId(Long value) implements Identifier {

public OrderId {
if (value == null || value <= 0) {
throw new IllegalArgumentException("订单 ID 非法");
}
}

public static OrderId of(Long value) {
return new OrderId(value);
}
}

为什么不用裸 Long

因为电商系统里至少会有:

1
2
3
4
5
6
OrderId
ProductId
CustomerId
PaymentId
SkuId
CouponId

它们底层都可能是 Long。如果全用 Long,方法签名很容易传错,编译器不会救你。

1
pay(Long customerId, Long orderId); // 这是什么魔法数组?

使用专用 ID 类型后:

1
pay(CustomerId customerId, OrderId orderId);

语义就清楚很多。

6.3 下单命令

src/main/java/com/cybermall/order/PlaceOrderCommand.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
package com.cybermall.order;

import com.cybermall.catalog.ProductId;
import com.cybermall.customer.CustomerId;
import org.jmolecules.architecture.cqrs.Command;

import java.util.List;

@Command
public record PlaceOrderCommand(
CustomerId customerId,
List<Item> items,
String couponCode
) {

public PlaceOrderCommand {
if (customerId == null) {
throw new IllegalArgumentException("客户不能为空");
}
if (items == null || items.isEmpty()) {
throw new IllegalArgumentException("订单明细不能为空");
}
}

public record Item(ProductId productId, int quantity) {
public Item {
if (productId == null) {
throw new IllegalArgumentException("商品不能为空");
}
if (quantity <= 0) {
throw new IllegalArgumentException("数量必须大于 0");
}
}
}
}

这里的 @Command 只是架构语义,不会改变运行时行为。它的价值在于:

  • 文档生成时能识别命令对象;
  • ArchUnit 可以检查命令对象位置;
  • 团队读代码时知道这是写操作入口参数。

6.4 订单聚合根

src/main/java/com/cybermall/order/internal/domain/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
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
package com.cybermall.order.internal.domain;

import com.cybermall.customer.CustomerId;
import com.cybermall.order.OrderId;
import com.cybermall.shared.kernel.Money;
import org.jmolecules.ddd.annotation.AggregateRoot;
import org.jmolecules.ddd.annotation.Identity;

import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;

@AggregateRoot
public class Order {

@Identity
private final OrderId id;

private final CustomerId customerId;

private final List<OrderLine> lines;

private Money totalAmount;

private OrderStatus status;

private final Instant createdAt;

private Order(OrderId id,
CustomerId customerId,
List<OrderLine> lines,
Money totalAmount,
OrderStatus status,
Instant createdAt) {
this.id = Objects.requireNonNull(id);
this.customerId = Objects.requireNonNull(customerId);
this.lines = new ArrayList<>(Objects.requireNonNull(lines));
this.totalAmount = Objects.requireNonNull(totalAmount);
this.status = Objects.requireNonNull(status);
this.createdAt = Objects.requireNonNull(createdAt);

if (this.lines.isEmpty()) {
throw new IllegalArgumentException("订单明细不能为空");
}
}

public static Order place(OrderId id,
CustomerId customerId,
List<OrderLine> lines,
Money totalAmount) {
return new Order(
id,
customerId,
lines,
totalAmount,
OrderStatus.WAITING_PAYMENT,
Instant.now()
);
}

public void markPaid() {
if (status != OrderStatus.WAITING_PAYMENT) {
throw new IllegalStateException("只有待支付订单可以支付");
}
this.status = OrderStatus.PAID;
}

public void cancel() {
if (status == OrderStatus.PAID) {
throw new IllegalStateException("已支付订单不能直接取消,需要走退款流程");
}
this.status = OrderStatus.CANCELED;
}

public OrderId id() {
return id;
}

public CustomerId customerId() {
return customerId;
}

public List<OrderLine> lines() {
return Collections.unmodifiableList(lines);
}

public Money totalAmount() {
return totalAmount;
}

public OrderStatus status() {
return status;
}

public Instant createdAt() {
return createdAt;
}
}

注意:这里的 Order 没有 @Entity@Table@Column,也没有 Spring 注解。

这是一个纯领域模型。

领域模型不直接等于数据库模型。真实项目里,尤其是复杂电商系统,我更建议:

1
2
3
领域对象:表达业务规则
持久化对象:表达数据库表结构
Mapper:负责两者转换

不要一上来就让领域对象背着数据库表结构跑步。跑一会儿它就累成 Hibernate 骆驼了。

6.5 订单明细值对象

src/main/java/com/cybermall/order/internal/domain/OrderLine.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.cybermall.order.internal.domain;

import com.cybermall.catalog.ProductId;
import com.cybermall.shared.kernel.Money;
import org.jmolecules.ddd.annotation.ValueObject;

@ValueObject
public record OrderLine(
ProductId productId,
String productName,
int quantity,
Money unitPrice,
Money lineAmount
) {

public OrderLine {
if (productId == null) {
throw new IllegalArgumentException("商品 ID 不能为空");
}
if (quantity <= 0) {
throw new IllegalArgumentException("数量必须大于 0");
}
if (unitPrice == null || lineAmount == null) {
throw new IllegalArgumentException("金额不能为空");
}
}

public static OrderLine of(ProductId productId, String productName, int quantity, Money unitPrice) {
return new OrderLine(productId, productName, quantity, unitPrice, unitPrice.multiply(quantity));
}
}

6.6 订单仓储接口

src/main/java/com/cybermall/order/internal/domain/Orders.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.cybermall.order.internal.domain;

import com.cybermall.order.OrderId;
import org.jmolecules.ddd.annotation.Repository;

import java.util.Optional;

@Repository
public interface Orders {

Order save(Order order);

Optional<Order> findById(OrderId orderId);
}

这里的 @Repository 是 jMolecules 的领域语义,不是 Spring 的 @Repository

它表达的是:这是一个领域仓储抽象。

真正的 Spring Bean 实现放到 infrastructure 里。

七、分层架构语义:让 package-info.java 也能说话

jMolecules 不只能标记类,还可以标记包。

比如订单模块内部采用:

1
2
3
adapter.web -> application -> domain
infrastructure -> domain
application -> domain

可以给每层加上架构语义。

src/main/java/com/cybermall/order/internal/domain/package-info.java

1
2
3
4
@DomainLayer
package com.cybermall.order.internal.domain;

import org.jmolecules.architecture.layered.DomainLayer;

src/main/java/com/cybermall/order/internal/application/package-info.java

1
2
3
4
@ApplicationLayer
package com.cybermall.order.internal.application;

import org.jmolecules.architecture.layered.ApplicationLayer;

src/main/java/com/cybermall/order/internal/infrastructure/package-info.java

1
2
3
4
@InfrastructureLayer
package com.cybermall.order.internal.infrastructure;

import org.jmolecules.architecture.layered.InfrastructureLayer;

src/main/java/com/cybermall/order/internal/adapter/web/package-info.java

1
2
3
4
@InterfaceLayer
package com.cybermall.order.internal.adapter.web;

import org.jmolecules.architecture.layered.InterfaceLayer;

这有什么用?

第一,读代码的人知道每个包的架构角色。

第二,Spring Modulith 文档生成和模块分析时能获得更多架构语义。

第三,ArchUnit 可以基于这些语义写规则。

八、订单模块 API 设计

模块根包下是公开 API。

src/main/java/com/cybermall/order/OrderApi.java

1
2
3
4
5
6
package com.cybermall.order;

public interface OrderApi {

OrderId placeOrder(PlaceOrderCommand command);
}

这个接口可以被 Web Controller 调,也可以被其他模块调。

但要注意:其他模块调用 OrderApi 是强耦合同步调用,不能滥用。模块之间调用优先级建议是:

1
2
3
4
查询/校验型调用:API 同步调用
状态变更后的通知:事件异步解耦
跨模块强一致事务:谨慎使用同步调用,最好限制在模块化单体内部
未来要拆微服务的边界:优先事件或防腐 API

九、订单下单服务:同步 API + 领域事件

src/main/java/com/cybermall/order/internal/application/DefaultOrderApi.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
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
package com.cybermall.order.internal.application;

import com.cybermall.catalog.ProductCatalogApi;
import com.cybermall.catalog.ProductSnapshot;
import com.cybermall.inventory.InventoryApi;
import com.cybermall.inventory.InventoryReserveCommand;
import com.cybermall.order.OrderApi;
import com.cybermall.order.OrderId;
import com.cybermall.order.PlaceOrderCommand;
import com.cybermall.order.events.OrderPlacedEvent;
import com.cybermall.order.internal.domain.Order;
import com.cybermall.order.internal.domain.OrderLine;
import com.cybermall.order.internal.domain.Orders;
import com.cybermall.promotion.PromotionApi;
import com.cybermall.shared.kernel.BusinessReference;
import com.cybermall.shared.kernel.Money;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.List;

@Service
class DefaultOrderApi implements OrderApi {

private final Orders orders;
private final ProductCatalogApi productCatalogApi;
private final PromotionApi promotionApi;
private final InventoryApi inventoryApi;
private final ApplicationEventPublisher events;
private final OrderIdGenerator orderIdGenerator;

DefaultOrderApi(Orders orders,
ProductCatalogApi productCatalogApi,
PromotionApi promotionApi,
InventoryApi inventoryApi,
ApplicationEventPublisher events,
OrderIdGenerator orderIdGenerator) {
this.orders = orders;
this.productCatalogApi = productCatalogApi;
this.promotionApi = promotionApi;
this.inventoryApi = inventoryApi;
this.events = events;
this.orderIdGenerator = orderIdGenerator;
}

@Override
@Transactional
public OrderId placeOrder(PlaceOrderCommand command) {
OrderId orderId = orderIdGenerator.nextId();

List<OrderLine> lines = command.items().stream()
.map(item -> {
ProductSnapshot product = productCatalogApi.getProduct(item.productId());
return OrderLine.of(
product.productId(),
product.productName(),
item.quantity(),
product.salePrice()
);
})
.toList();

Money originAmount = lines.stream()
.map(OrderLine::lineAmount)
.reduce(Money.yuan("0"), Money::add);

Money finalAmount = promotionApi.calculateFinalAmount(
command.customerId(),
originAmount,
command.couponCode()
);

Order order = Order.place(orderId, command.customerId(), lines, finalAmount);

orders.save(order);

// 库存预占:这里使用同步调用,是因为库存预占通常需要在下单阶段给用户明确结果。
inventoryApi.reserve(new InventoryReserveCommand(
BusinessReference.of("ORDER", orderId.value().toString()),
lines.stream()
.map(line -> new InventoryReserveCommand.Item(line.productId(), line.quantity()))
.toList()
));

// 订单已创建事件:支付单创建、履约初始化、营销埋点等适合通过事件解耦。
events.publishEvent(new OrderPlacedEvent(
orderId,
command.customerId(),
finalAmount,
Instant.now()
));

return orderId;
}
}

这里有一个很关键的设计点。

库存预占用了同步调用,因为对下单来说,库存是否预占成功通常是用户立即需要知道的结果。

支付单创建、履约初始化、通知、营销埋点可以用事件异步解耦,因为它们不应该反向拖住订单模块。

不要为了“解耦”把所有东西都事件化。事件不是银弹,滥用之后就是“全链路靠猜”。

十、订单领域事件

src/main/java/com/cybermall/order/events/OrderPlacedEvent.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.cybermall.order.events;

import com.cybermall.customer.CustomerId;
import com.cybermall.order.OrderId;
import com.cybermall.shared.kernel.Money;
import org.jmolecules.event.annotation.DomainEvent;

import java.time.Instant;

@DomainEvent
public record OrderPlacedEvent(
OrderId orderId,
CustomerId customerId,
Money totalAmount,
Instant occurredAt
) {
}

这个事件放在 order.events 命名接口里,允许 paymentshipping 等模块订阅。

但事件设计要克制:

  • 事件是事实,不是命令;
  • OrderPlacedEvent 表示“订单已创建”,不是“请支付模块创建支付单”;
  • 事件字段要稳定,别动不动暴露整个 Order 聚合;
  • 事件最好使用不可变 record;
  • 跨模块事件不要带 JPA Entity。

十一、支付模块监听订单事件

src/main/java/com/cybermall/payment/internal/application/OrderPlacedPaymentListener.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
package com.cybermall.payment.internal.application;

import com.cybermall.order.events.OrderPlacedEvent;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;

@Component
class OrderPlacedPaymentListener {

private final PaymentService paymentService;

OrderPlacedPaymentListener(PaymentService paymentService) {
this.paymentService = paymentService;
}

@ApplicationModuleListener
void on(OrderPlacedEvent event) {
paymentService.createPaymentForOrder(
event.orderId(),
event.customerId(),
event.totalAmount()
);
}
}

@ApplicationModuleListener 是 Spring Modulith 在模块事件场景里非常常用的注解。它比普通 @EventListener 更适合模块间事件,因为它和 Spring Modulith 的事件发布注册表、事务边界、异步监听等机制配合更好。

推荐策略:

1
2
3
模块内部普通事件:@EventListener
模块之间业务事件:@ApplicationModuleListener
需要外发 MQ 的事件:Spring Modulith event externalization / Outbox / MQ

十二、持久化适配:领域对象与 JPA Entity 分离

为了让领域模型保持干净,本文采用:

1
2
3
4
Order 领域聚合根
OrderJpaEntity 持久化模型
OrderMapper 转换器
JpaOrders 仓储实现

src/main/java/com/cybermall/order/internal/infrastructure/persistence/OrderJpaEntity.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
package com.cybermall.order.internal.infrastructure.persistence;

import jakarta.persistence.CollectionTable;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "mall_order")
class OrderJpaEntity {

@Id
private Long id;

private Long customerId;

private BigDecimal totalAmount;

private String currency;

private String status;

private Instant createdAt;

@ElementCollection
@CollectionTable(name = "mall_order_line", joinColumns = @JoinColumn(name = "order_id"))
private List<OrderLineJpaValue> lines = new ArrayList<>();

protected OrderJpaEntity() {
}

// getter/setter 省略,真实项目建议用 MapStruct 或手写转换
}

src/main/java/com/cybermall/order/internal/infrastructure/persistence/SpringDataOrderJpaRepository.java

1
2
3
4
5
6
package com.cybermall.order.internal.infrastructure.persistence;

import org.springframework.data.jpa.repository.JpaRepository;

interface SpringDataOrderJpaRepository extends JpaRepository<OrderJpaEntity, Long> {
}

src/main/java/com/cybermall/order/internal/infrastructure/persistence/JpaOrders.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
package com.cybermall.order.internal.infrastructure.persistence;

import com.cybermall.order.OrderId;
import com.cybermall.order.internal.domain.Order;
import com.cybermall.order.internal.domain.Orders;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
class JpaOrders implements Orders {

private final SpringDataOrderJpaRepository repository;
private final OrderPersistenceMapper mapper;

JpaOrders(SpringDataOrderJpaRepository repository, OrderPersistenceMapper mapper) {
this.repository = repository;
this.mapper = mapper;
}

@Override
public Order save(Order order) {
OrderJpaEntity entity = mapper.toEntity(order);
OrderJpaEntity saved = repository.save(entity);
return mapper.toDomain(saved);
}

@Override
public Optional<Order> findById(OrderId orderId) {
return repository.findById(orderId.value()).map(mapper::toDomain);
}
}

重点:

  • Orders 是领域仓储接口;
  • JpaOrders 是基础设施实现;
  • OrderJpaEntity 不暴露给 application/domain;
  • Controller 也不能直接碰 SpringDataOrderJpaRepository

这套规则后面会交给 ArchUnit 自动检查。

十三、Spring Modulith 验证模块结构

添加测试:

src/test/java/com/cybermall/architecture/ModulithStructureTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.cybermall.architecture;

import com.cybermall.MallApplication;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;

class ModulithStructureTest {

@Test
void verifyApplicationModules() {
ApplicationModules modules = ApplicationModules.of(MallApplication.class);
modules.verify();
}

@Test
void printApplicationModules() {
ApplicationModules modules = ApplicationModules.of(MallApplication.class);
System.out.println(modules);
}
}

运行:

1
mvn -Dtest=ModulithStructureTest test

如果出现这些问题,测试会失败:

  • 模块之间存在循环依赖;
  • 某模块访问了其他模块的 internal 包;
  • 配置了 allowedDependencies 后出现非法依赖;
  • 命名接口访问不符合规则。

比如 payment 里有人写了:

1
import com.cybermall.order.internal.domain.Order;

这就会被 Spring Modulith 拦下来。

这就是架构治理的爽点:不是靠你每天 code review 里喊“别这么写”,而是测试直接红。

十四、生成模块文档

Spring Modulith 可以生成模块文档和 PlantUML 图。

src/test/java/com/cybermall/architecture/ModulithDocumentationTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.cybermall.architecture;

import com.cybermall.MallApplication;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.docs.Documenter;

class ModulithDocumentationTest {

@Test
void writeDocumentation() {
ApplicationModules modules = ApplicationModules.of(MallApplication.class);

new Documenter(modules)
.writeDocumentation()
.writeIndividualModulesAsPlantUml();
}
}

运行:

1
mvn -Dtest=ModulithDocumentationTest test

生成目录通常在:

1
target/spring-modulith-docs

这对团队协作非常有用。新同事进项目,不用先问:“哥,这个订单能不能调支付?”

看模块图就知道。

十五、模块级集成测试

普通 @SpringBootTest 经常启动整个应用上下文,项目大了以后会慢。

Spring Modulith 提供 @ApplicationModuleTest,可以围绕模块做集成测试。

src/test/java/com/cybermall/order/OrderModuleTest.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
package com.cybermall.order;

import com.cybermall.order.events.OrderPlacedEvent;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.modulith.test.PublishedEvents;

import static org.assertj.core.api.Assertions.assertThat;

@ApplicationModuleTest
class OrderModuleTest {

@Autowired
OrderApi orderApi;

@Test
void shouldPublishOrderPlacedEvent(PublishedEvents events) {
PlaceOrderCommand command = TestPlaceOrderCommands.defaultCommand();

OrderId orderId = orderApi.placeOrder(command);

assertThat(orderId).isNotNull();
assertThat(events.ofType(OrderPlacedEvent.class))
.anySatisfy(event -> assertThat(event.orderId()).isEqualTo(orderId));
}
}

这个测试关注的是:

  • 订单模块能否完成下单;
  • 下单后是否发布 OrderPlacedEvent
  • 事件内容是否正确。

它不是端到端测试,也不是 Controller 测试,而是模块级测试。

十六、使用 ArchUnit 加强架构规则

Spring Modulith 能检查模块边界,但项目里还有很多团队自定义规则需要 ArchUnit。

比如:

  • Controller 不能直接访问 Repository;
  • domain 层不能依赖 Spring、JPA、MyBatis;
  • 聚合根不能被标记为 JPA Entity;
  • 领域仓储接口必须是接口;
  • 各模块不能跨越 internal
  • adapter 不能访问其他模块 infrastructure;
  • DTO 不能进入 domain。

16.1 基础 ArchUnit 配置

src/test/java/com/cybermall/architecture/ArchitectureRulesTest.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
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
112
113
114
115
116
117
118
119
120
121
package com.cybermall.architecture;

import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import org.junit.jupiter.api.Test;

import java.util.List;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;

class ArchitectureRulesTest {

private static final String BASE_PACKAGE = "com.cybermall";

private final JavaClasses classes = new ClassFileImporter()
.importPackages(BASE_PACKAGE);

private static final List<String> MODULES = List.of(
"shared",
"customer",
"catalog",
"promotion",
"inventory",
"order",
"payment",
"shipping"
);

@Test
void modulesShouldBeFreeOfCycles() {
slices()
.matching("com.cybermall.(*)..")
.should()
.beFreeOfCycles()
.check(classes);
}

@Test
void noModuleShouldAccessOtherModuleInternalPackages() {
for (String module : MODULES) {
noClasses()
.that()
.resideOutsideOfPackage("com.cybermall." + module + "..")
.should()
.dependOnClassesThat()
.resideInAPackage("com.cybermall." + module + ".internal..")
.because("其他模块不能访问 " + module + " 模块的 internal 包")
.check(classes);
}
}

@Test
void domainShouldNotDependOnTechnicalFrameworks() {
noClasses()
.that()
.resideInAPackage("..internal.domain..")
.should()
.dependOnClassesThat()
.resideInAnyPackage(
"org.springframework..",
"jakarta.persistence..",
"javax.persistence..",
"org.hibernate..",
"com.baomidou.mybatisplus..",
"..internal.adapter..",
"..internal.infrastructure.."
)
.because("领域层应该保持纯净,不应该依赖 Spring、JPA、MyBatis 或适配器实现")
.check(classes);
}

@Test
void webAdapterShouldNotAccessRepositoryDirectly() {
noClasses()
.that()
.resideInAPackage("..internal.adapter.web..")
.should()
.dependOnClassesThat()
.resideInAnyPackage(
"..internal.infrastructure..",
"..repository.."
)
.because("Controller 只能调用应用服务或模块 API,不能直接访问 Repository")
.check(classes);
}

@Test
void aggregateRootsShouldStayInDomainLayer() {
classes()
.that()
.areAnnotatedWith(org.jmolecules.ddd.annotation.AggregateRoot.class)
.should()
.resideInAPackage("..internal.domain..")
.because("聚合根应该属于领域层")
.check(classes);
}

@Test
void aggregateRootsShouldNotBeJpaEntities() {
noClasses()
.that()
.areAnnotatedWith(org.jmolecules.ddd.annotation.AggregateRoot.class)
.should()
.beAnnotatedWith(jakarta.persistence.Entity.class)
.because("领域聚合根不应该直接承担 JPA Entity 职责")
.check(classes);
}

@Test
void domainRepositoriesShouldBeInterfaces() {
classes()
.that()
.areAnnotatedWith(org.jmolecules.ddd.annotation.Repository.class)
.should()
.beInterfaces()
.because("领域仓储应该是接口,基础设施层负责实现")
.check(classes);
}
}

这组规则非常实用。

它不仅检查“模块有没有乱依赖”,还检查“DDD 分层有没有被破坏”。

如果有人在 Order 聚合里加了:

1
2
@Autowired
private RedisTemplate<String, Object> redisTemplate;

测试会失败。

如果有人在 Controller 里直接注入:

1
private final SpringDataOrderJpaRepository repository;

测试也会失败。

这就是 ArchUnit 的价值:架构规范不是 PPT,而是测试。

十七、ArchUnit 渐进式治理:老项目不要一刀切

老项目一上来加这些规则,大概率红一片。

这很正常。屎山不是一天堆成的,也别指望一天铲平。不然你不是在治理架构,你是在挑战团队血压。

推荐渐进路线:

1
2
3
4
5
6
7
第一阶段:只打印模块结构,不拦截
第二阶段:检查循环依赖
第三阶段:禁止新增跨模块 internal 访问
第四阶段:禁止 Controller 直接访问 Repository
第五阶段:收紧 domain 纯净性
第六阶段:为每个模块配置 allowedDependencies
第七阶段:CI 强制执行

ArchUnit 有冻结规则能力,可以让已有违规先被记录下来,后续新增违规才失败。

示例:

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.cybermall.architecture;

import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.library.freeze.FreezingArchRule;
import org.junit.jupiter.api.Test;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

class FrozenArchitectureRulesTest {

@Test
void freezeExistingDomainViolations() {
var classes = new ClassFileImporter().importPackages("com.cybermall");

FreezingArchRule.freeze(
noClasses()
.that()
.resideInAPackage("..internal.domain..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..")
).check(classes);
}
}

适合老项目过渡。

但注意,冻结不是免死金牌,只是给团队一个渐进治理入口。不要冻完就当没事了,不然你只是把冰箱塞满了架构债。

十八、Spring Modulith 事件发布注册表

模块事件最大的风险是:

1
2
3
订单事务提交了,但支付监听失败了怎么办?
订单发布事件了,但应用宕机了怎么办?
事件监听器执行一半失败了怎么办?

Spring Modulith 的事件发布注册表可以记录事件发布情况,并支持后续补偿、重试、观测。

18.1 开发环境自动建表

src/main/resources/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
spring:
application:
name: cybermall-modulith

datasource:
url: jdbc:postgresql://localhost:5432/cybermall
username: cybermall
password: cybermall

jpa:
hibernate:
ddl-auto: validate
open-in-view: false

modulith:
runtime:
verification-enabled: true
events:
jdbc:
schema-initialization:
enabled: true
republish-outstanding-events-on-restart: false

management:
endpoints:
web:
exposure:
include: health,info,modulith,prometheus

生产环境建议:

1
2
3
4
5
6
spring:
modulith:
events:
jdbc:
schema-initialization:
enabled: false

然后用 Flyway / Liquibase 管理表结构。

18.2 Flyway 脚本示例

src/main/resources/db/migration/V1__init_event_publication.sql

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CREATE TABLE IF NOT EXISTS event_publication
(
id UUID NOT NULL,
completion_date TIMESTAMP WITH TIME ZONE,
event_type VARCHAR(512) NOT NULL,
listener_id VARCHAR(512) NOT NULL,
publication_date TIMESTAMP WITH TIME ZONE NOT NULL,
serialized_event VARCHAR(4000) NOT NULL,
status VARCHAR(20),
completion_attempts INT,
last_resubmission_date TIMESTAMP WITH TIME ZONE,
PRIMARY KEY (id)
);

CREATE INDEX IF NOT EXISTS event_publication_by_listener_id_and_serialized_event_idx
ON event_publication (listener_id, serialized_event);

CREATE INDEX IF NOT EXISTS event_publication_by_completion_date_idx
ON event_publication (completion_date);

表结构请以你当前 Spring Modulith 版本提供的官方 schema 为准。不同版本字段可能会变化,生产项目不要手抄旧博客里的 SQL 就上线。

十九、外部消息:什么时候接 Kafka/RabbitMQ?

在模块化单体内部,Spring 事件已经能解决很多模块解耦问题。

但如果事件要发给外部系统,例如:

1
2
3
订单已创建 -> 发给风控系统
订单已支付 -> 发给 WMS
订单已发货 -> 发给 CRM

就需要外部消息。

Spring Modulith 支持事件外部化,可以将特定事件转发到 Kafka、AMQP、JMS 或 Spring Messaging。

一个典型做法是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.cybermall.order.events;

import org.jmolecules.event.annotation.DomainEvent;
import org.springframework.modulith.events.Externalized;

import java.time.Instant;

@DomainEvent
@Externalized("mall.order.placed::#{#this.orderId().value()}")
public record OrderPlacedIntegrationEvent(
Long orderId,
Long customerId,
String totalAmount,
Instant occurredAt
) {
}

建议区分两类事件:

类型 用途 示例
Domain Event 模块内部/模块之间表达领域事实 OrderPlacedEvent
Integration Event 对外发布,面向外部系统契约 OrderPlacedIntegrationEvent

不要把内部领域事件原封不动发到 Kafka。内部事件是领域模型的一部分,外部事件是集成契约。两者生命周期不同,别混在一起,否则以后改内部模型会把外部消费者一起打哭。

二十、Controller 应该放在哪里?

订单接口属于订单模块的入站适配器。

src/main/java/com/cybermall/order/internal/adapter/web/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
package com.cybermall.order.internal.adapter.web;

import com.cybermall.order.OrderApi;
import com.cybermall.order.OrderId;
import com.cybermall.order.PlaceOrderCommand;
import jakarta.validation.Valid;
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")
class OrderController {

private final OrderApi orderApi;

OrderController(OrderApi orderApi) {
this.orderApi = orderApi;
}

@PostMapping
PlaceOrderResponse placeOrder(@Valid @RequestBody PlaceOrderRequest request) {
PlaceOrderCommand command = request.toCommand();
OrderId orderId = orderApi.placeOrder(command);
return new PlaceOrderResponse(orderId.value());
}
}

Controller 的职责只有:

  • HTTP 参数接收;
  • DTO 转 Command;
  • 调用模块 API 或应用服务;
  • 返回 Response。

Controller 不应该:

  • 直接访问 Repository;
  • 写业务规则;
  • 操作事务细节;
  • 调其他模块 internal;
  • 拼复杂 SQL。

这些规则都可以交给 ArchUnit。

二十一、为什么库存 API 不直接接收 OrderId?

前面库存预占使用的是:

1
BusinessReference.of("ORDER", orderId.value().toString())

而不是:

1
reserve(OrderId orderId, ...)

这是有意设计。

如果 inventory API 直接使用 OrderId,就意味着:

1
2
order -> inventory
inventory -> order

这很容易产生循环依赖。

所以库存模块可以定义一个更通用的业务引用:

src/main/java/com/cybermall/shared/kernel/BusinessReference.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.cybermall.shared.kernel;

import org.jmolecules.ddd.annotation.ValueObject;

@ValueObject
public record BusinessReference(String businessType, String businessId) {

public BusinessReference {
if (businessType == null || businessType.isBlank()) {
throw new IllegalArgumentException("业务类型不能为空");
}
if (businessId == null || businessId.isBlank()) {
throw new IllegalArgumentException("业务 ID 不能为空");
}
}

public static BusinessReference of(String businessType, String businessId) {
return new BusinessReference(businessType, businessId);
}
}

这样库存模块不需要理解订单模块的内部 ID 类型。

模块边界设计里,这种小细节非常重要。你今天省一个 DTO,明天可能多一个循环依赖。

二十二、典型错误与自动拦截

错误 1:Payment 访问 Order internal

错误代码:

1
2
3
4
5
6
7
8
package com.cybermall.payment.internal.application;

import com.cybermall.order.internal.domain.Order;

class PaymentService {
void pay(Order order) {
}
}

拦截方式:

  • Spring Modulith modules.verify() 会失败;
  • ArchUnit noModuleShouldAccessOtherModuleInternalPackages 会失败。

错误 2:Controller 直接访问 Repository

错误代码:

1
2
3
4
5
@RestController
class OrderController {

private final SpringDataOrderJpaRepository repository;
}

拦截方式:

1
webAdapterShouldNotAccessRepositoryDirectly()

错误 3:领域对象依赖 Spring

错误代码:

1
2
3
4
5
6
@AggregateRoot
public class Order {

@Autowired
private RedisTemplate<String, Object> redisTemplate;
}

拦截方式:

1
domainShouldNotDependOnTechnicalFrameworks()

错误 4:聚合根直接成为 JPA Entity

错误代码:

1
2
3
4
@AggregateRoot
@jakarta.persistence.Entity
public class Order {
}

拦截方式:

1
aggregateRootsShouldNotBeJpaEntities()

这个规则不是绝对真理。有些团队会选择“领域对象即 JPA Entity”。如果你的团队明确采用这种风格,可以删掉这条规则。

架构规则不是宗教,不要拜工具,工具只是帮你贯彻选择。

二十三、CI 集成

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
name: build

on:
pull_request:
push:
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: ./mvnw -B verify

CI 中至少要跑:

1
2
3
ModulithStructureTest
ArchitectureRulesTest
关键 ApplicationModuleTest

建议把架构测试纳入 Pull Request 必跑项。

这样架构规则才不是“建议”,而是“门禁”。

二十四、老项目迁移路线

如果你现在已经有一个大 Spring Boot 单体,可以按下面顺序迁移。

阶段 1:先识别模块,不改代码

添加 Spring Modulith 依赖,写:

1
ApplicationModules.of(MallApplication.class)

先看它识别出来的模块是否符合预期。

阶段 2:按照业务能力调整一级包

从:

1
2
3
4
controller
service
mapper
entity

慢慢调整为:

1
2
3
4
5
order
payment
inventory
catalog
customer

不要一次性全改。先从最核心、边界最清楚的模块开始,比如订单。

阶段 3:引入 internal 约定

每个模块变成:

1
2
3
4
5
6
7
order
├── OrderApi.java
└── internal
├── application
├── domain
├── infrastructure
└── adapter

阶段 4:增加 jMolecules 注解

先标核心领域对象:

1
2
3
4
5
6
7
8
@AggregateRoot
class Order {}

@ValueObject
record Money(...) {}

@Repository
interface Orders {}

不要一开始就给全项目贴满注解。先从关键路径开始。

阶段 5:添加 ArchUnit 基础规则

先检查:

  • 循环依赖;
  • Controller 直连 Repository;
  • 跨模块 internal 访问。

老项目可以使用 freeze 过渡。

阶段 6:配置 allowedDependencies

等模块依赖关系比较清楚后,再加:

1
@ApplicationModule(allowedDependencies = {...})

这一步非常关键,也最容易暴露真实问题。

阶段 7:事件化解耦

对这些场景用事件:

1
2
3
4
订单创建后创建支付单
订单支付后通知履约
订单取消后释放库存
订单完成后发积分

但对必须同步确认的场景,仍然使用模块 API。

二十五、生产落地建议

25.1 不要把 shared 做成垃圾桶

shared 只能放稳定的共享内核。

可以放:

1
2
3
4
Money
Quantity
BusinessReference
PageQuery

不要放:

1
2
3
4
OrderUtils
PaymentHelper
InventoryCommonService
EverythingDTO

一旦 shared 变垃圾桶,模块边界就开始塌。

25.2 API 不要暴露内部实体

不要这样:

1
2
3
public interface OrderApi {
Order getOrder(OrderId orderId); // Order 是 internal.domain 下的聚合根
}

应该这样:

1
2
3
public interface OrderApi {
OrderView getOrder(OrderId orderId);
}

模块 API 是契约,不是把内部模型端出去。

25.3 领域事件不要塞整个聚合

不要这样:

1
2
public record OrderPlacedEvent(Order order) {
}

应该这样:

1
2
public record OrderPlacedEvent(OrderId orderId, CustomerId customerId, Money totalAmount, Instant occurredAt) {
}

事件应该表达必要事实,不是移动对象仓库。

25.4 allowedDependencies 要逐步收紧

刚开始可以不配,先用 Spring Modulith 打印真实依赖。

稳定后逐个模块加白名单。

不要第一天就把所有依赖全锁死,否则大概率卡死开发节奏。

25.5 模块化单体不是微服务,但要为微服务留路

模块化单体阶段就应该避免:

  • 共享数据库表乱改;
  • 模块间直接访问 internal;
  • 事件契约不稳定;
  • API 泄漏内部实体;
  • shared 泛滥。

这些问题如果不处理,未来拆微服务会更痛。

二十六、完整执行清单

新项目可以按这个顺序落地:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1. 创建 Spring Boot 3 项目
2. 引入 Spring Modulith BOM 和 Starter
3. 引入 jMolecules BOM 和 DDD/events/architecture 模块
4. 引入 ArchUnit 测试依赖
5. 按业务能力建立一级包:order/payment/inventory/catalog
6. 每个模块根包放 API、DTO、事件
7. 每个模块 internal 下放 application/domain/infrastructure/adapter
8. package-info.java 标记 @ApplicationModule
9. 对需要暴露的子包使用 @NamedInterface
10. 用 jMolecules 标记聚合根、值对象、领域事件、仓储
11. 编写 ModulithStructureTest
12. 编写 ArchitectureRulesTest
13. 编写 ApplicationModuleTest
14. 生成模块文档
15. 接入 CI
16. 老项目用 freeze 渐进治理

二十七、最终架构效果

最终你会得到这样的结构:

flowchart TB
    subgraph OrderModule[order 模块]
        OrderApi[OrderApi]
        OrderEvents[order.events]
        OrderInternal[internal]
        OrderDomain[domain: Order Aggregate]
        OrderApplication[application: DefaultOrderApi]
        OrderInfra[infrastructure: JpaOrders]
        OrderWeb[adapter.web: OrderController]

        OrderWeb --> OrderApi
        OrderApplication --> OrderDomain
        OrderInfra --> OrderDomain
        OrderApplication --> OrderEvents
    end

    subgraph InventoryModule[inventory 模块]
        InventoryApi[InventoryApi]
        InventoryInternal[internal]
    end

    subgraph PaymentModule[payment 模块]
        PaymentListener[OrderPlacedPaymentListener]
        PaymentService[PaymentService]
        PaymentListener --> PaymentService
    end

    subgraph Governance[架构治理]
        Modulith[Spring Modulith<br/>模块验证/文档/事件注册]
        JMolecules[jMolecules<br/>DDD/事件/层语义]
        ArchUnit[ArchUnit<br/>自动化架构测试]
    end

    OrderApplication --> InventoryApi
    PaymentListener --> OrderEvents

    Modulith --> OrderModule
    Modulith --> InventoryModule
    Modulith --> PaymentModule
    JMolecules --> OrderDomain
    ArchUnit --> OrderModule

项目会拥有这些能力:

能力 由谁提供
模块识别 Spring Modulith
模块依赖验证 Spring Modulith
internal 保护 Spring Modulith + ArchUnit
DDD 语义表达 jMolecules
分层语义表达 jMolecules
领域层纯净性检查 ArchUnit
Controller 访问规则 ArchUnit
循环依赖检查 Spring Modulith + ArchUnit
模块文档生成 Spring Modulith
模块事件发布注册表 Spring Modulith
模块级测试 Spring Modulith Test
CI 架构门禁 Maven + ArchUnit + Modulith Tests

二十八、我对这套组合的实际建议

如果你是新项目:

1
2
3
直接按 Spring Modulith 的一级业务包设计
jMolecules 从核心聚合开始标注
ArchUnit 从最基础规则开始写

如果你是老项目:

1
2
3
先用 Spring Modulith 看现状
再用 ArchUnit 检查最痛的规则
最后逐步引入 jMolecules 提升架构表达力

如果你是团队负责人:

1
2
3
把架构规则放进 CI
把模块图放进文档
把 allowedDependencies 作为模块契约

如果你是业务开发:

1
只要记住:别跨模块调 internal,别让 Controller 直连 Repository,别让 domain 依赖技术框架。

这三条守住,项目就不会太快变成祖传大铁锅。

参考资料

启示录

富贵岂由人,时会高志须酬。

架构治理不是为了写出“看起来高级”的代码,而是为了让系统在三个月、三年、甚至更久之后,仍然能被人理解、修改和演进。

工具不是银弹,但没有工具约束的架构规范,最后通常会变成“祖训”。

Spring Modulith、jMolecules、ArchUnit 这套组合,真正的价值在于:

1
2
3
4
让架构从口头约定,变成代码语义;
让代码语义变成测试规则;
让测试规则进入 CI;
让架构治理持续发生。

这就是模块化单体最值得落地的地方。


基于 Spring Boot 3 的电商模块化单体实战:整合 ArchUnit、Spring Modulith 与 jMolecules
https://allendericdalexander.github.io/2026/06/12/archtect/arch/springboot3-ecommerce-archunit-modulith-jmolecules-blog/
作者
AtLuoFu
发布于
2026年6月12日
许可协议