1. 我为什么在经历过建表驱动的开发后,转向了DDD
先说说我自己的背景。我大概有十年左右的Java后端经验,早期做传统企业应用,后来做互联网业务系统。很长一段时间里,我写代码的方式是“建表驱动”——拿到需求先设计数据库表,表定好了,代码照着CRUD写,Service层堆满业务逻辑,一个方法几百行是常事。系统前两年还好,第三年开始变味:加一个字段要关联十几个表,改一个逻辑要排查六七个Service,新人上手先折腾两周环境,再看两周代码才能接需求。每次迭代,光“理解现有逻辑为什么这样写”就要耗掉大半时间。
后来我接触到领域驱动设计(DDD),最初的想法很简单:把业务逻辑从数据模型里解出来。但真正研究进去之后,我才发现DDD解决的远不止是代码组织问题,它本质上是一套用统一语言把业务复杂度转化为软件结构复杂度的方法论。核心价值不是让你画出漂亮的架构图,而是让你的代码和业务模型保持同构——业务怎么运转,代码就怎么组织。这是我在实践中最深刻的体会。
这篇文章我不打算给你看标准概念定义,重点是说清楚我自己踩过的坑、总结出的方法,以及从需求到代码的完整映射过程。适合谁看?如果你正被“Service层越来越臃肿”“领域模型沦陷成数据载体”“微服务边界怎么划”这些问题困扰,这篇文章的内容基本都能覆盖到。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 实体、值对象、聚合:这三个概念决定你的业务模型是否干净
DDD 的战术设计里,实体(Entity)、值对象(Value Object)、聚合(Aggregate)是绕不开的底子。很多团队也看了几本书,但一落地就懵:到底什么该建模成实体,什么该建模成值对象?聚合边界画到哪里合适?这些问题不解决,后面所有设计都是空中楼阁。
2.1 实体与值对象的划分,看的是“身份”和“生命周期”
实体最重要特征是持续的身份标识。一个用户,无论他的姓名、手机号怎么改,这个用户始终是同一个用户。我们要始终能够定位到他的记录,并追踪他的状态变化。所以建模时,用户、订单、合同这类有明确身份、需要被单独追踪的对象,适合作为实体。
值对象相反,它描述的是某种属性或度量,本身没有独立身份。一个地址由省份、城市、街道、邮编组成,两个地址内容完全一样,你就说它们相等,而不需要区分“第一个地址”和“第二个地址”。钱加币种、时间段、坐标点,都是典型的值对象。
我见过很多团队,值对象用得很少。原因不难猜:持久化的时候,值对象通常要嵌入到某个表里,或者拆列存储,有些人觉得麻烦,干脆全部当成实体表,一个值对象一张表,还配一个主键ID。结果就是关联关系爆炸、查询复杂度直线上升。
注意:值对象虽然看似“只是数据”,但在领域模型里它承载了完整的业务约束。例如金额对象应当有币种和数值,而不是两个分开的字段,否则就给了业务“把美元当人民币加”的机会。
我的建议是:在建模初期把所有概念都列出来,先按“有没有身份”做一次粗筛,把明显是属性的东西归为值对象。等到做聚合划分时,值对象的归属自然就会清晰。
2.2 聚合边界怎么画,可以直接对应到业务不变量
聚合是DDD里最容易被误解的概念。网上流行一句话:“聚合是用来保证一致性的边界。”这句话对,但不够具体。我自己的理解是这样:聚合是一组必须同时满足业务不变量的对象集合,对外只能通过聚合根访问。你画聚合边界时,不是看“哪些对象经常一起查”,而是看“哪些对象必须保持一致性”。
举个例子,一个订单聚合,包含订单头、订单明细、收件地址、支付记录。一条订单明细的数量被修改之后,订单总额必须同步变化,这就是不变量。如果你允许客户端直接修改明细对象,它完全可能跳过订单总额的重新计算。这就要把明细放进聚合内部,明细的访问必须经过订单。
但是,聚合不是越大越好。很多人第一次设计聚合,恨不得把整个系统都放进一个聚合里,最大的好处是“一致性绝对没问题”——问题是并发效率太低。一个Order聚合如果小到只有订单头,大量操作不需要锁其他数据;如果大到把整个客户订单历史全部包进来,任何对订单的修改都要锁住一大片数据,系统很快就卡死。
我常用的画法分三步:
- 梳理业务场景中的业务不变式,比如“订单总金额=所有明细金额总和”“一个账户不能同时被两个销售认领”。
- 将需要同时满足这些不变式的对象聚到同一个聚合内,设一个聚合根作为唯一入口。
- 聚合之间只通过聚合根ID引用,不直接持有对方引用。
