系列:《从零重建 AI-native ERP/MES》· 第 02 篇 对应代码:Forge 仓库
M0、M1两个里程碑
TL;DR
- 不拆微服务,做模块化单体。 用 Spring Modulith 把「模块边界」变成一条会失败的测试,而不是一份没人看的文档。
- 模块之间只通过
shared里的接口通信。 数据权限、元数据、字段校验,都是业务模块声明需求、别的模块提供实现。 - 虚拟线程一行配置打开,但要知道它在 JDK 21 上的限制。
- 前后端契约只有一份:后端 OpenAPI 生成前端 TS 类型,接口一改,前端编译不过。
- Spring Boot 4 的坑,我踩过的都列在后面。
一、为什么是模块化单体,而不是微服务
先说上一代系统的结构:Spring Boot 2 时代的 Maven 多模块,十几个 xxx-module-*,看起来边界很清楚。但实际上:
- 模块之间可以随便互相依赖,Maven 只管「能不能编译」,不管「该不该依赖」;
- 报表模块直接查工单模块的表,工单改个字段,报表就挂;
- 想拆出去单独部署?拆不动,因为依赖已经织成网了。
有人会说那就上微服务。对一个几个人、甚至一个人的团队来说,微服务意味着:分布式事务、服务发现、链路追踪、N 套部署流水线。而制造业系统里,「下单 → 生成工单 → 扣减库存」天然就是一个事务。为了架构好看把它拆成三个服务,是在给自己找麻烦。
我的选择是 模块化单体:一个进程、一个数据库、一次部署,但模块边界像微服务一样严格。Spring Modulith 就是干这个的。
二、用 Spring Modulith 把边界变成测试
Modulith 的规则很简单:
dev.forge下的每个一级包是一个模块(iam、meta、basedata、shared);- 模块根包里的类型是公开 API,子包(如
iam.domain)默认是内部实现; - 其他模块只能依赖公开 API。
然后写一个测试:
class ModularityTests {
private final ApplicationModules modules = ApplicationModules.of(ForgeServerApplication.class);
@Test
void verifiesModuleBoundaries() {
modules.verify();
}
@Test
void writesModuleDocumentation() {
new Documenter(modules).writeModulesAsPlantUml().writeIndividualModulesAsPlantUml();
}
}
为了写这篇文章,我故意在物料模块里注入了 iam 模块内部的 UserRepository,跑测试的结果是:
org.springframework.modulith.core.Violations:
- Module 'basedata' depends on non-exposed type dev.forge.iam.domain.UserRepository within module 'iam'!
这就是和「多模块目录」最本质的区别:越界依赖会让 CI 变红。 边界不再靠 Code Review 时有人眼尖,而是靠机器。
顺带,Documenter 会在每次测试时生成模块关系图(PlantUML),文档永远和代码一致。
三、模块之间怎么说话:依赖倒置,而不是互相调用
既然 basedata 不能直接调 iam,那物料模块需要「当前用户的数据权限」怎么办?需要「校验自定义字段」怎么办?
答案是:需求方在 shared 里声明接口,提供方去实现它。
┌──────────────────────────────┐
│ shared(OPEN 模块,只放契约) │
│ DataScopeProvider │
│ ExtensionFields │
│ EntityDefinitionContributor │
└──────────────────────────────┘
▲ ▲ ▲
实现 │ │ 实现 │ 实现 & 调用
iam meta basedata
shared 被标记为 OPEN 模块,所有子包都对外可见:
@ApplicationModule(type = ApplicationModule.Type.OPEN)
package dev.forge.shared;
以「自定义字段」为例,三个模块各司其职:
// shared:契约
public interface EntityDefinitionContributor {
EntityDefinition definition();
}
public interface ExtensionFields {
Map<String, Object> validate(String entityCode, Map<String, Object> values);
}
// basedata:声明「物料有哪些内置字段」
@Component
class MaterialDefinition implements EntityDefinitionContributor {
@Override
public EntityDefinition definition() {
return new EntityDefinition("basedata.material", "物料", List.of(
FieldDefinition.builtin("code", "物料编码", FieldType.TEXT).required().sortable().build(),
FieldDefinition.builtin("name", "物料名称", FieldType.TEXT).required().sortable().build(),
// ...
));
}
}
// meta:收集所有模块的声明,合并租户自定义字段
@Service
class MetaService implements ExtensionFields {
MetaService(CustomFieldRepository customFieldRepository, List<EntityDefinitionContributor> contributors) {
this.definitions = contributors.stream()
.map(EntityDefinitionContributor::definition)
.collect(Collectors.toUnmodifiableMap(EntityDefinition::code, Function.identity()));
}
// ...
}
meta 模块不知道「物料」的存在,basedata 也不知道自定义字段存在哪张表。以后 M2 加「销售订单」,只需要再写一个 EntityDefinitionContributor,订单就自动拥有了自定义字段能力。
什么时候用接口,什么时候用事件?需要同步拿到结果(权限范围、校验结果)用接口;只是通知别人「我这边发生了什么」(工单完工 → 库存入账)用 Modulith 的事件,M2 会用到。
四、虚拟线程:一行配置,三个注意点
spring:
threads:
virtual:
enabled: true
打开后,Tomcat 的请求线程和 @Async 都跑在虚拟线程上。对 ERP 这类「大量请求在等数据库」的 IO 密集型系统,这基本是白捡的吞吐量。但有三件事要知道:
-
ThreadLocal照常可用。 我的租户上下文TenantContext就是ThreadLocal,虚拟线程下行为不变。只是不要往里塞大对象,虚拟线程可能有几万个。 -
JDK 21 上
synchronized会钉住载体线程(pinning)。 阻塞 IO 发生在synchronized块里时,虚拟线程无法让出。好在主流依赖已经处理过:pgjdbc 42.6+ 把内部的synchronized换成了ReentrantLock。这个问题在 JDK 24(JEP 491)才从根本上解决,所以业务代码里别在锁里做 IO。 -
连接池才是真正的上限。 虚拟线程让「等待」变便宜了,但数据库连接数没变。并发一上来,瓶颈会从线程池转移到 HikariCP,池大小要按数据库能承受的来配,而不是按线程数。
这一点我在后面做库存时真实踩到过:15 个并发请求同时扣库存,每个请求在外层事务里已经占着一个连接,取单据号时又用
REQUIRES_NEW开了个新事务、再要一个连接。连接池只有 10 个,10 个请求各握一个、都在等第二个,全部超时。线程再便宜也没用,卡住的是连接。改成取号加入外层事务后问题消失(细节在第 03 篇)。结论是:用了虚拟线程之后,要格外留意「一个请求同时需要几个连接」。
五、前后端只有一份契约
上一代系统里,前端的接口类型是照着后端 DTO 手抄的。字段一改,前端要到运行时才发现。
这次的做法:后端用 springdoc 生成 OpenAPI,前端用 openapi-typescript 生成类型,再用 openapi-fetch 调接口:
const page = await unwrap(api.GET('/api/basedata/materials', {
params: { query: { keyword, category, page: 1, size: 20 } },
}))
// page.items 的类型是 MaterialView[],路径、参数、返回值全部有类型
后端改了字段名,pnpm gen:api 一跑,所有用到的地方直接编译失败。
但默认生成的类型有个问题:所有字段都是可选的。
RoleView: {
id?: number;
code?: string;
// ...
}
原因是 Java 的 record 在 OpenAPI 里没有标 required。前端于是满屏的 ?. 和 !,类型形同虚设。我的处理是在后端加一个 OpenApiCustomizer:非 nullable 的字段一律标为 required,真正可空的字段显式标注:
@Bean
OpenApiCustomizer requireNonNullableProperties() {
return openApi -> openApi.getComponents().getSchemas().values().forEach(schema -> {
Map<String, Schema> properties = schema.getProperties();
if (properties == null) {
return;
}
schema.setRequired(properties.entrySet().stream()
.filter(entry -> !isNullable(entry.getValue()))
.map(Map.Entry::getKey)
.toList());
});
}
public record UserView(long id, String username, String nickname,
@Schema(nullable = true) Long deptId,
@Schema(nullable = true) String deptName,
boolean enabled, Set<Long> roleIds, Instant createdAt) {
}
生成结果变成:
UserView: {
id: number;
deptId?: number | null;
deptName?: string | null;
enabled: boolean;
// ...
}
可空性从此是后端的一个显式决定,而不是前端的猜测。
六、测试:真实数据库,只测会出事故的地方
所有集成测试都用 Testcontainers 起真实的 PostgreSQL,Flyway 迁移和演示数据跟生产一模一样:
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer postgresContainer() {
return new PostgreSQLContainer(DockerImageName.parse("postgres:17-alpine"));
}
}
@ServiceConnection 会自动把容器的连接信息注入数据源,不用写任何 URL。测试基类封装了登录,用例读起来像业务描述:
@Test
void managerSeesOwnDepartmentTree() {
assertThat(usernames("demo", "zhang"))
.contains("zhang", "li", "zhao")
.doesNotContain("admin", "wang");
}
目前后端 43 个测试,所有测试类共用一个 Spring 上下文和一个容器,第一个类约 12 秒(启动容器 + 上下文),后面的类大多在 2 秒内。
七、Spring Boot 4 踩坑清单
都是这次真实遇到的:
| 现象 | 原因 | 处理 |
|---|---|---|
| Initializr 生成的 pom 拉不到父 POM | 生成的版本号是 4.1.1.RELEASE,Maven Central 上是 4.1.1 | 手动改掉版本号 |
启动报 missing table [event_publication] | 引入 Modulith 的 JPA 事件发布后,Hibernate ddl-auto: validate 要求这张表存在 | 在 Flyway 里补上建表迁移 |
@AutoConfigureMockMvc 找不到 | Boot 4 按技术拆分了自动配置模块,包名变了 | 改为 org.springframework.boot.webmvc.test.autoconfigure |
HibernatePropertiesCustomizer 找不到 | 同上,移到了 org.springframework.boot.hibernate.autoconfigure | 改 import |
| Testcontainers 找不到 Docker | macOS 上 /var/run/docker.sock 指向未运行的 Docker Desktop,实际用的是 OrbStack | 设置 DOCKER_HOST 指向 OrbStack 的 socket |
| JSONB 字段映射 | Boot 4 默认是 Jackson 3 | Hibernate 7.4 自带 Jackson3JsonFormatMapper,@JdbcTypeCode(SqlTypes.JSON) 直接可用 |
另外,Boot 4 的 starter 也按技术拆分了(例如 spring-boot-starter-webmvc、spring-boot-starter-flyway),建议直接用 Initializr 生成依赖,而不是从老项目复制。
八、从老项目升级过来的人,注意这些
我之前把一个十几个模块的老项目从 Spring Boot 2 升到 3.5 + JDK 21,印象最深的两件事:
javax.*→jakarta.*只是开始。 真正花时间的是传递依赖:某个库还依赖老的javax,编译能过,运行时才炸。- 依赖版本要成组升级。 当时 Apache POI 升到 5.5.1 后运行时报
NoSuchMethodError,查下来是 commons-io 还停在 2.12.0,升到 2.21.0 才好。这类问题编译期完全看不出来,只能靠启动加冒烟测试兜底。
所以这次新项目的原则是:依赖版本交给 Spring Boot 的 BOM 管,不手写版本号,唯一的例外是 springdoc。
小结
2026 年的 Spring Boot,已经不是那个「要配一堆 XML、启动一分钟」的框架了。在这个项目里:
- Spring Boot 4 + JDK 21,本地启动约 5 秒;
- 模块边界由测试强制,文档自动生成;
- 虚拟线程一行打开;
- 前后端类型从同一份 OpenAPI 生成。
它对 AI 协作开发也更友好:边界清楚、约定一致、测试能兜底,AI 写的代码是对是错,跑一遍测试就知道。
下一篇(04)讲权限:多租户、RBAC、数据权限,以及一个 AI 审查帮我找到的提权漏洞。
- 系列总纲:00 · 我要用 AI-native 架构,从零重建一套制造业 ERP/MES
- 仓库:待更新(目前为私有仓库,需要代码可以私信我)