为什么网上大多数 DDD 都是错的,以及真正的解决方案是什么

10 阅读14分钟

当 DDD 只剩术语,它就已经无法落地:真正的出路是回到业务对象

本文是人类写的,只是ai润色

当 DDD 只剩术语,它就已经无法落地:真正的出路是回到业务对象

DDD 在中文技术圈已经被讲了很多年,但一个很尴尬的事实是: 术语越来越多,真正能落地的代码却越来越少。

你随便打开几篇 DDD 文章,几乎都会看到同一套词:聚合、实体、值对象、领域服务、仓储、限界上下文。看起来很完整,甚至有一种“只要把这些词学会,就能设计好系统”的幻觉。

但真正的问题恰恰在这里。

这些文章最常见的毛病,不是讲错了一两个概念,而是它们把本来应该服务于业务理解的术语,变成了阅读门槛本身。读者还没理解业务,就先被一堆抽象词压住;还没理解系统为什么这么设计,就先被迫接受一套命名体系。最后留下的往往不是“我明白了业务”,而是“我记住了一堆词”。

更糟的是,很多 DDD demo 看起来在讲建模,实际写的却还是最传统的 Service 模式:

  1. Service 查数据
  2. Service 做判断
  3. Service 改字段
  4. Service 保存结果

所谓领域对象只是数据壳,所谓领域服务只是流程脚本换了个名字。术语是 DDD 的,代码却还是老式事务脚本。

这就是为什么很多人学了很久 DDD,最后仍然不会落地。不是因为他们不够努力,而是因为他们接触到的很多内容,从一开始就没有真正回答最关键的问题:

这个系统真正的业务对象到底是什么?

如果这个问题不先回答,后面的聚合、实体、值对象、领域服务,全都可能沦为装饰。因为你根本还没找到业务中心,就已经开始分层、命名、套模板了。最后的结果往往就是:结构越来越像 DDD,代码却越来越难懂,落地却越来越困难。

所以我对大多数 DDD 文章的批评很简单:

它们最大的问题不是不够高级,而是太多术语、太少业务;太多形式、太少中心;太多分层、太少真正可落地的对象。

但这篇文章不只是批评。

我给出的解决方案也很直接:

  1. 不从术语出发,而从业务对象出发
  2. 先找到那个“如果它不存在,系统就不成立”的东西
  3. 让业务对象承担业务计算和业务一致性
  4. 让 Service 退回编排层
  5. 让 Repository 退回持久化层

也就是说,真正能落地的设计,不是先学会说一套 DDD 语言,而是先找到真正的业务中心,再围绕它组织系统。

接下来我要把这个批评说得更彻底一点,也把这条替代路径讲得更具体一点。因为只说“主流 DDD 有问题”还不够,关键在于要指出它到底错在哪里,以及真正可落地的出路到底是什么。

绝大多数 DDD Demo 都是假的,而真正的出路是回到业务对象

不是说它们不能跑。
不是说它们没有分层。
也不是说它们没有把类命名成 AggregateEntityDomainService

而是说,它们根本没有完成最关键的一步:

它们没有找到真正的业务对象。

所以它们所谓的 DDD,绝大多数只是 Service 模式换皮。

网上最常见的 DDD 内容,问题从来不是“术语讲得不够多”,而恰恰是术语太多,业务太少。文章一开头就是聚合、实体、值对象、领域服务、仓储、限界上下文,像在背词典;可一旦落到代码,马上原形毕露:

  1. Service 查数据
  2. Service 做判断
  3. Service 改字段
  4. Service 保存结果

然后作者告诉你,这叫领域建模。

不是。

这叫流程脚本。
只是把流程脚本放进了一个更体面的词汇系统里。

伪 DDD 最大的问题,不是写得丑,而是中心从一开始就错了

很多人以为 DDD 的关键是“把项目拆成 domain、application、infrastructure”。
这当然可以做,但这根本不是重点。

真正的问题只有一个:

这个系统真正依赖的业务对象,到底是什么?

如果这个问题没答对,后面所有分层、所有命名、所有目录结构,都会变成装饰。

因为你最后还是会回到熟悉的套路:

  1. 查几张表
  2. 拿几个对象
  3. 在 Service 里拼装业务
  4. 再把结果存回去

这不叫建模。
这叫把业务暂时寄存在 Service 的执行顺序里。

判断一个对象是不是真正业务对象,标准其实很简单

就问一句:

如果它不存在,系统还能成立吗?

如果还能成立,它大概率不是业务核心。
如果它一旦不存在,系统就失去了最基本的业务解释力,那它才值得成为建模中心。

但这里还要更严格一点。

真正的业务对象,不只是“系统里必须提到它”,而是:

  1. 它真的接收业务输入
  2. 它真的执行业务计算
  3. 它真的输出业务结果
  4. 它真的承担关键一致性

如果这些事都不是它做的,而是 Service 做的,那它就不是业务对象,只是业务道具。

银行转账为什么最适合拿来揭穿伪 DDD

网上最常见的 bank demo 通常长这样:

  1. 查转出账户
  2. 查转入账户
  3. 判断余额
  4. 一个减钱,一个加钱
  5. 保存两个账户

然后再起个名字叫 TransferDomainService,仿佛事情 suddenly 就高级了。

但问题恰恰就在这里:
转账根本没有被建模出来。

真正重要的不是两个账户字段被修改了,而是“发生了一次转账”。
真正重要的不是某个 Service 按顺序执行完了几步,而是这次转账必须满足一组不可破坏的业务约束:

  1. 转出余额必须足够
  2. 转账前后总额必须守恒
  3. 手续费必须参与计算
  4. 税务规则必须参与计算
  5. 某些账户类型可能禁止某些转账
  6. 某些时间段可能有不同规则

这些东西如果全在 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 元,满足风控,计算税费,结果必须正确。”

这本来应该是一条非常纯粹的业务验证。
可很多系统会把你逼到这种地步:

  1. 启动账户系统
  2. 启动支付系统
  3. 启动风控系统
  4. 启动税务系统
  5. 启动审计系统
  6. 再把数据库、缓存、消息队列全拖起来

你只是想验证“转账”,最后却不得不拉起半个平台。

这不是单纯的测试工程问题。
这往往说明“转账”根本没有被收拢成真正的业务对象,而是被拆碎后散落在多个 Service、多个模块、多个外围依赖之间。于是你没法验证“转账本身”,只能验证“所有东西拼起来以后,转账大概还能跑”。

这种痛苦本身就是证据。

它说明真正的业务边界,不在你当前那些漂亮的系统命名里,而在那些必须共同启动、共同验证、共同成立的部分里。

所以发现真正业务对象的一个非常实用的方法就是:

  1. 看哪组规则总是一起计算
  2. 看哪组数据总是必须一起出现
  3. 看哪组结果总是必须一起成立
  4. 看为了验证它,你是不是总要把多个系统一起拖起来

如果答案总是一样,那真正的业务对象大概率就藏在那里。

对转账系统来说,这个对象不是账户 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);

关键不是这三段代码好不好看,而是职责终于正常了:

  1. Service 不再假装自己是业务中心
  2. Transfer 真正承担了转账运算
  3. Repository 只保存结果

这才叫领域建模。

电商系统里,按名词拆分往往拆不出业务

再看电商。

很多系统拆成订单域、库存域、出库域、支付域,看起来层次分明,实际上业务常常是碎的。

比如一个商品系统里,订单和出库之间如果没有稳定的一对一业务关系,这个系统的数据解释力就是有问题的。
订单存在却没有出库依据,说明业务没有闭合;出库存在却无法追溯订单来源,说明记录失去了意义。

这时候真正该被建模的,很可能不是“订单”这个孤立名词,而是“履约”这个业务对象。

这就是很多伪 DDD 的根本误区:
它们太迷信名词,太不重视业务结果。

图书系统里,用户域和图书域的机械切分,本身就是错的

图书系统更能说明这个问题。

很多人会机械地拆:

  1. 用户域
  2. 图书域
  3. 借阅域
  4. 罚金域

看起来很整齐,但这类整齐往往只是视觉上的,不是业务上的。

真正的问题是:

一本书是否可借,到底是谁说了算?

图书对象吗?它知道库存,但它不知道借书的人信用怎么样,不知道这个人是否逾期,不知道这个人是不是已经借了同一本书。
用户对象吗?它知道自己的信用,但它不知道目标图书还有没有库存,不知道这次借阅面对的到底是哪本书。

所以这个问题从一开始就不是“用户问题”或者“图书问题”。
它是借阅问题。

也就是说,真正的业务对象不是 User,不是 Book,而是 Borrowing

这时候系统中心一下就变了。

你不再问:

“用户能不能借?”
“图书能不能借?”

你会开始问:

在这次借阅中,这个用户是否可以借这本书?

只有这个问题是对的。
也只有这个问题对了,系统边界才可能对。

知道了业务对象之后,怎么落地?

很多文章最虚的地方就在这里。前面讲一堆抽象话,落地时还是把所有逻辑塞回 Service。

其实真正落地就三步:

  1. Service 查询必要数据
  2. Domain 计算业务结果
  3. 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

因为还书天然同时影响:

  1. 借阅记录
  2. 图书库存
  3. 用户信用
  4. 罚金
  5. 罚金订单

很多人一看到这里,就会说:“这说明耦合太高,边界有问题。”

不,这恰恰说明你终于碰到了真正的业务。

业务从来不是因为“每个类都只动一张表”才成立的。
业务恰恰是因为“一次行为会同时带来一组必须共同成立的结果”才成立的。

所以正确问题不是“为什么一个方法动了这么多对象”,而是:

这些变化是不是同一次业务结果?

如果是,那就应该让一次 Service 编排把它完整落定,而不是强行拆碎,然后再让 Service 调 Service 把碎片重新拼回去。

Service 不应该调用 Service

这是我认为很多系统最该立住的一条原则。

如果 Service 代表用户可见的一个业务操作单元,那么它就不应该再依赖另一个 Service 来解释自己。
一旦 Service 调 Service,往往说明你没有找到真正的业务对象,只能靠一层 Service 去补另一层 Service。

这不是分层清晰。
这是业务切分失败之后的补丁。

正确做法是:

  1. Service 直接依赖自己所需的 Repository
  2. Service 直接调用自己所需的业务对象
  3. Service 对这次完整业务结果负责

这样系统的骨架才会硬。

是否能纯内存测试,是最诚实的检验标准

很多人会说自己有领域模型。
但如果那个领域模型必须启动 Spring、连数据库、配事务、带外部服务才能跑,那它十有八九不是业务对象,只是基础设施流程的一部分。

真正的业务对象,应该天然适合在内存里测试。

因为业务本来就不属于数据库,也不属于框架。
数据库和框架只是外壳,不是业务本体。

所以一个很诚实的判断标准就是:

核心业务规则能不能离开系统外壳独立测试?

如果不能,那你大概率并没有真的把业务放进对象。

结论

绝大多数 DDD demo 的问题,不是语法不高级,不是术语不完整,而是它们根本没有建模出真正的业务对象。

于是最后剩下的就只有:

  1. 漂亮的分层
  2. 正确的术语
  3. 空心的领域
  4. 肥大的 Service

这就是为什么我说,绝大多数 DDD demo 都是假的。

它们不是完全没有价值。
但如果把那种写法当成领域建模的标准答案,就会把一代又一代程序员带回同一个误区:

以为自己在建模业务,实际上只是在给 Service 模式重新起名。

真正该做的事情始终只有一件:

先找到真正的业务对象。
再让 Service 退回编排层。
再让 Repository 退回持久化层。
最后让业务一致性真正落在业务对象上。

只有到那一步,领域建模才算开始。

这是我的demo项目 cypress927/real-business-ddd: A radical critique of mainstream DDD demos: stop scripting services, start modeling real business objects.