DDD 精神:发现自己写不出领域驱动设计后,我是怎么重新理解DDD的
本文属于《我和苏Claw底的理想国》系列文章。该系列作品都是是作者和 OpenClaw 的对话,辩论,分析,甚至是对骂的的内容的整理。
网上关于 DDD 的文章很多,大多数都在讲聚合根、值对象、限界上下文这些概念,读完之后你知道了这些词是什么意思,但还是不知道怎么用。或许你也知道怎么用,怎么分包,怎么分层,怎么写具体的实体行为和领域逻辑的代码,但却不知道为什么这么做就是「领域驱动设计」,或者怎么做才不是「领域驱动设计」呢?
这篇文章不讲概念,讲思维方式。我想聊的是:一个后端工程师,怎么在日常开发里真正用上 DDD 的精神,而不是背一套术语。
一、怎么写代码有 DDD 味?DDD 的核心精神是什么?
先说一个判断:DDD 不是一套规范,是一种思维方式。
很多人学 DDD 学的是那套术语,然后开始对号入座,把代码往概念里塞。结果代码变得更复杂了,但业务逻辑反而更难找了。这不是 DDD,这是披着 DDD 外衣的过度设计。
DDD 真正的精神,我用三句话来概括:
第一句:代码应该说业务的语言,而不是让业务去理解代码的语言。
你现在打开自己的代码,看一眼方法名、类名、变量名——它们说的是技术语言还是业务语言?
技术语言长这样:UserDataProcessor、handleRequest、dataList、flag。
业务语言长这样:OrderFulfillmentService、confirmPayment、pendingOrders、isEligibleForRefund。
前者你要读完实现才知道它在干嘛,后者你看名字就知道这段代码在解决什么业务问题。这个差距,就是有没有 DDD 味的第一个分水岭。
第二句:边界是被发现的,不是被设计出来的——发现边界的方式是找到谁对什么负责、谁有权利改什么。
很多人在系统设计阶段就想把边界划得很精准,但这样是不对的,边界在业务在长期运转的过程中被发现出来的,不是你凭空想出来的。在设计的时候,你只要先搞清楚职责归属,设计的边界就够用了。"不能做什么"是边界的结果,不是边界的本质。
第三句:如无必要,勿增实体。不要死守规范,跟着复杂度走,复杂度没到不提前搭架子。
DDD 的分层和模式,是用来解决复杂度的工具,不是必须遵守的仪式。如果你的业务逻辑本身不复杂,强行引入聚合根、领域事件、应用层编排,只会让代码更难读、更难改。甚至 Controller 里写 IO,在一些场景下都是允许的。
这三句话是贯穿全文的底色,后面所有的内容都是在这三句话的基础上展开的。
二、怎么判断聚合?解决边界模糊的思路是什么?
边界模糊是 DDD 落地最常见的困境。两个概念数据上强相关,但业务上到底该不该放在一起,很难判断。
这里有三个问题,可以帮你发现边界。
问题一:谁有权利改它?
如果一个概念可以被多个不同的业务流程修改,而且修改的理由和时机都不一样,边界可能划大了。
商品的价格和库存,数据上都属于商品,放在一张表里很自然。但价格的变化是运营在改,库存的变化是每次下单在扣,两件事的触发方、频率、一致性要求完全不同。如果强绑在一起,高并发下库存扣减会锁到整个商品记录,包括价格字段。这就是一个典型的"数据上相关但业务上应该分开"的问题。
问题二:改一个需不需要连带改另一个?
如果不需要,它们的耦合可能是假的,可以拆开。
订单里的收货地址和用户的收货地址,数据上强相关,但用户改了地址,已有的订单地址不应该变。所以订单里存的是下单时的地址快照,而不是用户地址的引用。这两个东西看起来是同一个概念,实际上是两个不同的东西。
问题三:这两个东西能不能独立演化?
A 变了,B 必须跟着变 → 它们可能属于同一个聚合,需要在同一个事务里保证一致性。
A 变了,B 不需要立刻知道,延迟一点也没关系 → 它们应该分开,通过事件异步通信。
"审核已通过"和"资格已确认"必须同时发生,是同一个聚合。"资格已确认"和"通知已发送",通知失败不影响资格的有效性,通过领域事件异步触发就够了。
还有一个更根本的判断方法:问变化频率。
两个东西放在一起,意味着它们要一起变化。如果它们有不同的变化节奏,就是一个信号,说明它们可能不该在一起。这个问题在需求阶段几乎是隐形的,只有在系统变复杂之后才会暴露出来。所以要主动去问,而不是等它暴露。
三、聚合内部的数据和跨聚合的行为,分别该怎么处理?
这是 DDD 里最容易被误解的地方,一句话说清楚:
数据的归属由聚合边界决定,行为的协作由应用层编排。
聚合内部的数据,只有聚合根有权利修改。外部不能绕过聚合根直接操作内部对象。这保证了聚合内部的业务规则始终由聚合自己来保护。
跨聚合的行为,不属于任何一个聚合,放在应用层编排。Order 和 Inventory 是两个独立的聚合,"下单"这个动作需要同时操作两者,这个协作逻辑放在应用层的 PlaceOrderService 里,由它来编排两个聚合的行为。
关于实体行为和 IO 的分离,这里有一个常见的误解需要澄清。
很多人觉得"申请被提交"就是"申请被写入数据库",所以实体的 submit() 方法里应该有写库操作。这个理解是错的。
application.submit() 在领域层做的事只有一件:把申请对象的状态从草稿变成已提交,同时做业务校验。它不知道数据库的存在。
public void submit() {
if (status != ApplicationStatus.DRAFT) {
throw new DomainException("只有草稿状态的申请才能提交");
}
this.status = ApplicationStatus.SUBMITTED;
}
写数据库是应用层的事:
public void submitApplication(String applicationId) {
EnrollmentApplication application = repository.findById(applicationId);
application.submit(); // 只改内存状态
repository.save(application); // IO 在这里
}
这样分开之后,领域层的代码不依赖任何框架和基础设施,单元测试极其简单,直接 new 一个对象调方法就能测。
但这里有一个重要的补充:不要为了分层而分层。
如果你的业务逻辑本身不复杂,一个 Service 方法里直接写完校验、写库、返回结果,完全没有问题。这不是坏代码,是合适的代码。
我们按复杂度可以分三个阶段引入结构:
第一阶段,业务简单,直接写 Service + 数据库操作,不需要实体行为,不需要领域事件。
第二阶段,状态流转变多,Service 里开始出现大量状态判断,这时候把状态机和业务规则收进实体,让实体自己保护自己的不变量。
第三阶段,多个聚合之间开始有复杂协作,跨聚合的副作用开始增多,才引入领域事件和严格的应用层编排。
复杂度没到,不要提前搭架子。你已经被复杂度逼到这一步了,引入这些东西是在解决真实的痛点,不是在追求设计美感。
四、拿到流程图,怎么把它翻译成业务语言?
产品给的流程图有一个天然的缺陷:它描述的是"操作路径",不是"业务意图"。每一个框和箭头,都是在说"用户做了什么、系统做了什么",但不会告诉你"为什么要这样做"、"这个概念的边界在哪里"。
所以拿到流程图,第一件事不是开始建模,而是把它翻译成业务语言。
翻译有三个要求:
主语必须是业务角色,不能是系统。
"系统校验用户资质" → "用户提交了入驻申请,平台判断是否符合准入条件"
主语换了之后,你会发现"平台"和"用户"是两个不同的角色,它们之间有一个"申请"的概念在流转,这个概念在原来的流程图里是隐形的。
动词必须是业务动词,不能是技术动词。
"更新订单状态为已支付" → "订单完成了支付"
"调用库存接口扣减数量" → "商品库存被预占"
技术动词换成业务动词之后,你会发现"支付完成"和"库存预占"是两件独立的事,它们之间有没有强依赖,是不是必须在同一个事务里,这个问题才会浮出来。
结果必须用过去时表达,说的是"发生了什么",不是"做了什么"。
"发送通知" → "用户收到了审核结果通知"
用过去时表达的好处是:它迫使你思考这件事的结果是什么,而不只是动作本身。"发送通知"是动作,"用户收到了通知"是结果。如果通知发送失败了,用户没收到,这件事算不算完成?这个问题在动作语言里是隐形的,在结果语言里是显式的。
翻译完之后,做一个验证:把翻译结果念给产品听,问他"我理解的对吗"。如果产品说"对,就是这个意思",翻译就成功了。如果产品说"不对",说明流程图里还有信息你没读出来,或者你引入了产品没有的假设。
五、从业务语言到领域草图,这些问题你需要回答
翻译完之后你手里有一段业务语言的描述。现在要从这段话里把领域对象挖出来。
不是所有概念都值得在第一次就建模出来。挖的方式是按顺序问六个问题。
第一个问题:这个概念现在让我痛吗?
这是所有问题的前置过滤器。没有痛感,后面的问题都不用问,先用最简单的方式实现,记在脑子里就行。有痛感,才继续往下问。
痛感有三种:同一段逻辑在多个地方重复出现;改一个地方要连带改很多地方;这个概念的含义开始模糊,不同的人理解不一样。
第二个问题:这个概念有没有自己的生命周期?
它会被创建、变化、消亡吗?还是它只是另一个概念的一个属性?
有生命周期 → 实体,需要 ID,需要独立存储。没有生命周期,只关心它的值是什么 → 值对象,跟着宿主存,顺手把它的校验规则封进去。
第三个问题:这个行为天然属于谁?
对着业务描述里的每一个动词,问这个行为最自然地属于哪个概念。
天然属于某个实体 → 放进实体方法,让实体自己保护规则。涉及多个实体,放哪里都别扭 → 领域服务。
注意:不要因为一个行为有 IO 就把它推进领域服务。IO 是应用层的事,领域服务只处理"这个行为在业务上涉及多个概念"的情况。
第四个问题:这两个概念能不能独立变化?
A 变了,B 必须跟着变 → 同一个聚合,同一个事务。A 变了,B 不需要立刻知道 → 不同聚合,异步事件通信。
第五个问题:同一个词在不同场景下意思一样吗?
"商家"在入驻流程里是申请者,在订单流程里是履约方,在结算流程里是收款方。如果你用同一个 Merchant 对象承载这三种角色,它会越来越臃肿,改一个场景的逻辑会影响其他场景。
意思不一样 → 限界上下文的边界信号,在不同的上下文里分别建模。
第六个问题:我现在的代码,改一个地方要连带改几个地方?
这个问题不是在设计阶段问的,是在写代码过程中持续问的。超过三个地方 → 边界可能划错了,考虑收拢。
走完这六个问题,你得到了领域草图。然后做一次走查:用自然语言把业务流程跑一遍,对照草图逐句验证——这句话描述的事情,在我的代码结构里,谁来做,怎么做,做完之后状态存在哪里?
走查过程中发现"不知道该由谁做" → 缺了一个概念。发现"需要跨越很多对象才能完成" → 边界划错了。
走查通过,才开始写代码。
最后
DDD 最容易走偏的地方,是把它当成一套需要遵守的规范,而不是一套帮你思考的工具。
规范会让你问"我有没有按照 DDD 的方式写",工具会让你问"这样写能不能更好地表达业务意图"。前者让你对着概念找代码,后者让你对着问题找答案。
我在日常开发里真正用到的,不是聚合根和值对象这些词,而是这几个习惯:命名用业务词汇、规则放在合适的地方、改代码之前先想清楚这个概念属于谁。
结构可以晚点加,但思维方式要从一开始就对。