老炮踩坑录 · 💎 老炮视野系列
两个真实企业项目的真实代码,逐层统计:2583 个类 / 228635 行 对照 411 个类 / 22972 行
关键词:DDD 分层 · application/domain · controller/service/mapper · 依赖方向 · 事务位置 · repository 包装
👋 欢迎阅读
🏠个人主页:知守观
📘我的专栏: 老炮踩坑录
💻当前内容:DDD分层
引言
同一家公司,两个项目,两套分包方式。
一个是供应链中台,2022 年前后建的,顶层四个 Maven 模块——三个业务模块加一个公共模块,公共模块再拆 entity、infrastructure、component、util 四个子模块——业务模块按 application、domain、web 切。
另一个是基础档案服务,更早,单个 web 模块里按 controller、service、mapper、entity 切。
同一个技术体系,同一个自研框架,两拨负责人。
我在两个项目里都写过需求。这篇不评哪套更先进,把目录、代码量、依赖方向、事务位置摆在一起,看每种选择具体落在什么地方。
如果只记三件事:
① DDD 四层落地后,domain 层最大类 5885 行,和技术分层那个 938 行的
TaskServiceImpl没有本质区别——目录给了位置,规则怎么组织还是人的事;② 应用层 23 个实现里 5 个直接 import mapper,越层调用编译期拦不住;
③ 事务注解散在应用层、领域层、策略层和接口上,回滚边界看包结构看不出来。
统计口径是主代码,src/test 单独计数,行数按非空行计。两边的头部总数口径不同,先交代:中台计全部模块(含公共模块下 entity、infrastructure、component、util 四个子模块);档案服务只计 web 业务模块,api 模块是空壳、common 模块是注解工具和枚举,未计入头部数字。
两个仓库的目录树,摆在一起看
供应链中台的业务模块长这样,三个模块结构一致:
档案服务的业务代码集中在一个 web 模块,按技术职责横切:
模块数差得远。中台三个业务模块各 130、112、121 个类(采购、仓配、主数据),实体模块 1609 个类(其中 dto/po 1245 个,下表 entity 行按此口径,其余为枚举、view、convert 等),基础设施模块 475 个类,公共 component 108 个类、util 28 个类。档案 web 模块 411 个类,公共模块 294 个,其中 192 个类名以 Enum 结尾。
这个对比第一眼不公平:23 万行对 2 万行。代码量和分包方式有关,但解释不了全部。后面每一项,两边都有对应物。
同一套统计口径,按目录切:
标题里的四层,指表现层、应用层、领域层、基础设施层。这个项目把基础设施层拆成 repository 和 mapper 两个包,web 单独放 controller,按调用链数是 5 段。后文说四层还是五段,指的是同一套结构,口径不同。
两边重心不同。先交代一句:中台行数最大的一块其实不是 domain,是 entity 下的 DTO/PO——83260 行,占 36.4%。数据载体的体量超过业务逻辑,这是"四层"落地名不副实的第一处注脚。单看业务逻辑,中台压在最里面的 domain,52356 行,占 22.9%;档案压在中间的 service,7923 行,占 34.5%。均值更直白:domain 类平均 463 行,service 类平均 103 行。
均值背后有两个具体的类:DemandDomainServiceImpl 5885 行,16 个 public 方法、82 个 private 方法;ContractDomainServiceImpl 4792 行。档案最大的 TaskServiceImpl 938 行。分层把代码引进了 domain 目录,类的体量没跟着变小。
接口同名,调用越层
挑一对有代表性的。配送验收这个用例,应用层接口 7 个方法,领域层接口 9 个,同名 6 个:
public interface IShipAcceptApplication {
PageResult<WdShipAcceptBillResp> queryShipAcceptBillList(WdShipAcceptBillQueryReq queryParams);
WdShipAcceptBatchDetailQueryResp queryShipAcceptBatchDetail(String billNo);
// ... 共 7 个
}
public interface IShipAcceptDomainService {
PageResult<WdShipAcceptBillResp> queryShipAcceptBillList(WdShipAcceptBillQueryReq queryParams);
WdShipAcceptBatchDetailQueryResp queryShipAcceptBatchDetail(String billNo);
// ... 共 9 个
}
同名的 6 个,参数类型和返回类型完全一样。应用层实现里多出来的活是组装和转换:60 个应用层实现,16 个引了 MapStruct 生成的转换类;domain 113 个类里,36 个引了转换类。
这种写法下,应用层相当一部分方法只有一行转发。它买到的是事务、日志、权限的统一入口,付出的是双份接口定义。改一个参数,动两个接口、两个实现。
目录立起来,调用不一定沿着目录走。采购模块 23 个应用层实现:12 个引 domain,11 个直接引 repository,5 个直接引 mapper:
import com.example.scm.infrastructure.repository.mapper.purchase.PurchaseOrderMapper;
应用层直接引 mapper,中间两层全跳过。编译期拦不住,全靠口头约定。
12、11、5,三类引用出现在同一个模块里。说明团队内部对"应用层该调谁"没有统一答案。或者有过答案,后来没人守。
档案服务那边链路短。37 个 controller 里 35 个引 service,没有一个引 mapper。controller 平均 57 行,多数方法一行转发,越层无从谈起——一共三层,没地方可越。
短链路挡得住越层,挡不住规则没地方放。ConfigController 191 行,保存报表配置的方法里内联了一段重复校验:
List<String> checkList = new ArrayList<>();
params.forEach(p -> {
String s = p.getCategoryCode() + p.getCategoryName() + p.getProductType();
if (checkList.contains(s)) {
BizExceptions.throwWithCodeAndMsg("-1", "存在相同的商品分类+商品类型组合");
}
checkList.add(s);
});
这段逻辑待在 controller 里,service 的保存方法只负责落库。分层约定写在类注释里,执行靠自觉。
domain 拿到 PO,repository 平均 22 行
三个业务模块的 domain 共 57 个类,42 个 import 了 entity.po 下的持久化对象。
仓配模块 domain/delivery/model 目录下只有 5 个类:LocStocksModel、WareAllotContext、WareAllotParam、WareAllotBillDetailModel、StringPair。参数对象和中间结果。这个目录里没有聚合根,没有值对象,没有领域事件——单看一个目录不足为凭,更有说服力的是上面 42/57 的 PO 依赖。
数据访问契约带着持久化的痕迹。仓储接口继承 MyBatis-Plus 的 IService,实现类继承 ServiceImpl:
public interface IMoveOrderRepository extends IService<MoveOrderPO> {
Page<MoveOrderPO> listPage(QueryMoveOrderReq query);
MoveOrderPO queryMoveOrderPOWithValid(String moveOrderNo);
boolean updateStatus(String moveOrderNo, Integer status, Integer statusOld);
}
@Service
public class MoveOrderRepositoryImpl
extends ServiceImpl<MoveOrderMapper, MoveOrderPO>
implements IMoveOrderRepository {
}
继承这一下,save、updateById、page、list 一整套方法直接到手。135 个仓储接口全部继承 IService,135 个仓储实现全部继承 ServiceImpl,没有例外。
repository 包 433 个文件:135 个接口、135 个实现、163 个 Mapper。接口命名三种并存:I 前缀带 Service 后缀的 97 个,I 前缀不带后缀的 23 个,连 I 前缀都省掉的 15 个。实现类也没守住命名,12 个不带 Impl 后缀,直接叫 XxxRepositoryService,和接口只差一个 I。合计 9499 行,平均 22 行一个。绝大多数是组装 LambdaQueryWrapper,调一次 baseMapper 或 getOne。
档案服务没有这层包装。46 个 Mapper 接口对 46 个 XML,平均 29 行,service 直接注入 Mapper。没有一个继承 BaseMapper,SQL 全部写在 XML 里,分页走插件。
理论上这层包装买的是 domain 不依赖 MyBatis-Plus。但看上面的接口签名就知道这笔买卖没做成:IService、Page、MoveOrderPO 全是持久化层类型,仓储接口本身就站在 MyBatis-Plus 上,再加上 42 个 domain 类直接 import PO——真换持久化实现,domain 照样跟着改。成本倒是实打实的:简单查询走 LambdaQueryWrapper,每加一个改接口、实现两处;复杂 SQL 再带上 XML,三处。档案服务固定是接口、XML 两处。这个项目里没有替换过一次持久化实现。结论不是"划不划算看情况":就这份代码而言,解耦没买到,过路费照收。
事务注解散在四个位置
这是我最想对比的一项。回滚边界藏在哪,看包名猜不出来。
同一个中台,三个业务模块三种放法。
采购模块:应用层实现 7 个文件带 @Transactional,领域层实现 7 个带,应用层接口上还有 15 个注解,挤在 2 个接口里:
public interface PurchaseBillService {
@Transactional(rollbackFor = Exception.class)
void createPurchBill(CreatePurchBillReq req);
// ... 一个接口 5 个方法都这么标
}
仓配模块:应用层实现 2 个文件,领域层 13 个文件(配送 11、仓库 2),策略层 12 个文件。
主数据模块:整个模块搜不到 @Transactional,TransactionTemplate 和 DataSourceTransactionManager 的手动用法也搜不到。这个模块有 26 个应用层实现、43 个 controller,自己一个事务注解都没有。
它的领域层不在自己手里。公共 component 模块的 domain/masterdata 下,27 个接口、27 个实现,外加 1 个 feign 契约和 1 个扩展类,共 56 个类。18 个实现类带事务注解,最多的一个类标了 13 个。改主数据的代码,得翻到另一个模块,才知道事务从哪开始。
档案服务简单:40 个 service 实现里 10 个带注解,33 个接口里 1 个把注解写在了接口上。扫一眼 impl 包就知道边界在哪。这是技术分层剩下的少数优点之一。
接口上那 15 个注解还有个坑。@Transactional 标在接口方法上,走 JDK 动态代理能生效。Spring 官方文档的说法是:切成 CGLIB 类代理,接口上的注解不被识别;但 4.x 之后的源码里有对接口方法的回退查找,多数版本实测依然生效——文档和实现对不上,实际行为取决于所用版本。项目里没看到全局代理方式的配置说明,这批注解到底是不是摆设,我没有运行期证据,留在后面「三点没确认」。
回滚边界是排查线上问题的第一道线索。它散落成这样,出事先花半小时找事务从哪开始、到哪结束。这个成本和选哪套分包方式无关,和有没有人管有关。
空目录和 37 比 1
供应链中台三个业务模块用同一套目录模板,各自取用了其中一部分。
主数据模块的 domain/intf 和 domain/impl 是空目录,一个类都没有,56 个领域类全在公共 component 模块。仓配模块的 domain 下没有 intf 子目录,9 个接口直接放在子领域包根,连 intf 包都省了。采购模块的 domain/extension 给扩展点留了位置,里面只有 1 个类。
档案服务没有这个问题,它没有 domain 目录。controller 下 8 个子包都有实际内容,没有空目录。
测试更直接:中台 37 个测试类,仓配 17、主数据 13、采购 6、公共 1,占主代码的 1.4%。其中 12 个测 controller,2 个直接测领域服务,1 个测 mapper,剩下 22 个是模块级综合测试。档案服务 1 个。52356 行领域代码,对应 2 个直接测试。
对照表
三点没确认:
一是采购模块 15 个事务注解写在接口上,是否生效取决于运行期代理方式和 Spring 版本,项目里有没有全局切到 CGLIB,我没抓到运行期证据;
二是主数据模块自己一个事务注解都没有,写路径全靠 component 的领域实现托管事务——26 个应用层实现的每一条写路径是否都落在带注解的领域实现上、有没有绕过去的直连调用,我没逐个核对(同一 Spring 容器内跨 Maven 模块调用,事务上下文本身没有问题,这不是疑点,疑点是覆盖率);
三是仓配把领域接口放在子领域包根、采购放在
intf子包,是约定还是历史遗留,没找到任何文档。
自查打卡
三层类数和行数(排除 src/test,行数按非空行):
find . -path '*/src/main/*' -name '*.java' | grep '/domain/' | wc -l
find . -path '*/src/main/*' -name '*.java' -exec cat {} + | grep -c '\S'
越层调用,应用层 import mapper:
grep -rn 'import .*repository\.mapper' --include='*.java' . | grep '/application/'
事务注解按包分布(目录名按实际项目调):
grep -rl '@Transactional' --include='*.java' . | grep -v test | grep -oE '/(application|domain|strategy|service)/' | sort | uniq -c
老炮点评
就说两件事。
第一,分包是目录问题,分层是依赖方向问题。目录谁都能立,依赖方向要靠检查守。最便宜的办法是一条 grep 进 CI:应用层 import mapper 直接让构建失败。讲究点用 ArchUnit 把依赖规则写成测试,每次构建跑一遍。
第二,给事务定一个唯一的位置。定在哪个层都行,全项目只能有一个。现在这种每层都有一些的状态,等于每层都没有。
至于 5885 行和 938 行那两个类,分层帮不上忙。业务规则没有别的地方可放,就会堆进顺手找到的那个大类里。这个坑,两种分包方式一起踩。
问你一句
你们项目的分包约定写在哪?最近一次有人越层调用,是 Code Review 拦下来的,还是合进去之后才发现的?
在评论区聊聊。
如果这篇对你有帮助,欢迎: 👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言
您的小小鼓励,是我继续更新的动力🚀
我是老炮,18 年 Java 老兵,仍在一线。🔥关注「Java老炮踩坑录」,真实企业项目复盘,不错过每一篇真实案例,少踩坑。
下期预告 《inner/ 和 job/ 子包:接口暴露面的第三层管理》
controller 按访问来源再分包,暴露面靠目录约定管理。这层约定管住了什么、漏掉了什么,下期数给你看。