同一个团队两套分包:DDD 四层落地后,domain 层最大的类还是 5885 行

老炮踩坑录 · 💎 老炮视野系列

两个真实企业项目的真实代码,逐层统计: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 个:

java 复制代码
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:

java 复制代码
import com.example.scm.infrastructure.repository.mapper.purchase.PurchaseOrderMapper;

应用层直接引 mapper,中间两层全跳过。编译期拦不住,全靠口头约定。

12、11、5,三类引用出现在同一个模块里。说明团队内部对"应用层该调谁"没有统一答案。或者有过答案,后来没人守。

档案服务那边链路短。37 个 controller 里 35 个引 service,没有一个引 mapper。controller 平均 57 行,多数方法一行转发,越层无从谈起------一共三层,没地方可越。

短链路挡得住越层,挡不住规则没地方放。ConfigController 191 行,保存报表配置的方法里内联了一段重复校验:

java 复制代码
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:

java 复制代码
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 个接口里:

java 复制代码
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,行数按非空行):

bash 复制代码
find . -path '*/src/main/*' -name '*.java' | grep '/domain/' | wc -l
find . -path '*/src/main/*' -name '*.java' -exec cat {} + | grep -c '\S'

越层调用,应用层 import mapper:

bash 复制代码
grep -rn 'import .*repository\.mapper' --include='*.java' . | grep '/application/'

事务注解按包分布(目录名按实际项目调):

bash 复制代码
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 按访问来源再分包,暴露面靠目录约定管理。这层约定管住了什么、漏掉了什么,下期数给你看。

相关推荐
文丑颜不良啊26 分钟前
LeetCode2541~2560题目解析
java·leetcode
Anymous30 分钟前
我手写的 Redis 锁,输给了 10 秒的 TTL
后端·架构
瑞雪兆丰年兮34 分钟前
JavaWeb-day11-SpringBootWeb案例(文件上传)
java·运维·服务器·javaweb·oss
Highcharts.js1 小时前
Highcharts智能数据分析平台Orbit 多语言配置技术文档
java·前端·javascript·highcharts·orbit·智能数据分析·设置文档
Rain的Java大神之路1 小时前
设计一套生产级 API 接口:从契约、安全、幂等到限流,我踩过的坑都在这了
java·后端·面试
SimonKing1 小时前
零配置也能白嫖前沿大模型!dsh-our-free-model插件真香警告
java·后端·程序员
nice_lcj5201 小时前
【从零理解 Spring AI · 15】让检索更准:先宽召回,再精排序
java·后端·rag·spring ai·ai应用开发·检索优化·重排序
回家路上绕了弯1 小时前
要人确认的那一步,在确认什么?
后端
IT_陈寒2 小时前
JavaScript项目实战经验分享:从内存泄漏到性能优化的血泪史
前端·人工智能·后端