欢迎你来读这篇博客,这篇博客主要围绕 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 > <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 > <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-starter-core</artifactId > </dependency > <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-starter-jdbc</artifactId > </dependency > <dependency > <groupId > org.springframework.modulith</groupId > <artifactId > spring-modulith-starter-insight</artifactId > <scope > runtime</scope > </dependency > <dependency > <groupId > org.jmolecules</groupId > <artifactId > jmolecules-ddd</artifactId > </dependency > <dependency > <groupId > org.jmolecules</groupId > <artifactId > jmolecules-events</artifactId > </dependency > <dependency > <groupId > org.jmolecules</groupId > <artifactId > jmolecules-layered-architecture</artifactId > </dependency > <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 > <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.order、com.cybermall.catalog、com.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 的价值不是“让代码跑起来”,而是让代码自己说明架构角色。
普通代码:
别人不知道它是实体、聚合根、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 命名接口里,允许 payment、shipping 等模块订阅。
但事件设计要克制:
事件是事实,不是命令;
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 () { } }
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.Entitypublic 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) ; }
应该这样:
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; 让架构治理持续发生。
这就是模块化单体最值得落地的地方。