老炮踩坑录 · S06 · 跨项目联动系列
基于 13 个真实工程的目录结构与 pom 实测
关键词:多模块 Maven · 技术分层 · 业务域切分 · 发布边界 · 依赖方向
欢迎阅读
🏠个人主页: 知守观
📘我的专栏: 老炮踩坑录
💻当前内容: 模块切分
引言
上个月帮朋友看一个项目的代码结构,他新招的两个人在同一周入职。
第一个人的任务是改订单列表的导出字段。他打开工程,controller 目录下 40 多个 Controller 类,按字母排。找订单相关的类,翻了十几分钟。
第二个人的任务差不多,也是订单。他打开工程,一眼看到 order-service 这个模块,进去就是 controller、service、dao,两分钟定位到。
同一个需求,两个人的第一天体验差了十倍。跟聪明程度没关系,工程的切法不一样。
我这几年手里过过 13 个 Java 工程,涉及的行业包含,零售、供应链、金融、电信、低代码平台都有。把这 13 个的目录结构和 pom 摊在一起看,模块切分能归成六种做法。哪一种都没有对错,每一种的代价都摆在明面上。
这篇就把这些账翻开,讲清楚每种切法省了什么、欠了什么。
先把 13 个工程摊开

表里最右边那列,是我自己按"改一个字段要动几个 pom"估出来的,不是精确统计,看个量级就行。
技术层横切:一个 war 打天下
档案服务是典型的三个模块:
xml
<modules>
<module>archive-api</module>
<module>archive-common</module>
<module>archive-web</module>
</modules>
职责分得很干净。api 放对外 Feign 接口和 DTO,别的服务引这个 jar 就能调;common 放注解、工具、常量;web 放全部业务实现,里面再按技术层分包:
css
archive-web/src/main/java/cn/xxx/archive/
├── controller/
│ ├── external/ 需要鉴权的对外接口
│ ├── externalno/ 免鉴权的对外接口
│ ├── internal/ 服务间调用
│ ├── self/ 自用调试
│ └── store/ 商店业务
├── service/
│ ├── intf/
│ └── impl/
├── mapper/
├── entity/
│ ├── convert/ MapStruct 转换器
│ ├── dto/
│ └── po/
└── config/
这套结构的好处很直接。三个模块一次编译,一个 jar 部署,运维修一个进程。新人进来只需要理解一套目录,controller 找入口,service 找逻辑,mapper 找 SQL,路径固定。
代价也直接。任何一行业务改动都要全量重新打 jar、重启进程。业务量涨到需要单独扩容某个功能时,你没法只扩它。团队超过八个人,service/impl 这一个目录上会同时挂着七八个分支的改动。
那个五层 controller 分包(external / externalno / internal / self / store),是这套横切结构里唯一一处按访问来源做的纵切。它的动机跟业务分层没关系,落点在暴露面管理。这个我在会另一篇里单独写。
业务域纵切:拆开的代价是调用面
供应链系统是四个模块:
xml
<modules>
<module>sc-pms</module>
<module>sc-md</module>
<module>sc-wds</module>
<module>sc-common</module>
</modules>
pms 管采购,md 管主数据,wds 管仓配。三个服务各自是 Spring Boot 应用,能独立起、独立发。改采购单的审批流,只碰 pms。这个边界是清楚的。
代价写在依赖里。三个服务之间跨域调用靠 Feign,工程里数出来 22 个 Feign 客户端,从档案系统到 WMS 都有一份。任何一个下游抖动,调用方跟着超时。运维面从"一个进程"变成了"三个进程 + 22 条外部链路"。
还有一件更有意思的事。纵切工程里通常都有一个 common 模块,供应链系统这个 common 下面还分了 entity、infrastructure、util、component 四层。所有业务模块都依赖它。
perl
mvn -q dependency:tree -pl sc-pms | grep -E "sc-md|sc-wds"
# 有输出,说明两个业务域之间还存在编译期依赖
这时候 common 已经不叫公共模块了,它是一条横跨所有业务域的横向层,只不过名字不叫 controller 和 service。改动它里面一个 DTO 字段,三个服务要跟着重新编译、回归、发布。我见过最夸张的一次,加一个租户 ID 字段,动了七个模块的 DTO、MapStruct 转换器和建表脚本。
纵切把"业务域"拆开了,但数据模型和通用实体往往拆不开。这一层依赖不显式管理,纵切就退化成了"三个 war 共用一堆 jar"。
部署单元纵切 + 代码内横切:第三种答案
商品中台的切法跟前两种都不一样。它按可部署单元切成 admin、service、web、task、sync-out、price-tag 这些模块,每个都能单独打包启动;但真正的业务代码全在 core 这一个库里,core 内部再按技术层分包:
bash
goods-core/src/main/java/
├── service/ 业务逻辑(接口 + impl)
├── dao/mysql/mapper/ MyBatis Mapper
├── entity/mysql/ 实体
└── entity/mongo/ 文档实体
它的判断标准我后来想明白了:部署边界跟团队边界对齐,代码包边界跟职责对齐。 谁负责价签,谁就管 price-tag 那个可部署模块;但价格计算、商品查询这些共用逻辑不进可部署模块,沉到 core 里被所有模块依赖。
这样做的代价是 core 会变成事实上的单体。六个可部署进程,跑的是同一套核心代码,任何一个共享方法改动,六个进程全部回归。它换来的是"该独立发布的能独立发布,不该拆的没被硬拆"。
资产系统和促销系统是另一个变体。资产系统按业务域切了六个模块,asset、stock、contract、finance、scm、inventory,一个域一个模块;促销系统切得更细,把命中计算单独拎出来部署成一个服务,因为它的 QPS 量级和管理端差两个数量级。
按端切、按制品切:两个老样本
网贷网关按 app、mgt、api 三端切,柜面系统干脆就是一个 war。开放网关只有 server 和 client 两个模块。这三种切法的共同点是,切分依据跟业务域无关。
按端切是历史产物。移动端和后台管理端的接口形态、鉴权方式、发布节奏都不一样,先按端把代码分开,比按业务域拆更省事。问题是同一份业务规则在 app 和 mgt 里各写了一遍,改一次业务要改两处,漏了就是两个端的数据对不上。
按制品切更单纯。开放网关的 client 模块是给接入方用的 SDK,server 是网关本体。SDK 必须能独立发版、独立引依赖,所以切出来。这跟业务分层没关系,是分发需求。
语音系统那 10 个子模块,切分依据是部署环境。IVR 门户、语音短信、告警、数据迁移各是一套独立部署的服务,跑在同一套语音业务里但生命周期不同,一个升级不能停另一个。
我踩过的三个坑
坑一:把 package 当成 module
有个项目号称"按业务域拆了九个包",我接手时一看根 pom,只有一个 module。
九个包确实存在,但在同一个 jar 里。编译还是全量编译,发布还是一个 jar,依赖还是随便跨包调。这种"逻辑分层"能解决目录可读性,解决不了编译隔离和依赖管控。
判断方法很简单,看 pom 里 <modules> 有几个。逻辑分层和物理拆分是两件事,很多团队把前者说成了后者,后来发现拆了半年,构建时间一点没变。
坑二:纵向拆过头
另一个工程把业务域拆成了七个 Spring Boot 服务,每个服务一套 DTO。后来产品要在所有单据上加一个字段,七个服务的 DTO、MapStruct 转换器、SQL 映射、建表脚本全部要改。一个人改了三天,还没算回归测试。
这件事之后我给自己定了一条:跨域的共享数据模型,要么收在一个公共 jar 里统一升级,要么就接受重复定义并写清楚同步责任。 七个服务各写一份 DTO 又不写同步约定,本质是把一份模型拆成了七份互不相关的副本。
坑三:common 变成垃圾桶
第三个坑跟上面那节的 common 模块有关。纵切之后,谁都往 common 里塞东西:工具类、常量、枚举、甚至某个业务专用的 DTO。半年后 common 依赖了七个模块,七个模块又依赖 common。
perl
# 看 common 被谁依赖,谁又在依赖 common
mvn -q dependency:tree | grep -B2 "common"
循环依赖一旦出现,编译器不会拦,Maven 也不会报错,只是某天改 common 里一个常量,五个服务全部启动失败。
我的处理办法是给 common 设准入线:只放跨域稳定的东西(基础枚举、统一返回结构、通用工具),业务 DTO 一律留在自己的模块里。这条线守不住,纵切就是白拆。
怎么选:先回答四个问题
我给团队做结构评审时,会问四个问题。答案决定切法,跟流行什么架构无关。

四个问题里有两个以上偏纵切,就可以开始拆;一个都没有,老老实实横切。为了"看起来像微服务"去拆,兜下来的是 22 个 Feign 客户端和一套跨域事务。
有个细节我没完全确认:模块数和构建时长之间的关系。我体感是拆到 10 个模块以上、且有编译依赖时,增量构建收益才明显;全量构建反而更慢。但手头 CI 环境不同,没做严格对照。有做过压测的兄弟,评论区指点一下。
开工前的自查
三条命令,五分钟能看出一个工程真实的切分状态。
perl
# 1. 物理模块数,跟别人说的"拆了几个服务"对不对得上
grep -c "<module>" pom.xml
# 2. 业务域之间的编译期依赖
mvn -q dependency:tree | grep -E "pms|md|wds"
# 3. 公共模块的规模,超过 500 行就要审一遍准入
find . -path "*/common/*" -name "*.java" | wc -l
自查打卡 : 跑一句:
grep -c "<module>" pom.xml打在评论区,我看看有多少工程是"说拆了十个服务,pom 里只有一个 module"的?
老炮点评
看了 13 个工程,两种切法的争论其实有个更底层的问题:模块边界到底服务于谁。
横切服务的是"读代码的人",一套目录走到黑,路径固定,找东西靠肌肉记忆。纵切服务的是"发版的人"和"扩容的人",一个域一个人管,改完自己发。
这两种诉求在同一个团队里都会存在,所以大部分工程都走向了混合切法:部署单元按业务纵切,代码包按职责横切。商品中台就是这么干的,供应链系统那个越来越胖的 common 也是这么长出来的。
真正决定成败的地方在依赖方向,跟切了几刀关系不大。所有依赖单向流动,横切纵切都能维护;出现循环依赖,再漂亮的分层图也是纸上的。
我维护过最久的一个横向分层工程,跑了六年没重构,因为业务域之间确实没有独立发布的需求。我也见过拆成 14 个模块的工程,第二年开始没人敢改公共模块。切法本身不背锅,超出团队承受能力的那部分切法才背锅。
最后问一句:你手上的工程,跨模块改一个字段要动几处?拆开之后,是真的好维护了,还是变成了没人敢碰的"微服务大杂烩"?评论区聊聊。
下期预告:《设计态与运行态分离的低代码平台架构》
一个低代码平台,流程定义和流程执行跑在两套服务里,设计器改完流程要点发布才进运行态。BPMN 解析交给 Flowable,执行交给自研运行时------这么切开之后,同步流程和流式流程怎么共存,下期讲。
如果本文对你有帮助,欢迎:
点赞 | 收藏 | 关注 | 留言
我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。