
先说点实际的
干过几年企业级项目的兄弟应该都有体会:单体应用在业务初期是真香,部署简单、调试方便、没那么多弯弯绕。但业务一旦堆起来,代码就慢慢失控了。包结构光靠约定维护是不行的------今天你调我的 Service,明天我改你的 Repository,同一个订单表被三个模块的 SQL 轮番蹂躏。到这种时候,团队里总会有人提一句:要不要拆分微服务?
我的态度一直是:先别急。
微服务不是银弹,尤其在业务边界还没摸清、运维能力又撑不住的时候,拆了只会死得更快。但单体的混乱也确实要解决。Spring Modulith 解决的就是这个中间地带的问题------先不拆,但把单体内的边界划清楚、用工具守住,等哪天真要拆了,心里也有底。
Spring Modulith 是什么思路
简单说,它就是把单体应用看成一组模块的集合。每个模块对应一个业务能力,模块之间只能通过公开接口或者领域事件打交道,不能直接穿透到别人的内部实现里去。
它给开发者提供的东西可以归纳成三个:
- 用包结构定义模块。根包下的每个子包就是一个模块,模块内的类默认都被视为内部实现,只有显式暴露出来的才能被其他模块碰。
- 自动化边界校验。写个测试类,Spring Modulith 会自动去扫代码依赖关系。谁违规引用了别人的 internal 类,测试直接红给你看。不用靠 Code Review 去人肉守规矩。
- 事件驱动模块通信。在 Spring 事件机制基础上做了一层增强,让事件可以跟事务绑定、支持异步处理、还能持久化记录,给最终一致性兜底。
有了这三个能力,"模块化"就不再只是一句口号,而是能在 CI 里自动执行、在代码里体现的硬约束。
集成到 Spring Boot
引入依赖
在 pom.xml 里加就行:
xml
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>
建议配合 Spring Boot 的 BOM 一起用,这样不用自己操心版本。事件持久化后面会讲到,用得上的话再引入 spring-modulith-starter-jpa。
包结构就是模块边界
Spring Modulith 的规则很简单:根包下的直接子包就是一个模块。拿电商举例,比如根包是 com.example.shop:
com.example.shop
├── order
│ ├── Order.java
│ ├── OrderService.java
│ ├── OrderController.java
│ └── internal
│ └── OrderRepository.java
├── report
│ ├── ReportService.java
│ ├── ReportController.java
│ └── internal
│ └── ReportRepository.java
└── shared
├── Money.java
└── ...
这里 order、report、shared 就是三个模块。internal 包里的东西是模块的私密内容,其他模块绝对不能碰。想给模块加限制,可以在包上写注解:
java
@org.springframework.modulith.ApplicationModule(
name = "order",
allowedDependencies = {"shared"}
)
package com.example.shop.order;
这么一写,就相当于声明了:order 模块只能依赖 shared,要敢去碰 report,测试直接失败。
模块之间怎么通信
模块间通信主要就两种方式:直接调公开 Service,或者发领域事件。Spring Modulith 在 Spring 原生的 ApplicationEventPublisher 和 @EventListener 之上做了增强,事件可以跟事务绑定,还能异步处理。
事务绑定是重点
@ApplicationModuleListener 是 Modulith 提供的事件监听注解,它的关键特性是事务绑定。默认情况下,事件监听器在事件发布事务提交之后才会执行。这意味着如果主事务回滚了,事件不会被处理。这一点对有数据一致性要求的业务场景特别有用。
需要说明的是,@ApplicationModuleListener 本身不是 异步的。Spring 的默认事件发布机制是同步阻塞的,Modulith 的事件处理器默认也是同步执行的(除非你同时加 @Async 或者在配置里专门开启异步)。但不管同步还是异步,事务绑定和事务边界与事件发布事务一致的机制是它和原生 Spring 事件的最大区别。也就是说,如果监听器抛异常,默认情况下发布方的事务也会回滚。
举个例子。订单模块里定义事件:
java
public record OrderCompletedEvent(Long orderId, double amount) {}
订单服务里发布:
java
@Service
public class OrderService {
private final ApplicationEventPublisher events;
public OrderService(ApplicationEventPublisher events) {
this.events = events;
}
@Transactional
public void completeOrder(Long orderId) {
// 业务逻辑...
events.publishEvent(new OrderCompletedEvent(orderId, 100.0));
}
}
报表模块监听:
java
@Service
public class ReportService {
@ApplicationModuleListener
public void onOrderCompleted(OrderCompletedEvent event) {
// 更新报表数据...
}
}
这个监听器会在 completeOrder 的事务提交之后执行,而且和发布方共享同一个事务。如果不希望共享事务,可以显式改成 @Transactional(propagation = Propagation.REQUIRES_NEW)。
领域事件设计
跨模块交互,尽量走领域事件而不是直接调对方的 Service 或 Repository。比如:
OrderPlacedEvent------下单成功,触发库存扣减PaymentReceivedEvent------支付完成,更新订单状态OrderCompletedEvent------订单完成,通知报表模块
每个模块只管自己关心的那些事件,不用知道对方内部怎么实现的。这样两个模块之间就不存在编译期的强依赖了。
数据库怎么隔离
很多人问:模块化单体要不要做数据库隔离?我的答案是:必须做,但要循序渐进。
物理上,数据表依然可以在同一个数据库里。但逻辑上,每个模块应该只访问自己的表。比如订单模块碰 t_order、t_order_item,报表模块只碰 t_sales_report。跨模块的数据访问,一律走事件或公开 API,绝不能直接去查别人的表。
这样做最大的好处是:模块内部的数据结构想怎么改就怎么改,不用通知别的模块。这点在后期拆分微服务的时候尤其值钱------数据库都是各管各的,拆分时不需要做大规模的数据迁移。
当然,历史遗留系统往往已经有表被多个模块共享了。这种情况下,我建议先改代码,把跨模块的表访问从代码层面切断,然后再考虑物理拆表。不要一上来就搞大重构,容易出事故。
公开 API 怎么设计
Spring Modulith 里,模块对外可访问的部分就是包内非 internal 的类。实践中我一般这么做:
- 对外暴露接口 ,比如
OrderQueryService,里面只定义业务方法; - 实现类放在
internal包里,外部模块永远看不到; - 传输对象用 DTO,不要直接把实体类丢出去;
- 关键状态变化用领域事件广播出去。
示例:
java
public interface OrderQueryService {
OrderSummary getOrderSummary(Long orderId);
}
实现类放 internal 包里,外部模块想用只能面向接口编程。配合 @ApplicationModule 的 allowedDependencies 声明,模块之间的访问路径就被双重锁死了。
自动化验证模块结构
Spring Modulith 最打动我的一点,就是它把架构规则做成了自动化测试。不需要组织一堆人天天盯着代码审查,机器会替你做这件事。
在测试目录下建一个测试类,放在和根包同名的包下:
java
@org.springframework.modulith.test.ApplicationModuleTest
class ShopApplicationModuleTest {
// 测试方法可以为空
}
@ApplicationModuleTest 会自动触发依赖分析,校验以下内容:
- 模块间依赖是否符合
allowedDependencies的声明 - 有没有循环依赖
- 有没有模块碰了别人的
internal包 - 有没有非法注入其他模块的 Bean
一旦违规,测试就失败并给出详细的违规报告。比如:
Invalid dependency from 'order' to 'report':
order.OrderService --> report.internal.ReportRepository
这种构建期的自动化守卫,比什么都管用。代码能不能合进主干,机器说了算。
一个实战例子:订单和报表解耦
前面说的比较多是概念和 API,走一个完整例子更能说明问题。
原来的一团乱麻
我们之前的项目里,报表模块为了统计每日销售额,直接去操作订单模块的 Repository,几个模块共享一张 t_order 表。后来订单表加了一个字段,报表模块的 SQL 直接崩了。就是因为这种强耦合,我们决定引入 Spring Modulith。
改造后的模块划分
根包 com.example.shop 下分三个模块:order、report、shared。订单模块用 @ApplicationModule 声明只允许依赖 shared。报表模块不直接依赖订单模块,只监听订单模块发出来的事件。
定义领域事件
java
public record OrderCompletedEvent(Long orderId, double totalAmount) {}
订单服务里发布事件:
java
@Service
public class OrderService {
private final ApplicationEventPublisher events;
public OrderService(ApplicationEventPublisher events) {
this.events = events;
}
@Transactional
public void completeOrder(Long orderId) {
// ... 完成订单业务逻辑
events.publishEvent(new OrderCompletedEvent(orderId, totalAmount));
}
}
报表模块异步更新
java
@Service
public class SalesReportService {
private final SalesReportRepository reportRepository;
SalesReportService(SalesReportRepository reportRepository) {
this.reportRepository = reportRepository;
}
@ApplicationModuleListener
public void handleOrderCompleted(OrderCompletedEvent event) {
SalesReport report = new SalesReport();
report.setOrderId(event.orderId());
report.setAmount(event.totalAmount());
report.setSalesDate(LocalDate.now());
reportRepository.save(report);
}
}
因为事件处理器和主事务绑定,所以报表保存失败的话,订单完成操作也会回滚。数据一致性由框架保证,不用自己写补偿逻辑。
集成测试
java
@ApplicationModuleTest
class OrderReportIntegrationTest {
@Autowired
ApplicationEventPublisher events;
@Autowired
SalesReportRepository reportRepository;
@Test
void orderCompletedEventUpdatesReport() {
events.publishEvent(new OrderCompletedEvent(1L, 99.99));
// 等待异步处理完成
assertThat(reportRepository.findByOrderId(1L)).isPresent();
}
}
Spring Modulith 还给事件测试提供了 Scenario API,能精确等待某个事件处理完成。用起来大概是这样的:
java
@ApplicationModuleTest
class OrderReportScenarioTest {
@Autowired
Scenario scenario;
@Test
void orderCompletionTriggersReport() {
scenario.stimulate(() -> events.publishEvent(new OrderCompletedEvent(1L, 10.0)))
.andWaitForEventOfType(OrderCompletedEvent.class)
.toArrive();
assertThat(reportRepository.findByOrderId(1L)).isPresent();
}
}
比自己在测试里写 Thread.sleep() 靠谱多了。
从模块化单体到微服务
有人说,模块化单体是"为不拆微服务找借口"。我不这么看。做模块化单体的目的,恰恰是为了让微服务的拆分风险更可控。
拆分的路线可以按下面这样走:
- 先定模块,把代码按业务能力分组,用 Modulith 把依赖规则立起来,切断不合理的跨模块调用。事件驱动替代直接调用。
- 再隔离数据,让每个模块只碰自己的表。跨模块数据交换走公开 API 或事件,数据库物理拆不拆暂时不做。
- 拆出独立部署单元,把某个模块的代码、表结构、资源全部拷出去,单独建一个 Spring Boot 应用跑起来。
- 暴露远程接口,在新服务里提供 REST 或者 gRPC 接口,原来用事件通信的部分切换成 Kafka 或者 RabbitMQ。
- 流量逐步切,通过网关把对旧模块的请求转发到新服务,最后在单体里删掉该模块的实现。
Spring Modulith 的事件持久化功能在拆分过程中很有用------事件记录都存在数据库里,即使中间发生故障导致消息丢失,也能根据记录做恢复或回放。
跟 Maven 多模块的区别
有同事问过我:Maven 多模块不是也能做模块化吗?为什么还要整这玩意?
区别是本质性的。
Maven 多模块是物理隔离 ,每个模块是一个独立的编译单元。你在 pom.xml 里声明依赖 A 依赖 B,编译层面上确实能控制 A 不能直接引用 B 里的类------前提是你根本不声明那个依赖。但一旦声明了,A 就能访问 B 里所有 public 的类,包括那些你并不想对外暴露的内部实现类。架构约束在编译之后就不存在了。
Spring Modulith 是逻辑约束,它是在同一编译单元内,基于包结构和专门的测试工具来实现模块边界强制的。它不仅能管住 "谁能访问谁" 的类依赖,还能感知 Spring Bean 的注入关系、事件发布关系,这种层面的约束 Maven 多模块做不到。
当然,两者可以组合。你完全可以既用 Maven 多模块管理构建层次,又用它做更细的包边界控制和依赖校验。但从成本角度考虑,单体阶段一个简单的 Maven 构建加上 Spring Modulith 通常比一上来就拆成十几个 Maven 子模块要务实得多。
落地要踩的坑
最后聊几个我们实际干的时候踩过的坑。
模块别拆太细了。 我见过有人把 util 也当模块搞,里面放一堆静态工具类,最后得出来的就是模块满天飞、事件满天飞,绕来绕去比原来还难维护。模块还是要跟业务能力走,粒度尽量粗一点。
别在业务代码里偷懒直接注入别的模块的 Repository。 这在项目初期可能感受不到问题,等业务复杂起来了,想改数据模型的时候就会发现动弹不得,到处都被牵着走。
领域事件的设计要重视。 事件名字应该是"已经发生的过去",比如 OrderPlacedEvent、OrderCompletedEvent,而不是"命令式"的 PlaceOrderCommand------那是给命令总线用的。事件对象应该不可变,只携带必要的数据。
循环依赖不只出现在类层面,事件层面也会有。 A 模块的事件触发 B 模块,B 模块事件再触发 A 模块,这种环路如果不去梳理,最终会演变成"事件风暴",系统响应越来越慢。事件设计阶段就最好画个流向图,注意环路。
数据库隔离不要指望一步到位。 历史系统里已经存在共享表很正常,先切断代码访问,再想办法拆表。如果业务改造比较大,先让新代码走新表,旧表和旧逻辑暂时并存过渡,稳一点总没错。
最后
Spring Modulith 或者说模块化单体这条路,给大多数单体应用提供了一个更现实的演进方式。它特别适合那种还有救但已经开始混乱的单体项目------在一个合理的约束下,逐步把模块边界理清楚,把跨模块的耦合降下来。后续如果真要拆微服务,也能更加从容,因为边界已经画好了,说白了你就省了大半前期工作。
如果你的项目现在还不需要上分布式,但又不想继续忍受"一锅乱炖"的代码结构,那 Spring Modulith 就值得试试。
官网地址:https://farerboy.com/
