单体架构的三种形态
- 单体架构的三种模块组织形态
- 先界定讨论范围
- 一、总览
- 二、形态一:单模块单体
- 是什么
- 目录结构
- 典型代表
- 核心特征
- 编译器不管的后果
- 优点
- 缺点
- 什么时候选它
- 三、形态二:分层模块单体(Layered Monolith)
- 是什么
- 目录结构
- 典型代表
- 核心特征
- Maven 如何强制约束
- 编译器阻断实例
- 优点
- 缺点
- 什么时候选它
- 四、形态三:模块化单体(Modular Monolith)
- 是什么
- 目录结构
- 典型代表
- 核心特征
- 模块间通信机制
- 方式一:接口 + 依赖注入
- 方式二:领域事件
- 优点
- 缺点
- 什么时候选它
- 五、三种形态一张表
- 六、从单模块到模块化的演进路线
- 七、常见误区
- 误区 1:多模块 = 微服务
- 误区 2:单模块 = 质量差
- 误区 3:模块化单体就是按业务建目录
- 误区 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 任何类 │ │ 编译器:零隔离 │ │ 分层约束:全靠团队自觉 │ └──────────────────────────────────────┘编译器不管的后果
以下代码在单模块中完全可以编过,但破坏了分层原则:
// 文件:com/example/service/UserService.java@AutowiredprivateUserControlleruserController;// ❌ Service 引用了 Controller!// 编译器:✅ 没报错(因为 UserController 也在 classpath 上)// 后继者:这人为什么在 Service 里调 Controller?// 文件:com/example/mapper/UserMapper.javaimportcom.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 如何强制约束
<!-- 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><!-- 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>编译器阻断实例
// 文件:project-service/.../service/UserService.java// ✅ 同级或下层模块,随便引用importcom.example.common.utils.StringUtils;// common 在所有模块的依赖链上importcom.example.entity.User;// entity 在 service 的依赖链上importcom.example.mapper.UserMapper;// mapper 在 service 的依赖链上// ❌ 上层模块 → 编译报错importcom.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 对方的实现类,只能通过接口或事件。
方式一:接口 + 依赖注入
// ======== shop-common:定义接口 ========publicinterfaceUserLookup{UserInfofindById(LonguserId);}// ======== shop-user:提供实现 ========@ComponentclassUserLookupImplimplementsUserLookup{privatefinalUserMapperuserMapper;publicUserInfofindById(LonguserId){returnuserMapper.findById(userId).toInfo();}}// ======== shop-order:只依赖接口 ========@ServicepublicclassOrderService{privatefinalUserLookupuserLookup;// 注入接口,不是具体实现publicOrderDTOcreateOrder(CreateOrderRequestreq){UserInfouser=userLookup.findById(req.getUserId());// ...}}shop-order的pom.xml里只依赖shop-common(接口所在),不依赖shop-user(实现在哪)。Spring 在运行时自动注入UserLookupImpl。
方式二:领域事件
// ======== shop-order:发布事件 ========@Service@TransactionalpublicclassOrderService{privatefinalApplicationEventPublisherevents;publicvoidconfirmOrder(LongorderId){Orderorder=orderMapper.findById(orderId);order.confirm();events.publishEvent(newOrderConfirmedEvent(order));// 发出事件}}// ======== shop-user:订阅事件 ========@ComponentpublicclassUserEventHandlers{@EventListenerpublicvoidonOrderConfirmed(OrderConfirmedEventevent){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文档