当 DDD 只剩术语,它就已经无法落地:真正的出路是回到业务对象
本文是人类写的,只是ai润色
当 DDD 只剩术语,它就已经无法落地:真正的出路是回到业务对象
DDD 在中文技术圈已经被讲了很多年,但一个很尴尬的事实是: 术语越来越多,真正能落地的代码却越来越少。
你随便打开几篇 DDD 文章,几乎都会看到同一套词:聚合、实体、值对象、领域服务、仓储、限界上下文。看起来很完整,甚至有一种“只要把这些词学会,就能设计好系统”的幻觉。
但真正的问题恰恰在这里。
这些文章最常见的毛病,不是讲错了一两个概念,而是它们把本来应该服务于业务理解的术语,变成了阅读门槛本身。读者还没理解业务,就先被一堆抽象词压住;还没理解系统为什么这么设计,就先被迫接受一套命名体系。最后留下的往往不是“我明白了业务”,而是“我记住了一堆词”。
更糟的是,很多 DDD demo 看起来在讲建模,实际写的却还是最传统的 Service 模式:
- Service 查数据
- Service 做判断
- Service 改字段
- Service 保存结果
所谓领域对象只是数据壳,所谓领域服务只是流程脚本换了个名字。术语是 DDD 的,代码却还是老式事务脚本。
这就是为什么很多人学了很久 DDD,最后仍然不会落地。不是因为他们不够努力,而是因为他们接触到的很多内容,从一开始就没有真正回答最关键的问题:
这个系统真正的业务对象到底是什么?
如果这个问题不先回答,后面的聚合、实体、值对象、领域服务,全都可能沦为装饰。因为你根本还没找到业务中心,就已经开始分层、命名、套模板了。最后的结果往往就是:结构越来越像 DDD,代码却越来越难懂,落地却越来越困难。
所以我对大多数 DDD 文章的批评很简单:
它们最大的问题不是不够高级,而是太多术语、太少业务;太多形式、太少中心;太多分层、太少真正可落地的对象。
但这篇文章不只是批评。
我给出的解决方案也很直接:
- 不从术语出发,而从业务对象出发
- 先找到那个“如果它不存在,系统就不成立”的东西
- 让业务对象承担业务计算和业务一致性
- 让 Service 退回编排层
- 让 Repository 退回持久化层
也就是说,真正能落地的设计,不是先学会说一套 DDD 语言,而是先找到真正的业务中心,再围绕它组织系统。
接下来我要把这个批评说得更彻底一点,也把这条替代路径讲得更具体一点。因为只说“主流 DDD 有问题”还不够,关键在于要指出它到底错在哪里,以及真正可落地的出路到底是什么。
绝大多数 DDD Demo 都是假的,而真正的出路是回到业务对象
不是说它们不能跑。
不是说它们没有分层。
也不是说它们没有把类命名成 Aggregate、Entity、DomainService。
而是说,它们根本没有完成最关键的一步:
它们没有找到真正的业务对象。
所以它们所谓的 DDD,绝大多数只是 Service 模式换皮。
网上最常见的 DDD 内容,问题从来不是“术语讲得不够多”,而恰恰是术语太多,业务太少。文章一开头就是聚合、实体、值对象、领域服务、仓储、限界上下文,像在背词典;可一旦落到代码,马上原形毕露:
- Service 查数据
- Service 做判断
- Service 改字段
- Service 保存结果
然后作者告诉你,这叫领域建模。
不是。
这叫流程脚本。
只是把流程脚本放进了一个更体面的词汇系统里。
伪 DDD 最大的问题,不是写得丑,而是中心从一开始就错了
很多人以为 DDD 的关键是“把项目拆成 domain、application、infrastructure”。
这当然可以做,但这根本不是重点。
真正的问题只有一个:
这个系统真正依赖的业务对象,到底是什么?
如果这个问题没答对,后面所有分层、所有命名、所有目录结构,都会变成装饰。
因为你最后还是会回到熟悉的套路:
- 查几张表
- 拿几个对象
- 在 Service 里拼装业务
- 再把结果存回去
这不叫建模。
这叫把业务暂时寄存在 Service 的执行顺序里。
判断一个对象是不是真正业务对象,标准其实很简单
就问一句:
如果它不存在,系统还能成立吗?
如果还能成立,它大概率不是业务核心。
如果它一旦不存在,系统就失去了最基本的业务解释力,那它才值得成为建模中心。
但这里还要更严格一点。
真正的业务对象,不只是“系统里必须提到它”,而是:
- 它真的接收业务输入
- 它真的执行业务计算
- 它真的输出业务结果
- 它真的承担关键一致性
如果这些事都不是它做的,而是 Service 做的,那它就不是业务对象,只是业务道具。
银行转账为什么最适合拿来揭穿伪 DDD
网上最常见的 bank demo 通常长这样:
- 查转出账户
- 查转入账户
- 判断余额
- 一个减钱,一个加钱
- 保存两个账户
然后再起个名字叫 TransferDomainService,仿佛事情 suddenly 就高级了。
但问题恰恰就在这里:
转账根本没有被建模出来。
真正重要的不是两个账户字段被修改了,而是“发生了一次转账”。
真正重要的不是某个 Service 按顺序执行完了几步,而是这次转账必须满足一组不可破坏的业务约束:
- 转出余额必须足够
- 转账前后总额必须守恒
- 手续费必须参与计算
- 税务规则必须参与计算
- 某些账户类型可能禁止某些转账
- 某些时间段可能有不同规则
这些东西如果全在 Service 里,那么所谓 DDD 就只是表面化妆。
真正该出现的对象,不是“一个会操作两个账户的 Service”,而是 Transfer 本身。
因为银行系统不是靠“两个账户分别变了”成立的,而是靠“转账这件事被正确表达了”成立的。
一个更典型、也更误导人的错误例子:把流程 Service 命名成 DomainService
网上大量所谓 DDD 示例,真正做的其实只是下面这件事:把原本就存在的事务脚本 Service,换一个更像领域建模的名字。
public class TransferDomainService {
private final AccountRepository accountRepository;
private final TaxService taxService;
private final RiskControlService riskControlService;
public void transfer(String fromAccountId, String toAccountId, BigDecimal amount) {
Account from = accountRepository.findById(fromAccountId);
Account to = accountRepository.findById(toAccountId);
if (from.getBalance().compareTo(amount) < 0) {
throw new IllegalArgumentException("余额不足");
}
if (!riskControlService.allowTransfer(from, to, amount)) {
throw new IllegalArgumentException("风控不允许转账");
}
BigDecimal tax = taxService.calculate(amount);
from.setBalance(from.getBalance().subtract(amount).subtract(tax));
to.setBalance(to.getBalance().add(amount));
accountRepository.save(from);
accountRepository.save(to);
}
}
这类代码最糟糕的地方,不是它不能运行,而是它会误导读者,让人以为这就叫领域建模。
它有实体,有仓储,有税务服务,有风控服务,还有一个名字听起来很正确的 TransferDomainService。
可真正的业务对象 Transfer 依然不存在,真正的业务一致性依然没有自己的宿主,真正的业务运算依然全部写在 Service 里。
这不是领域建模。
这只是一个更会起名的事务脚本。
所以我反复强调,问题从来不在于类名像不像 DDD,而在于:业务到底有没有被对象化。
如果没有,那么 DomainService 这个名字本身就是伪装。
如果测试一条转账规则要同时启动半个平台,那你的业务划分大概率已经错了
还有一个比读代码更诚实的判断标准:看你测试时到底有多痛苦。
假设你只是想验证一条核心转账规则:
“A 向 B 转 100 元,满足风控,计算税费,结果必须正确。”
这本来应该是一条非常纯粹的业务验证。
可很多系统会把你逼到这种地步:
- 启动账户系统
- 启动支付系统
- 启动风控系统
- 启动税务系统
- 启动审计系统
- 再把数据库、缓存、消息队列全拖起来
你只是想验证“转账”,最后却不得不拉起半个平台。
这不是单纯的测试工程问题。
这往往说明“转账”根本没有被收拢成真正的业务对象,而是被拆碎后散落在多个 Service、多个模块、多个外围依赖之间。于是你没法验证“转账本身”,只能验证“所有东西拼起来以后,转账大概还能跑”。
这种痛苦本身就是证据。
它说明真正的业务边界,不在你当前那些漂亮的系统命名里,而在那些必须共同启动、共同验证、共同成立的部分里。
所以发现真正业务对象的一个非常实用的方法就是:
- 看哪组规则总是一起计算
- 看哪组数据总是必须一起出现
- 看哪组结果总是必须一起成立
- 看为了验证它,你是不是总要把多个系统一起拖起来
如果答案总是一样,那真正的业务对象大概率就藏在那里。
对转账系统来说,这个对象不是账户 Service、税务 API 或风控模块。
真正的对象就是:转账本身。
真正的转账,不该由 Service 计算
如果你真的把转账当成业务对象,那代码的结构就应该彻底反过来。
先由 Service 查询数据:
Account from = accountRepository.findById(fromAccountId);
Account to = accountRepository.findById(toAccountId);
List<TransferRule> rules = transferRuleRepository.findRules(from, to, amount);
然后把它们交给 Transfer:
Transfer transfer = new Transfer(
from.getBalance(),
to.getBalance(),
amount,
rules
);
TransferResult result = transfer.execute();
最后再落库:
from.setBalance(result.getNewFromBalance());
to.setBalance(result.getNewToBalance());
accountRepository.save(from);
accountRepository.save(to);
关键不是这三段代码好不好看,而是职责终于正常了:
- Service 不再假装自己是业务中心
- Transfer 真正承担了转账运算
- Repository 只保存结果
这才叫领域建模。
电商系统里,按名词拆分往往拆不出业务
再看电商。
很多系统拆成订单域、库存域、出库域、支付域,看起来层次分明,实际上业务常常是碎的。
比如一个商品系统里,订单和出库之间如果没有稳定的一对一业务关系,这个系统的数据解释力就是有问题的。
订单存在却没有出库依据,说明业务没有闭合;出库存在却无法追溯订单来源,说明记录失去了意义。
这时候真正该被建模的,很可能不是“订单”这个孤立名词,而是“履约”这个业务对象。
这就是很多伪 DDD 的根本误区:
它们太迷信名词,太不重视业务结果。
图书系统里,用户域和图书域的机械切分,本身就是错的
图书系统更能说明这个问题。
很多人会机械地拆:
- 用户域
- 图书域
- 借阅域
- 罚金域
看起来很整齐,但这类整齐往往只是视觉上的,不是业务上的。
真正的问题是:
一本书是否可借,到底是谁说了算?
图书对象吗?它知道库存,但它不知道借书的人信用怎么样,不知道这个人是否逾期,不知道这个人是不是已经借了同一本书。
用户对象吗?它知道自己的信用,但它不知道目标图书还有没有库存,不知道这次借阅面对的到底是哪本书。
所以这个问题从一开始就不是“用户问题”或者“图书问题”。
它是借阅问题。
也就是说,真正的业务对象不是 User,不是 Book,而是 Borrowing。
这时候系统中心一下就变了。
你不再问:
“用户能不能借?”
“图书能不能借?”
你会开始问:
在这次借阅中,这个用户是否可以借这本书?
只有这个问题是对的。
也只有这个问题对了,系统边界才可能对。
知道了业务对象之后,怎么落地?
很多文章最虚的地方就在这里。前面讲一堆抽象话,落地时还是把所有逻辑塞回 Service。
其实真正落地就三步:
- Service 查询必要数据
- Domain 计算业务结果
- Repository 保存业务结果
说白了就是:
- Service 编排
- Domain 运算
- Repository 落库
如果第二步不存在,也就是 Service 把业务自己做完了,那你就别再叫它 DDD。
借阅系统的正确落地方式
借书流程应该是这样:
public BorrowRecord borrow(String userId, String isbn) {
User user = userRepository.findById(userId);
Book book = bookRepository.findById(isbn);
boolean hasOverdue = borrowRecordRepository.hasOverdueBooks(userId);
boolean hasBorrowedSameBook = borrowRecordRepository.hasBorrowedBook(userId, isbn);
BorrowCheckResult checkResult = borrowChecker.check(
user.getCreditScore(),
user.getCurrentBorrows(),
hasOverdue,
hasBorrowedSameBook
);
if (!checkResult.canBorrow()) {
throw new IllegalArgumentException(checkResult.getReason());
}
LocalDate borrowDate = LocalDate.now();
LocalDate dueDate = dueDateCalculator.calculate(borrowDate);
BorrowRecord record = BorrowRecord.create(
UUID.randomUUID().toString(),
userId,
isbn,
borrowDate,
dueDate
);
borrowRecordRepository.save(record);
bookRepository.save(book.decreaseAvailable());
userRepository.save(user.incrementBorrows());
return record;
}
你会发现这里最关键的地方不是 BorrowService,而是 borrowChecker.check(...) 这一步。
如果没有这个业务对象,这个流程马上就会退化回熟悉的脚本模式。
还书更能暴露一个系统到底是不是伪 DDD
因为还书天然同时影响:
- 借阅记录
- 图书库存
- 用户信用
- 罚金
- 罚金订单
很多人一看到这里,就会说:“这说明耦合太高,边界有问题。”
不,这恰恰说明你终于碰到了真正的业务。
业务从来不是因为“每个类都只动一张表”才成立的。
业务恰恰是因为“一次行为会同时带来一组必须共同成立的结果”才成立的。
所以正确问题不是“为什么一个方法动了这么多对象”,而是:
这些变化是不是同一次业务结果?
如果是,那就应该让一次 Service 编排把它完整落定,而不是强行拆碎,然后再让 Service 调 Service 把碎片重新拼回去。
Service 不应该调用 Service
这是我认为很多系统最该立住的一条原则。
如果 Service 代表用户可见的一个业务操作单元,那么它就不应该再依赖另一个 Service 来解释自己。
一旦 Service 调 Service,往往说明你没有找到真正的业务对象,只能靠一层 Service 去补另一层 Service。
这不是分层清晰。
这是业务切分失败之后的补丁。
正确做法是:
- Service 直接依赖自己所需的 Repository
- Service 直接调用自己所需的业务对象
- Service 对这次完整业务结果负责
这样系统的骨架才会硬。
是否能纯内存测试,是最诚实的检验标准
很多人会说自己有领域模型。
但如果那个领域模型必须启动 Spring、连数据库、配事务、带外部服务才能跑,那它十有八九不是业务对象,只是基础设施流程的一部分。
真正的业务对象,应该天然适合在内存里测试。
因为业务本来就不属于数据库,也不属于框架。
数据库和框架只是外壳,不是业务本体。
所以一个很诚实的判断标准就是:
核心业务规则能不能离开系统外壳独立测试?
如果不能,那你大概率并没有真的把业务放进对象。
结论
绝大多数 DDD demo 的问题,不是语法不高级,不是术语不完整,而是它们根本没有建模出真正的业务对象。
于是最后剩下的就只有:
- 漂亮的分层
- 正确的术语
- 空心的领域
- 肥大的 Service
这就是为什么我说,绝大多数 DDD demo 都是假的。
它们不是完全没有价值。
但如果把那种写法当成领域建模的标准答案,就会把一代又一代程序员带回同一个误区:
以为自己在建模业务,实际上只是在给 Service 模式重新起名。
真正该做的事情始终只有一件:
先找到真正的业务对象。
再让 Service 退回编排层。
再让 Repository 退回持久化层。
最后让业务一致性真正落在业务对象上。
只有到那一步,领域建模才算开始。