一篇文章搞懂DDD

zxbandzby
11
2025-04-28

前言

在阅读本文之前,你可能会认为DDD是整天做PPT的架构师们才应该去关注的东西;或者会认为DDD是比较顶层的东西,跟我写代码的程序员关系不大;你可能还会认为DDD是一种被咨询师们吹得天花乱坠但是却无法落地的概念炒作而已。在日常实践中,我接触过不懂装懂的言必称DDD者,也见识过声称DDD与编码毫无关系的虚无主义者,当然也接触过真正能将DDD落地者。在本系列文章中,我将向你证明,DDD正是软件工程师的工具,可以用于编写更好的代码,设计更好的架构,进而做出更好的软件。当然,我也会针对DDD中被夸大其词的那部分进行澄清,甚至批评。

DDD是什么呢?是架构思想?是方法论?还是软件之道?从某种层度上说这些都对,但是对于程序员或者架构师来讲,最接地气的回答应该是:DDD是面向对象进阶。对于写了几年代码希望在职业生涯中更上一层楼的程序员来说,学习DDD是再适合不过的了。为了能让DDD新手们更快地上手,我们还是以代码为入口展开讲解,首先让我们来看看DDD项目代码和非DDD项目代码有何不同。

一、实现业务逻辑的三种方式

第一种:事务脚本 #

对于上述需求,从纯技术上讲,我们希望达到的最终目的不过是在数据库中的member表中更新2个字段而已,一个是手机号(mobile_number)字段,另一个是手机号已识别(mobile_identified)字段。为了实现这个需求,最简单直接的方式难道不是直接写个SQL语句直接更新数据库表么?的确如此,这个简单的方式其实有个专门的名词 —— 事务脚本(Transactional Script),也即通过类似编写脚本的方式完成一个业务用例,一个业务用例对应一次事务。

    @Transactional//事务边界
    public void updateMyMobile(String mobileNumber, String memberId) {
        
        //采用事务脚本的方式,直接通过SQL语句实现业务逻辑
        String sql = "update member set mobile_number = ? , mobile_identified = 1 where id = ?;";
        jdbcTemplate.update(sql, mobileNumber,memberId);
    }

这种直接通过技术手段实现业务功能的方式没有任何软件建模可言,它将原本可以分开的业务性代码和技术性代码揉杂在一起,既不利于业务的重用,也不利于系统的长期演进,因此通常被认为只适合一些小型软件项目。

第二种:贫血对象 #

看到第一种实现方式你可能会想:这都什么年代了,还在像写C语言那样编写代码,不使用点儿面向对象技术连一个刚入职的毕业生估计都不好意思。那好吧,让我们创建一个Member对象。

    @Transactional
    public void updateMyMobile(String mobileNumber) {
        String memberId = CurrentUserContext.getCurrentMemberId();
        Member member = memberRepository.findMemberById(memberId);

        //先后调用Member对象中的2个setter方法实现业务逻辑
        member.setMobileNumber(mobileNumber);
        member.setMobileIdentified(true);

        memberRepository.updateMember(member);
    }

在上例中,首先我们将数据库访问相关的逻辑全部封装在memberRepository中,从而解决了“技术性代码和业务性代码揉杂”的问题。其次,创建了Member对象,其中包含两个setter方法,setMobileNumber()用于设置手机号码,setMobileIdentified()用于标记标记手机号已识别,这应该面向对象了吧?!但是,问题恰恰出在了这两个setter方法上:此时的Member对象只是一个数据容器而已,而非真正的对象。这种只有数据没有行为的对象被称为贫血对象

问题还不止于此,本例中先后调用的两个setter方法事实上违背了软件开发的一个根本性原则 —— 内聚性。简单来讲,“设置手机号”和“标记手机号已识别”这两个步骤在业务上是紧密联系在一起的,应该由Member中的单个方法完成,而不应该由2个独立的方法完成。为了解释这里体现的内聚性,让我们再来看个需求:除了成员自己可以修改手机号外,管理员也可以为任何成员设置手机号,为此我们再实现一个updateMemberMobile()方法。

    @Transactional
    public void updateMemberMobile(String mobileNumber,String memberId) {
        Member member = memberRepository.findMemberById(memberId);

        //与updateMyMobile()相同,需要先后调用Member对象中的2个setter方法实现业务逻辑
        member.setMobileNumber(mobileNumber);
        member.setMobileIdentified(true);

        memberRepository.updateMember(member);
    }

这里,updateMemberMobile()方法也需要显式地先后调用Member的setMobileNumber()setMobileIdentified()方法,也就是说编码者需要记住必须同时调用2个方法,否则程序就会出Bug。这种方式存在以下问题:

  1. 业务逻辑的泄漏:对于维持“设置手机号”和“标记手机号已识别”同时发生的职责来说,本应该由Member对象自身完成的,结果泄漏到了Member对象的外部;

  2. 增加调用者的负担:对于作为Member客户方的updateMyMobile()updateMemberMobile()方法来讲,他们本应该将Member当做一个黑盒,但在本例中却需要了解Member的内部细节(先后调用setMobileNumber()setMobileIdentified()方法),这无疑是调用者的负担。

  3. 难于维护:如果以后业务需求有变,那么需要同时修改updateMyMobile()updateMemberMobile()2个方法,这可能不是能够轻易做到的,特别是在人员流动频繁的软件项目中。

与事务脚本相似,贫血对象除了可用于一些小的软件项目外,通常被认为是一种反模式,应该避免使用。

第三种:领域对象 #

领域对象是一个与贫血对象相对立的概念,它表示直接体现业务逻辑的一类对象,这类对象不仅包含业务数据,还包含业务行为。领域对象希望达到的理想状态是:所有业务逻辑均由领域对象完成,外界将领域对象当做一个黑盒向其发送指令(调用方法)即可。在本例中,设置手机号的同时需要标记“手机号已识别”均属业务逻辑,应该全部放到领域对象中完成。

    @Transactional
    public void updateMyMobile(String mobileNumber) {
        String memberId = CurrentUserContext.getCurrentMemberId();
        Member member = memberRepository.findMemberById(memberId);

        //只需调用Member种的updateMobile()方法即可
        member.updateMobile(mobileNumber);

        memberRepository.updateMember(member);
    }

这里,updateMyMobile()方法只需调用Member中的updateMobile()方法即可,然后由Member自行处理具体的业务逻辑:

    //由Member对象自身处理同时更新mobileNumber和mobileIdentified字段
    public void updateMobile(String mobileNumber) {
        this.mobileNumber = mobileNumber;
        this.mobileIdentified = true;
    }

在本例中,除了将数据和行为同时放到Member对象之外,我们还会考虑如何设计和安排这些行为才最得当,比如将高内聚的mobileNumbermobileIdentified放到同一个方法中,此时的Member便是一个行为饱满的领域对象,并开始变得有些“领域驱动”的意味了,所谓的"DDD是面向对象进阶"这个说法也正体现于此。事实上,在DDD中Member对象也被称为聚合根,而“更新mobileNumber的同时需要一并更新mobileIdentified”则被称为聚合根的不变条件,我们将在后续文章中对此做详细讲解。

看到这里,你可能会问:领域对象的实现方式不就是将贫血对象中的业务逻辑实现挪了个位置吗?的确,但是这一挪,便挪出了编程的讲究与思考,挪出了模型的设计与原则,挪出了软件的发展与进步。就像云计算早年被认为不过是将本地的计算资源搬移到网络上一样,我们将很多看似并不具有颠覆性的微小创新合在一起,便可将理想编织成一个个能够为行业为社会带来实际进步的美好现实。

你可能还会说,领域对象这种实现方式我平时就是这么做的呀!?没错,我们平时编程的很多做法其实已经包含了DDD中的某些思想或实践,因为DDD并不是什么全新的东西要把你所写的代码全部推翻重来,而是很多具有逻辑归因性的东西其实大家都能总结出来,只是那些大牛总结得比我们更早,更系统,更全面而已。

对于以上三种实现方式,我们在前面提到事务脚本和贫血对象只适合一些小型的软件项目,那么问题来了,到底多小才算小呢?这个问题没有标准答案,就像你问微服务多小算小一样,It depends!然而,但凡是企业中立过项的软件项目,都不会是实现一个Code Kata这么简单,都不能被定义为“小型项目”。因此,对于几乎所有企业级软件系统来说,使用领域对象进而DDD都不会是个错误的选择。

DDD书籍推荐 #

我基本上参阅完了市面上所有的DDD书籍(截止到2023年3月份),在这些书籍中,真正值得推崇的有以下4本书:

  • 《领域驱动设计:软件核心复杂性应对之道》(蓝皮书,从左往右第一本,首版时间2003年):DDD的开山之作,对于初学者来说阅读起来有些晦涩,不建议初学者直接阅读该书

  • 《实现领域驱动设计》(红皮书,从左往右第二本,首版时间2013年):这本是讲DDD落地的经典书籍,其中包含大量代码示例,很多人都是通过这本书才真正进入DDD的世界

  • 《领域驱动设计模式、原理与实践》(从左往右第三本,首版时间2015年):这也是一本能够帮你系统的完成DDD落地的书籍

  • 《解构领域驱动设计》(首版时间2021年):国内第一本关于DDD的专著,作者张逸在DDD社区具有比较大的影响力

对于英文书籍,建议大家如果有条件的话,一定阅读英文原版,因为那才是第一手资料,中文翻译始终存在漏译错译等无法表达原书本意的情况。

总结 #

本文从事务脚本、贫血对象和领域对象三种实现业务逻辑的方式为入口,一步一步地引入DDD的概念,希望能让DDD新手们平滑地开启DDD的学习之路。

二、DDD概念大白话

DDD分为战略设计战术设计,战略设计是一种宏观的顶层设计,而战术设计则更偏向于代码落地实践。

战略设计,其实就是分包,把TMD的不同的业务领域的东西划分到不同的文件夹里面。分类方式就是按照领域来分,按照限界上下文(技术边界)来分,领域里面按照子域来分。

1、战略设计

在领域驱动设计(DDD)中,​领域、子域和限界上下文的范围层级关系并非固定的“领域 > 限界上下文 > 子域”​,而是需要从不同维度理解:


1. ​领域(Domain)是最大的业务范围​

  • 定义​:领域是整个组织的业务问题空间,例如电商系统中的全部业务流程(商品、订单、支付、物流等)

  • 范围特点​:领域是战略层的全局视角,包含所有子域和限界上下文,是业务目标的完整集合


2. ​子域(Subdomain)是领域的逻辑细分​

  • 定义​:子域是领域内按业务功能或重要性划分的逻辑单元,例如电商领域可拆分为核心子域(订单处理)、支撑子域(库存管理)、通用子域(用户认证)

  • 范围特点​:

    • 子域属于领域的一部分​:领域是“整体”,子域是“组成部分”

    • 子域与限界上下文无固定大小关系​:一个子域可能对应多个限界上下文(如订单子域拆分为订单创建、支付处理等上下文),也可能一个限界上下文包含多个子域(如小型系统中订单上下文同时处理订单和库存逻辑)


3. ​限界上下文(Bounded Context)是子域的实现边界​

  • 定义​:限界上下文是子域在技术实现中的语义边界,例如订单子域可能对应“订单服务”限界上下文,内部定义唯一的订单模型和规则

  • 范围特点​:

    • 限界上下文是子域的物理体现​:子域是业务逻辑的划分,限界上下文是技术实现边界,两者属于不同维度(问题空间 vs 解决方案空间)

    • 限界上下文大小可变​:根据团队分工或技术需求,一个限界上下文可能覆盖一个子域(如物流子域对应独立物流服务),也可能仅覆盖子域的一部分(如支付子域拆分为支付网关和结算服务)


总结:三者关系的正确理解

  1. 领域包含所有子域和限界上下文​:领域是最大的业务范围,子域和限界上下文均属于领域内

  2. 子域与限界上下文无层级大小​:两者是不同维度的划分,子域是业务逻辑分解(战略层),限界上下文是技术实现边界(战术层)

  3. 典型场景​:

    • 大型系统​:领域 → 多个子域 → 每个子域对应多个限界上下文(如电商领域拆分为订单、支付等子域,订单子域再拆分为订单服务、库存服务等限界上下文)

    • 简单系统​:领域 → 一个限界上下文覆盖多个子域(如小型电商系统将订单、库存逻辑合并到一个限界上下文中)


示例:电商系统的范围层级

  • 领域​:电商交易(包含所有业务)

  • 子域​:核心域(订单处理)、支撑域(库存管理)、通用域(用户认证)

  • 限界上下文​:

    • 订单服务(实现订单子域)

    • 支付服务(实现支付子域)

核心结论​:领域是最大范围,子域和限界上下文是不同维度的细分,无固定大小关系,需根据业务复杂度灵活设计

2、战术设计

1. ​聚合根(Aggregate Root)​​

  • 定义​:聚合根是聚合的核心实体,负责管理聚合内部对象的一致性,是外部访问聚合的唯一入口

    。例如,在电商系统中,“订单”是聚合根,包含订单项、支付信息等子对象。

  • 核心作用​:

    • 维护业务规则​:确保聚合内的实体和值对象符合业务约束(如订单总额不可为负)

    • 生命周期管理​:控制聚合内对象的创建、修改和删除(如删除订单时需连带删除订单项)

    • 事务边界​:聚合内的修改需保证事务一致性,跨聚合操作则通过事件最终一致性实现

  • 设计原则​:

    • 单一职责​:聚合根应聚焦核心业务逻辑,避免过大(如“订单聚合根”仅处理订单状态流转)

    • 引用外部聚合根​:跨聚合协作时,通过ID引用而非直接对象引用(如订单聚合引用客户ID而非客户对象)

2. ​领域服务(Domain Service)​​

  • 定义​:处理跨聚合或复杂业务逻辑的无状态服务,用于实现无法归属单一实体的操作

    。例如,订单折扣计算需结合用户等级、商品类型等。

  • 适用场景​:

    • 跨实体协作​:如订单支付需协调订单、库存、支付等多个实体

    • 复杂规则封装​:如风险评估、运费计算等需独立逻辑的场景

    • 外部系统交互​:调用第三方服务(如物流接口)时,领域服务负责数据转换和协议适配

  • 设计原则​:

    • 无状态性​:领域服务不存储业务状态,仅依赖输入参数执行逻辑

    • 避免基础设施依赖​:不直接操作数据库或DAO,由应用服务协调


3. ​实体(Entity)​​

  • 定义​:具有唯一标识符的业务对象,其状态可变但标识不变

    。例如,用户实体通过用户ID区分,即使姓名或地址变更仍视为同一实体。

  • 特点​:

    • 标识驱动​:通过唯一ID判断相等性(如订单ID相同则视为同一订单)

    • 生命周期管理​:实体状态变更需符合业务规则(如用户状态从“激活”变为“禁用”)

    • 业务行为封装​:实体可包含与自身相关的逻辑(如用户实体实现密码加密方法)


4. ​值对象(Value Object)​​

  • 定义​:描述不可变特征的对象,无唯一标识,通过属性值判断相等性

    。例如,地址(省、市、街道)或金额(数值+货币单位)。

  • 特点​:

    • 不可变性​:一旦创建,属性不可修改(如订单金额创建后不可调整)

    • 轻量级​:仅承载数据,不涉及复杂行为(如地址仅包含地理信息)

    • 组合性​:可嵌套其他值对象(如订单项包含商品ID、数量、单价)

  • 典型应用​:用于参数传递、实体属性补充或业务规则验证(如校验邮箱格式)


5. ​领域事件(Domain Event)​​

  • 定义​:记录领域内重要状态变化的事件(如“订单已支付”),用于驱动后续业务操作

  • 核心作用​:

    • 解耦系统​:通过事件总线或MQ通知订阅方,避免直接调用(如订单支付后触发库存扣减)

    • 业务流程串联​:跨聚合或微服务间协作(如订单完成后触发物流派单)

    • 事件溯源​:记录事件日志,支持状态回溯(如通过事件序列重建订单状态)

  • 设计要素​:

    • 事件结构​:包含事件类型、发生时间、相关聚合根ID及业务数据

    • 发布与订阅​:通过事件总线或消息队列实现异步处理(如使用Kafka或RabbitMQ)


6. ​工厂(Factory)​​

  • 定义​:封装复杂对象创建逻辑的模式,用于保证聚合或实体的完整性

  • 适用场景​:

    • 聚合创建​:确保聚合根与子对象同时初始化(如订单聚合需包含订单项和支付信息)

    • 依赖注入​:处理对象间的复杂依赖关系(如创建用户时需初始化默认权限)

    • 不可变对象构建​:值对象(如地址)的创建需校验属性合法性

  • 实现方式​:

    • 静态工厂方法​:通过静态方法返回对象实例(如OrderFactory.create()

    • 独立工厂类​:复杂场景下单独定义工厂类(如聚合根包含多个子实体时)


概念间的关系

  1. 聚合根与实体/值对象​:聚合根管理聚合内的实体和值对象,外部只能通过聚合根访问内部对象

  2. 领域服务与聚合根​:领域服务协调多个聚合根完成业务逻辑(如订单服务调用订单聚合根和库存聚合根)

  3. 领域事件与聚合根​:聚合根触发领域事件(如订单聚合根触发OrderCreatedEvent),事件订阅方执行后续操作

  4. 工厂与聚合根​:工厂负责创建聚合根及其子对象,确保初始状态符合业务规则

具体分析和案例:https://docs.mryqr.com/ddd-introduction/

动物装饰