小团队为什么不该一上来就拆微服务
不少创业团队在项目启动阶段就规划了微服务架构,理由听起来都合理:业务将来会变大、服务要能独立部署、团队要能并行开发。但真正落地几个月后,他们会发现微服务的隐性成本远超预期——服务注册发现、配置中心、分布式链路追踪、网关、容错降级,每一项都是独立的基础设施投入。一个五人团队,光维护这些中间件就耗掉大量精力,真正写业务的时间反而被压缩了。
这不是说微服务不好,而是说微服务有它适用的前提条件:足够大的团队规模、足够高的并发量、足够明确的业务边界。当这些条件不具备时,一个结构良好的单体应用,反而是更务实的选择。Spring Modulith 正是在这个背景下出现的——它不是一个新框架,而是基于 Spring Boot 的一套模块化设计工具和约定,帮你把单体应用做得像微服务一样有边界、有契约、有测试保障,但不需要多进程通信和分布式基础设施。
核心思路很简单:在同一个进程内,通过包结构和约定来约束模块边界,让每个模块拥有明确的对外接口和内部实现,模块之间通过事件或接口调用协作,而不是随意交叉引用。这种方式保留了单体在开发、部署、调试上的简单性,同时在代码组织层面达到了接近微服务的模块化程度。
Spring Modulith 怎么定义一个模块
Spring Modulith 的模块概念并不复杂,但它和传统的”按功能分包”有本质区别。传统分包更多是开发者的自觉行为,编译器和框架不会帮你检查边界是否被遵守。Spring Modulith 则提供了一套机制,让模块边界变成可以被验证、被测试、被文档化的东西。
它的基本规则是:应用程序主包(包含 @SpringBootApplication 注解的类所在的包)的直接子包,默认就是一个应用模块。模块内部的子包对外部模块不可见,只有模块根包下的类(或显式声明的 API 包)才是其他模块可以访问的公开接口。
拿一个电商系统举例,包结构大概长这样:
com.example.shop // 主包
├── ShopApplication.java // 启动类
├── catalog // 商品模块
│ ├── ProductApi.java // 对外接口(公开)
│ └── internal // 内部实现(不可访问)
│ ├── ProductRepository.java
│ └── ProductServiceImpl.java
├── order // 订单模块
│ ├── OrderService.java // 对外接口(公开)
│ └── internal
│ └── OrderProcessor.java
└── inventory // 库存模块
├── InventoryApi.java
└── internal
└── StockManager.java
在这个结构里,catalog.internal 包下的类对 order 模块来说是不可见的。如果 order 模块直接 import 了 catalog.internal.ProductRepository,Spring Modulith 的验证机制会报错。这种约束不是靠 code review 来维持的,而是可以通过测试自动检查的。
模块验证:让边界约束可执行
Spring Modulith 最有价值的设计之一,是提供了一个 ApplicationModules API,让你在测试阶段自动验证模块边界的合法性。你不需要额外装什么插件,写一行测试就行:
@Test
void verifyModularStructure() {
ApplicationModules.of(ShopApplication.class).verify();
}
这个测试会扫描整个应用的包结构,检查是否存在模块越界访问。如果某个模块的内部类被另一个模块引用了,测试直接失败。这比人工 review 要可靠得多,尤其是在团队人员流动较快的情况下,新人不太可能无意中破坏模块边界——CI 跑不过去,PR 就合不了。
很多团队在引入 Spring Modulith 之前,代码库的包结构基本是”名义上分了模块”,实际上 service 层互相注入、dao 层跨模块查询的情况比比皆是。一旦引入这种验证机制,你会发现代码里藏了多少本不该存在的耦合。这也是为什么我建议在项目早期就引入这套约束——越晚引入,重构的沉没成本越高。
模块间通信:事件驱动 vs 直接调用
模块边界划定之后,接下来要解决的问题是:模块之间怎么协作?Spring Modulith 支持两种方式——直接通过公开接口调用,以及通过领域事件通信。两种方式各有适用场景,不存在谁取代谁的问题。
当一个模块需要同步获取另一个模块的数据时,直接调用公开接口是合理的。比如订单模块需要查询商品信息,调用 ProductApi 暴露的方法就行。这种方式简单直接,调用链清晰,调试也方便。但它的前提是被调用方已经定义了稳定的公开接口,并且调用方对返回结果有明确的预期。
当模块之间的协作是”发生了某件事,相关方需要做出反应”这种模式时,事件驱动更合适。比如订单创建完成后,库存模块需要扣减库存,通知模块需要发短信。如果用直接调用,订单模块就得依赖库存和通知两个模块的接口,耦合度立刻上升。用事件的话,订单模块只负责发布”订单已创建”事件,谁消费、怎么消费,它不关心。
| 对比维度 | 直接接口调用 | 领域事件通信 |
|---|---|---|
| 耦合程度 | 较高,调用方需知道被调用方接口 | 较低,发布方不关心消费方 |
| 一致性保证 | 同步事务,天然一致 | 需配合事务监听器保证最终一致 |
| 调试难度 | 调用链清晰,断点即可跟踪 | 事件流转链路需要额外追踪 |
| 适合场景 | 需要同步返回结果的查询类操作 | 状态变更后的通知、级联处理 |
| 后续拆服务成本 | 需改为远程调用,改动较大 | 天然适配消息中间件,迁移成本低 |
一个常见误区是觉得用了 Spring Modulith 就必须全部走事件驱动。实际上,过度的异步化反而会增加调试和排障的复杂度。对于小型项目,同步调用占多数、关键流转节点用事件解耦,是更务实的策略。
事件可靠性:Event Publication Registry
提到事件驱动,很多人第一反应是”事件丢了怎么办”。在传统单体里,Spring 的 ApplicationEventPublisher 发布事件后,如果监听器执行失败,事件就丢了,没有重试机制。这在业务关键场景下是不可接受的。
Spring Modulith 提供了一个 Event Publication Registry 机制,会在数据库中记录每个事件的发布状态和消费状态。事件发布和状态记录在同一个事务里完成,如果事务回滚,事件也不会被记录。如果监听器执行失败,事件状态保持”未完成”,后续会自动重试。
// 订单模块发布事件,框架自动持久化
@Transactional
public void createOrder(OrderRequest request) {
Order order = orderRepository.save(request.toOrder());
// 事件发布与订单保存在同一事务内
applicationEventPublisher.publishEvent(
new OrderCompleted(order.getId(), order.getTotalAmount())
);
}
// 库存模块监听事件,失败会自动重试
@TransactionalEventListener
public void onOrderCompleted(OrderCompleted event) {
inventoryService.deductStock(event.getOrderId());
}
这套机制的好处是,你不需要引入 Kafka 或 RabbitMQ 就能获得事件可靠性保障。对于小团队来说,这降低了技术栈的复杂度。但要注意,它依赖数据库表来存储事件,如果事件量非常大,这张表会成为性能瓶颈。在业务规模真正需要分布式消息队列之前,这个方案是够用的。
模块级测试:不只是跑全量上下文
传统的 Spring Boot 集成测试通常加载完整的应用上下文,所有 Bean 都会初始化。这在项目变大之后会变得很慢,而且一个模块的测试故障可能因为上下文初始化失败而影响其他模块的测试结果。
Spring Modulith 提供了 @ApplicationModuleTest 注解,可以只加载单个模块的 Spring 上下文来进行集成测试。这意味着你可以针对订单模块单独写测试,不需要启动库存、通知等不相关模块的组件:
@ApplicationModuleTest
class OrderModuleIntegrationTests {
@Autowired
OrderService orderService;
@MockBean
ProductApi productApi; // 外部依赖可 mock
@Test
void shouldCreateOrderSuccessfully() {
OrderRequest request = new OrderRequest("P001", 2);
Order order = orderService.create(request);
assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED);
}
}
这种测试策略让模块的独立可测性大幅提升。更重要的是,它倒逼开发者在写代码时就考虑依赖注入的合理性——如果一个模块的测试需要 mock 大量外部依赖,说明这个模块对其他模块的耦合可能过高了。
三个常见的落地误区
在实际项目中,我见过不少团队用 Spring Modulith 踩的坑,总结下来主要有三类。
第一个误区是按技术层分模块而不是按业务域分模块。有些团队把模块设计成 controller、service、repository 三个模块,这完全偏离了 Spring Modulith 的设计意图。模块应该是业务概念的边界,不是技术层的切分。正确的方式是按领域来拆——商品、订单、库存、用户,每个模块内部包含自己的 controller、service 和 repository。
第二个误区是模块间共享数据库表。Spring Modulith 的模块边界应该在数据层面也有体现。如果一个订单模块直接查询商品模块的数据库表,那模块边界在代码层面是虚设的。比较合理的做法是每个模块管理自己的表,跨模块的数据需求通过公开接口或事件同步来解决。当然,在同一个数据库实例下完全隔离表确实有成本,这需要根据实际情况做取舍,但至少不应该出现跨模块直接写对方表的代码。
第三个误区是把 Spring Modulith 当成银弹,认为用了它就天然具备了微服务的所有好处。Spring Modulith 解决的是代码组织层面的问题,它不提供独立部署、独立扩缩容、技术栈异构这些微服务才有的能力。如果你的系统确实需要这些能力,最终还是要走向分布式架构。Spring Modulith 的价值在于让这个演进路径更平滑——因为模块边界已经在代码层面建立好了,拆服务时只需要把接口调用改成远程调用、把领域事件改成消息中间件。
什么场景适合,什么场景不适合
Spring Modulith 并非万能解法,它有自己适合的场景边界。从团队规模来看,5 到 20 人的后端团队是最适合的区间。团队更小的话,传统分层的单体可能就够用了,引入模块化约束反而增加认知负担;团队更大、业务更复杂时,多团队协作下模块化单体在部署节奏和故障隔离上的局限性会逐渐显现。
从业务阶段来看,Spring Modulith 最适合 MVP 到成长期之间的阶段。项目刚启动时业务边界还不清晰,过度设计模块结构反而是负担。但当核心业务流程稳定、领域边界逐渐清晰之后,引入模块化约束能有效防止代码腐化。到了业务量和团队规模都需要独立部署和独立扩展的阶段,就是考虑把某些模块拆出去的时候了。
- 适合:中型电商、SaaS 后台、企业内部管理系统、中等规模的金融业务系统
- 谨慎:高并发实时系统(模块化单体的进程内通信无法满足极端性能需求)、需要多技术栈异构的系统
- 不适合:超大型多团队协作平台、需要精细粒度独立扩缩容的场景
从模块化单体到微服务的渐进式演进
Spring Modulith 的一个重要价值是它让架构演进变得可控。很多团队在从单体迁移到微服务时面临的最大困难不是技术实现,而是代码耦合太严重,根本拆不动。而如果从一开始就用模块化单体来组织代码,每个模块有明确的接口、内部实现隔离、跨模块通信走事件,那未来需要拆分时,路径就清晰得多。
典型的演进路径大致是这样的:
- 阶段一:所有模块在同一个进程内运行,通过 Spring Modulith 约束边界
- 阶段二:将高频变更或独立扩展需求最强的模块抽出为独立服务,接口调用改为 HTTP/gRPC,领域事件改为消息中间件
- 阶段三:逐步将更多模块拆出,核心模块保持稳定,最终形成按业务域拆分的微服务集群
这个过程中,Spring Modulith 建立的模块文档可以帮你直观看到模块依赖关系。运行 mvn spring-modulith:document 会自动生成 PlantUML 架构图和模块画布文档,这些在拆分评估时非常有用——你能一眼看到哪些模块依赖最复杂、拆分成本最高。
写在最后
架构选型的核心不是追新,而是匹配。微服务不是”更高级”的架构,它只是适合特定条件的架构。如果你的团队规模和业务复杂度还没到那个临界点,强行上微服务只会徒增运维负担和沟通成本。
Spring Modulith 提供的不是一个大而全的框架,而是一套关于”如何把单体做对”的方法论和工具。它通过包结构约束、边界验证、事件可靠性和模块级测试,让单体应用具备接近微服务的模块化品质,同时保留了单体在开发效率和部署简单性上的优势。
如果你的团队正在纠结要不要上微服务,先问自己一个问题:当前的痛点到底是”单体不够用了”,还是”单体没做好”?如果是后者,Spring Modulith 可能是比微服务更正确的答案。等业务真正需要分布式架构时,从结构良好的模块化单体出发去拆分,也比从一团乱麻的传统单体出发要容易得多。
原创文章,作者:,如若转载,请注明出处:https://fudengji.cn/article/305/