单体架构的三种形态
- 单体架构的三种模块组织形态
-
- 先界定讨论范围
- 一、总览
- 二、形态一:单模块单体
- [三、形态二:分层模块单体(Layered Monolith)](#三、形态二:分层模块单体(Layered Monolith))
- [四、形态三:模块化单体(Modular Monolith)](#四、形态三:模块化单体(Modular Monolith))
- 五、三种形态一张表
- 六、从单模块到模块化的演进路线
- 七、常见误区
-
- [误区 1:多模块 = 微服务](#误区 1:多模块 = 微服务)
- [误区 2:单模块 = 质量差](#误区 2:单模块 = 质量差)
- [误区 3:模块化单体就是按业务建目录](#误区 3:模块化单体就是按业务建目录)
- [误区 4:项目大了必须上微服务](#误区 4:项目大了必须上微服务)
- 八、推荐阅读
单体架构的三种模块组织形态
单体架构 ≠ 一个 Main 函数 + 一堆 if-else。从模块组织方式看,单体只有三种形态。选错形态,项目变大后的维护成本指数级上升。
先界定讨论范围
"架构分类"可以从很多维度看,为避免概念混淆,先把每个维度的关系说清楚:
一个 Spring Boot 项目的架构可以从多个维度同时描述:
维度 1:部署拓扑
├── 单机部署 → 一台服务器一个进程(本文不展开)
└── 分布式 → 多服务 / 多节点
维度 2:模块组织 ← 本文只讨论这一个
├── 单模块单体
├── 分层模块单体
└── 模块化单体
维度 3:内部架构风格
├── 分层架构 (Layered)
├── 六边形架构 (Hexagonal)
├── 清洁架构 (Clean Architecture)
└── CQRS / 事件驱动 等(本文不展开)
维度 4:数据库拓扑
├── 共享数据库
└── 每模块独立数据源(本文不展开)
本文只讨论维度 2------模块组织方式。 同一个项目可以同时是"分层模块单体 + 分层架构 + 共享数据库 + 单机部署",这不矛盾。
在这个维度下,单体只有三种形态。区分它们的关键问题只有一个:
Maven / Gradle 的模块边界,是按什么切的?
一、总览
单体架构的模块组织方式
│
┌───────────────┼───────────────┐
│ │ │
单模块单体 分层模块单体 模块化单体
(Single-Module) (Layered) (Modular Monolith)
│ │ │
不拆模块 按技术层拆 按业务领域拆
│ │ │
pom.xml ×1 pom.xml ×N pom.xml ×N
包名约定分层 admin/system/ user/order/
framework/common product/payment
三种形态都打成 1 个 JAR、1 个进程、1 个数据库。 区别全在源码组织上。
二、形态一:单模块单体
是什么
整个项目只有一个 Maven/Gradle 模块,所有的类在同一个源码目录下。分层靠包名约定,编译器不干预。
目录结构
project/
├── pom.xml ← 全项目唯一的 pom
└── src/main/java/com/example/
├── config/ ← Security、CORS、MyBatis 配置
├── controller/ ← REST 接口
├── service/ ← 业务逻辑
│ └── impl/
├── mapper/ ← 数据访问
├── entity/ ← 数据库实体
├── dto/ ← 入参 / 出参对象
├── interceptor/ ← 拦截器
├── exception/ ← 全局异常处理
└── util/ ← 工具类
典型代表
- Spring PetClinic(Spring 官方示例项目) --- 单模块,包名分层
- 单体博客、个人项目、外包小后台 --- 绝大多数小型 Spring Boot 项目
- 早期 Spring Boot 教程项目 ---
controller/service/mapper三件套
核心特征
┌──────────────────────────────────────┐
│ 单一 Maven/Gradle 模块 │
│ │
│ controller/ ←→ service/ │
│ ↕ ↕ │
│ dto/ ←→ mapper/ │
│ ↕ ↕ │
│ entity/ ←→ util/ │
│ │
│ 所有类在同一个 classpath 下 │
│ 任何类可以 import 任何类 │
│ 编译器:零隔离 │
│ 分层约束:全靠团队自觉 │
└──────────────────────────────────────┘
编译器不管的后果
以下代码在单模块中完全可以编过,但破坏了分层原则:
java
// 文件:com/example/service/UserService.java
@Autowired
private UserController userController; // ❌ Service 引用了 Controller!
// 编译器:✅ 没报错(因为 UserController 也在 classpath 上)
// 后继者:这人为什么在 Service 里调 Controller?
java
// 文件:com/example/mapper/UserMapper.java
import com.example.controller.dto.LoginRequest; // ❌ Mapper 依赖了 Controller 层的 DTO
// 编译器:✅ 没报错
// 结果:DTO 一改,Mapper 跟着编译不过------但只要改完 DTO 别忘了 Mapper 就行
单模块不意味着代码一定烂,但烂了编译器不会告诉你。
优点
| 优点 | 说明 |
|---|---|
| 心智负担最低 | 一个项目窗口看全部代码,新人 15 分钟就能开始改 bug |
| 构建快 | 无模块间依赖解析,mvn compile 一条命令 |
| 重构灵活 | IDE 拖拽就能把类从一个包移到另一个包 |
| CI/CD 极简 | 一条流水线,一个产出物 |
缺点
| 缺点 | 说明 |
|---|---|
| 边界腐化 | 新人不知道(或不遵守)分层约定 → 半年后变成大泥球 |
| 循环依赖 | AService → BService → AService,编译器不报,运行时 Spring 循环注入才炸 |
| 拆不开 | 想拆出独立服务时,先要手工梳理所有 import 关系 |
| 测试成本 | 单元测试经常不自觉地加载了整个 Spring Context |
什么时候选它
- 团队 ≤ 3 人,每个人都清楚分层约定
- 代码 < 5 万行,预期生命周期 < 2 年
- 快速验证、一次性项目、个人工具
三、形态二:分层模块单体(Layered Monolith)
是什么
把单模块按技术层 纵向切开,每个技术层变成一个独立的 Maven 模块。模块间的依赖方向通过 pom.xml 声明,编译器强制执行。
目录结构
project/
├── pom.xml ← 父 POM(聚合所有模块)
├── project-web/ ← Web 层(入口)
│ ├── pom.xml ← 依赖 project-service, project-common
│ └── src/main/java/.../
│ └── controller/ ← 只放 Controller
├── project-service/ ← 业务层
│ ├── pom.xml ← 依赖 project-mapper, project-common
│ └── src/main/java/.../
│ ├── service/ ← 业务逻辑
│ └── dto/ ← 业务 DTO
├── project-mapper/ ← 数据访问层
│ ├── pom.xml ← 依赖 project-entity, project-common
│ └── src/main/java/.../
│ ├── mapper/ ← MyBatis Mapper 接口
│ └── mapper/xml/ ← SQL XML
├── project-entity/ ← 实体层
│ └── src/main/java/.../
│ └── entity/ ← 数据库实体类
└── project-common/ ← 公共层(最底层)
└── src/main/java/.../
├── util/ ← 通用工具
├── annotation/ ← 自定义注解
└── constant/ ← 常量
典型代表
- 若依 (RuoYi-Vue) --- 国内最知名的开源后台管理系统,
ruoyi-admin/ruoyi-system/ruoyi-framework/ruoyi-common四个核心模块 - JeecgBoot --- 另一个国内流行的低代码平台,同样按
web/system/common拆模块 - 大量企业自研后台系统 --- 3-10 人团队,需要编译器约束防犯错
核心特征
模块边界 = 技术层的物理化。 之前靠包名分的层,现在变成了 Maven 模块,编译器开始管事了。
┌──────────────────────────────────────────┐
│ project-web │
│ (Web 层 / 入口模块) │
│ │
│ 依赖 ↓ (pom.xml 里写 dependency) │
├──────────────────────────────────────────┤
│ project-service │
│ (业务逻辑层) │
│ │
│ 依赖 ↓ │
├──────────────────────────────────────────┤
│ project-mapper │
│ (数据访问层) │
│ │
│ 依赖 ↓ │
├──────────────┬───────────────────────────┤
│ project-entity│ project-common │
│ (数据库实体) │ (工具 / 注解 / 常量) │
│ │ 不依赖任何模块 │
└──────────────┴───────────────────────────┘
依赖方向:自上而下 ↓ (单向 DAG)
违规检测:编译期(Maven 找不到类 → 直接报错)
Maven 如何强制约束
xml
<!-- project-web/pom.xml -->
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>project-service</artifactId> <!-- ✅ Web 可以依赖 Service -->
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>project-common</artifactId> <!-- ✅ Common 谁都能用 -->
</dependency>
<!-- 注意:这里绝对不能写 project-web 依赖自己 -->
</dependencies>
xml
<!-- project-service/pom.xml -->
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>project-mapper</artifactId> <!-- ✅ Service 依赖 Mapper -->
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>project-common</artifactId>
</dependency>
<!-- 这里不能写 project-web!Service 不能反向依赖 Web 层 -->
<!-- 如果写了 → Maven 检测到循环依赖 → 编译失败 -->
</dependencies>
编译器阻断实例
java
// 文件:project-service/.../service/UserService.java
// ✅ 同级或下层模块,随便引用
import com.example.common.utils.StringUtils; // common 在所有模块的依赖链上
import com.example.entity.User; // entity 在 service 的依赖链上
import com.example.mapper.UserMapper; // mapper 在 service 的依赖链上
// ❌ 上层模块 → 编译报错
import com.example.web.controller.UserController; // 编译器:找不到这个类!
// 原因:project-service/pom.xml 里没写 project-web 的 dependency
// Maven 在编译 service 模块时,web 模块根本不在 classpath 上
这是分层模块单体和单模块单体最本质的区别------约束从"文档规范"变成了"编译器报错"。
优点
| 优点 | 说明 |
|---|---|
| 编译期强制分层 | Service 引用 Controller → 直接编译失败 |
| 依赖方向可审计 | mvn dependency:tree 打印完整的模块依赖图 |
| 增量编译 | 只改了 common → 只重编 common,不用全量 |
| 拆分成本降低 | 模块边界已划定,拆微服务时只需把 dependency 换成 RPC |
| 按模块分人 | A 负责 web 层,B 负责 service 层,C 负责 common |
缺点
| 缺点 | 说明 |
|---|---|
| 横向改动面大 | 加一个"用户列表"功能,要在 web/service/mapper/entity 四个模块都加文件 |
| 业务内聚性差 | 用户相关的代码散落在 web/service/mapper/entity 四个模块 |
| common 膨胀 | 什么工具都塞 common,最终变成垃圾桶模块 |
| 学习成本 | 新人要先看懂模块依赖图,才知道代码该放哪 |
什么时候选它
- 团队 3-10 人,需要编译期防呆
- 代码量 5-20 万行
- 企业后台管理系统(RuoYi 的典型场景)
- 确认未来可能拆微服务,但现阶段不想承担微服务运维成本
四、形态三:模块化单体(Modular Monolith)
是什么
把项目按业务领域竖向切开。每个模块内部有自己的 Controller、Service、Mapper、Entity------每个业务模块是一个"小单体"。模块间通过接口通信。
目录结构
shop/
├── pom.xml ← 父 POM
├── shop-common/ ← 共享内核(仅接口 + 值对象 + 事件)
│ └── src/main/java/com/shop/common/
│ ├── UserLookup.java ← 接口定义(不是实现!)
│ ├── OrderConfirmedEvent.java ← 领域事件类
│ └── Money.java ← 值对象
├── shop-user/ ← 用户领域(完整的微型单体)
│ ├── pom.xml ← 依赖 shop-common
│ └── src/main/java/com/shop/user/
│ ├── UserController.java
│ ├── UserService.java
│ ├── UserMapper.java
│ ├── User.java
│ └── UserLookupImpl.java ← 实现 common 定义的接口
├── shop-order/ ← 订单领域
│ ├── pom.xml ← 依赖 shop-common
│ └── src/main/java/com/shop/order/
│ ├── OrderController.java
│ ├── OrderService.java
│ ├── OrderMapper.java
│ └── Order.java
├── shop-product/ ← 商品领域
│ └── src/main/java/com/shop/product/
│ ├── ProductController.java
│ ├── ProductService.java
│ ├── ProductMapper.java
│ └── Product.java
└── shop-payment/ ← 支付领域
└── src/main/java/com/shop/payment/
├── PaymentController.java
├── PaymentService.java
└── PaymentMapper.java
典型代表
- DDD 经典示例项目(如 IDDD 书中的协作上下文例子)
- Kamil Grzybek 的 Modular Monolith 示例
- Spring Modulith 官方示例
- 大型电商系统的初期形态 --- Shopify 在拆微服务前就是这个形态
核心特征
模块边界 = 业务领域。 和分层模块单体不同------这里"垂直切",而不是"水平切"。
分层模块单体(水平切): 模块化单体(垂直切):
web 层 user 领域
┌────────────┐ ┌──────────────────┐
│UserController│ │ UserController │
│OrderController│ │ UserService │
│ProductController│ │ UserMapper │
└────────────┘ │ User.java │
└──────────────────┘
service 层 order 领域
┌────────────┐ ┌──────────────────┐
│UserService │ │ OrderController │
│OrderService │ │ OrderService │
│ProductService│ │ OrderMapper │
└────────────┘ │ Order.java │
└──────────────────┘
mapper 层 product 领域
┌────────────┐ ┌──────────────────┐
│UserMapper │ │ ProductController │
│OrderMapper │ │ ProductService │
│ProductMapper│ │ ProductMapper │
└────────────┘ │ Product.java │
└──────────────────┘
改"用户登录"→ 3 个模块都要动 改"用户登录"→ 只动 user 一个模块
模块间通信机制
关键原则:模块之间不能直接 import 对方的实现类,只能通过接口或事件。
方式一:接口 + 依赖注入
java
// ======== shop-common:定义接口 ========
public interface UserLookup {
UserInfo findById(Long userId);
}
// ======== shop-user:提供实现 ========
@Component
class UserLookupImpl implements UserLookup {
private final UserMapper userMapper;
public UserInfo findById(Long userId) {
return userMapper.findById(userId).toInfo();
}
}
// ======== shop-order:只依赖接口 ========
@Service
public class OrderService {
private final UserLookup userLookup; // 注入接口,不是具体实现
public OrderDTO createOrder(CreateOrderRequest req) {
UserInfo user = userLookup.findById(req.getUserId());
// ...
}
}
shop-order 的 pom.xml 里只依赖 shop-common(接口所在),不依赖 shop-user(实现在哪)。Spring 在运行时自动注入 UserLookupImpl。
方式二:领域事件
java
// ======== shop-order:发布事件 ========
@Service
@Transactional
public class OrderService {
private final ApplicationEventPublisher events;
public void confirmOrder(Long orderId) {
Order order = orderMapper.findById(orderId);
order.confirm();
events.publishEvent(new OrderConfirmedEvent(order)); // 发出事件
}
}
// ======== shop-user:订阅事件 ========
@Component
public class UserEventHandlers {
@EventListener
public void onOrderConfirmed(OrderConfirmedEvent event) {
userService.addLoyaltyPoints(event.getUserId(), 100);
// 订单模块完全不知道"用户模块在监听"
}
}
事件机制让 shop-order 和 shop-user 之间零编译期依赖 。两者都只依赖 shop-common(事件类定义在那)。
优点
| 优点 | 说明 |
|---|---|
| 业务内聚性极高 | 改"订单"功能只需动 shop-order 一个模块 |
| 团队自治 | A 团队全部在 shop-user 里干活,B 团队全部在 shop-order |
| 天然可拆分 | 某个模块需要独立部署时,把接口调用换成 RPC/消息队列即可 |
| 模块边界清晰 | 一眼看出哪些代码属于哪个业务领域 |
缺点
| 缺点 | 说明 |
|---|---|
| 前期设计成本高 | 必须先识别业务边界------DDD 的限界上下文、事件风暴 |
| 接口维护成本 | 跨模块接口一改,实现方和调用方都要联动 |
| 过度工程化 | 3 个业务模块的小系统强行 DDD → 接口比业务代码还多 |
| common 膨胀 | 什么接口和事件都往 common 塞,common 变成隐形的"大模块" |
| 跨模块查询困难 | 不能写 JOIN,只能在代码层拼数据 |
什么时候选它
- 业务领域边界明确且复杂(如电商、金融、物流)
- 团队 > 10 人,按业务线分组开发
- 未来确定会拆微服务,但想在单体阶段先验证领域模型
- 用 Spring Modulith 或 ArchUnit 做编译期模块边界验证
五、三种形态一张表
| 单模块单体 | 分层模块单体 | 模块化单体 | |
|---|---|---|---|
| 切分依据 | 不切(包名约定) | 按技术层 | 按业务领域 |
| Maven 模块数 | 1 | 4-10 | 5-20+ |
| 各模块内部 | 包 = 技术层 | 包 = 业务类 | 包 = 技术层 |
| 典型代表 | Spring PetClinic、个人博客 | 若依 RuoYi-Vue | DDD 电商参考实现 |
| 编译期隔离 | ❌ 无 | ✅ 技术层之间 | ✅ 业务领域之间 |
| 循环依赖 | ❌ 靠人发现 | ✅ 编译报错 | ✅ 编译报错 |
| 改一个功能 | 动 1 个模块(多个包) | 动 3-4 个模块 | 动 1 个模块 |
| 新人上手 | 15 分钟 | 2 小时 | 1 天 |
| 拆微服务难度 | 极难 | 中等 | 容易 |
| 过度工程化风险 | 无 | 中 | 高 |
| 适合团队 | 1-3 人 | 3-10 人 | 10+ 人 |
| 适合代码量 | < 5 万行 | 5-20 万行 | > 10 万行 |
| 约束力来源 | 团队自律 | Maven 依赖树 | 接口契约 + Maven |
六、从单模块到模块化的演进路线
项目从小到大的自然演变路径:
单模块单体 ────→ 分层模块单体 ────→ 模块化单体 ────→ 微服务
│ │ │ │
│ │ │ │
阶段 1 阶段 2 阶段 3 阶段 4
"一个人" "一个组" "多个组" "多个团队"
快速出活 需要防呆 需要自治 独立交付
什么时候升级?
阶段 1 → 2 的信号:
- 团队超过 3 人
- Code Review 反复抓出"Service 引用了 Controller"
- 新人入职后两个月还在放错包
→ 拆成分层模块单体,让编译器替你管
阶段 2 → 3 的信号:
- "加一个订单导出"要改 web/service/mapper/common 四个模块
- "加一个用户标签"也是这四个模块
- 每次上线都在改同一批模块,上线冲突频繁
→ 拆成模块化单体,按业务领域收拢代码
阶段 3 → 4 的信号:
- 某个业务模块(如支付)需要独立扩容
- 某个业务模块需要独立技术栈(如 Go 重写)
- 各业务线的发布节奏无法同步
→ 拆成微服务,独立部署
七、常见误区
误区 1:多模块 = 微服务
错。 若依有 6 个 Maven 模块,但最终打成 1 个 JAR,部署 1 个进程,连接 1 个数据库。它的运维模型是纯粹的单体。模块组织影响的是源码结构和编译约束,不影响部署拓扑。
误区 2:单模块 = 质量差
错。 单模块只说明没有编译期隔离,不说明代码耦合。一个包名清晰、分层严格的单模块项目,比一个 common 模块 500 个类的多模块项目更容易维护。
误区 3:模块化单体就是按业务建目录
没那么简单。 真正的模块化单体要求模块间不能直接 import 实现类 ,必须通过接口或事件通信。如果只是建了 user/order/product 三个目录但互相随便 import,那不叫模块化单体,叫"假装拆了的单模块单体"。
误区 4:项目大了必须上微服务
错。 Shopify 是 Rails 单体,撑到几十亿美元市值才拆。关键在于模块边界是否清晰------如果模块化单体做得好,一个 JAR 可以撑十年。
八、推荐阅读
- Simon Brown --- Modular Monoliths (2020)
- Kamil Grzybek --- Modular Monolith: A Primer (2019)
- Sam Newman --- Monolith to Microservices (O'Reilly, 2019)
- Vaughn Vernon --- Implementing Domain-Driven Design (Addison-Wesley, 2013)
- Spring 官方 --- Spring Modulith 文档