被误解的DDD

29 阅读47分钟

最近这些年,在讨论到软件工程时,总是不可避免地谈到领域驱动设计 (DDD,Domain-Driven Design) 。DDD早在20多年前就由Eric Evans所提出,但在近些年,尤其是在国内,一下子变得火热,成为了软件工程的潮流。20多年前提出的东西在今天变得爆火,我觉着可能与以下几个原因密切相关。

首先,软件已经深度渗透到了各行各业,这就不可避免地会出现两个问题:一是程序员总是需要面对不同的全新业务领域,并且需要真正理解他们的业务;二是现实的复杂性给软件的架构带来了更多的挑战。当你要为一个医疗系统编写代码时,你必须理解医疗流程、诊断规则、药品管理这些专业知识。当你要为金融系统开发功能时,你得搞懂交易规则、风控逻辑、清算流程。软件不再只是简单的增删改查,而是要真正映射现实世界的复杂业务规则。

其次,云计算和Cloud Native的出现,让软件的功能需求和非功能需求彻底分离。非功能需求,比如性能、安全、扩展性、可用性等由基础设施平台来提供,这让程序员可以把更多的时间精力放在业务上,对业务的理解和抽象,就成为了程序员工作的重心。而到了AI生成代码的时代,让这一转向更加彻底:程序员的主要工作,由原先的编写代码,变成了与业务人员进行沟通,梳理和抽象业务逻辑,分解任务,确保AI对业务理解的上下文是聚焦准确的,以及其所产出的结果,无论是功能还是非功能,都是可以满足业务要求的。而这些全都是DDD的重点所在。

所以,在近些年,我们看到了非常多的书籍或者文章在讨论DDD。但我们又会发现,真正能把DDD讲明白的却又不多。要么过于抽象,偏理论化,要么会发现它倡导的很多架构模式,比如什么聚合、六边形架构、CQRS等,都十分的难以在现实当中落地。甚至很多文章对它的讲述是建立在一些重大的误解之上。

出现这一现象其实并不奇怪。因为DDD是一个非常庞大的体系,涉及到战略和战术两个层面,讲述的侧重点不同,就容易出现"盲人摸象"的现象。另外,DDD与我们所熟悉的面向对象和设计模式也完全不是一回事,但是我们却又总是喜欢套用相同的思路来理解和实践DDD。这些都会导致我们对DDD的实质产生极大的误解,继而影响我们的工程实践。

所以,我便写了这篇文章,来试着澄清一些对于DDD的常见误解,方便大家正确的理解和实践DDD。

一、忽视了层次

首先必须得明白DDD是分为战略战术两个层次的。对层次的忽视,是对DDD的最大误解。很多人一提到DDD,脑子里立刻浮现的就是Entity、Value Object、Aggregate这些概念,然后开始纠结怎么设计类的层次结构,怎么划分模块。但这些其实都只是战术层面的东西,而战略层面才是DDD的根基。如果战略层面没做好,后面的战术再精妙也是空中楼阁。

战略层次

战略层次与技术没有关系,它强调的是对领域知识的整理和抽象,这是解决复杂性的前提。很多程序员一听到"战略"这个词就觉得虚,觉得这是管理层该考虑的事情,跟自己写代码没什么关系。但恰恰相反,战略层次决定了你的代码最终会是什么样子。

首先要做的是分清主域和子域。一个软件系统往往会涉及多个业务领域,但这些领域的重要性是不同的。有些是核心域,是你的业务竞争力所在,比如电商系统中的推荐算法、定价策略;有些是通用域,比如用户认证、权限管理,这些功能虽然必要,但并不是你的差异化优势,市面上有成熟的解决方案;还有些是支撑域,比如日志记录、数据统计,它们为核心业务提供支持,但本身不直接产生业务价值。

明确了领域类型之后,你就知道该在哪里投入更多的精力。核心域需要深入理解,精心设计,因为这是你的护城河。通用域可以考虑直接采用第三方服务或开源方案,没必要重复造轮子。支撑域则要在满足需求的前提下尽量简单,避免过度设计。

更重要的是要明确子域之间的边界。现实世界的业务往往是交织在一起的,但在软件系统中,我们必须把它们清晰地分开。这个分开不是物理上的分离,而是概念上的隔离。比如在一个电商系统中,订单、库存、物流、支付都是不同的子域,它们之间有交互,但每个子域都应该有自己独立的概念模型和业务规则。

接下来是领域知识的整理,这是战略层次中最关键也最容易被忽视的部分。很多项目的失败,根源就在于对业务理解不到位。而要真正理解业务,首先得找到真正的领域专家。这里说的领域专家不是那些只会提需求的产品经理,而是真正懂业务规则、能说清楚为什么要这么做的人。可能是业务部门的老员工,可能是行业专家,也可能是你的客户。

找到领域专家之后,就要与他们进行反复沟通。注意,这里说的是反复沟通,不是开一两次会就完事了。业务知识往往是隐性的,很多规则和约束在业务人员看来是理所当然的,他们不会主动说出来,只有在具体场景中才会暴露出来。所以你需要通过不断地提问、确认、验证来挖掘这些隐性知识。

而且,口头上达成的一致并非真正的一致。你以为你理解了业务人员的意思,业务人员也以为你理解了,但实际上双方的理解可能完全不同。所以必须基于原型来达成一致。这个原型可以是一个简单的界面草图,可以是一段伪代码,也可以是一个流程图,总之要有一个具象的东西让双方能够指着它说"对,就是这样"或者"不对,应该是那样"。

沟通的结果应该是什么呢?首先是概念一致。同一个词在不同人嘴里可能意味着完全不同的东西。比如"订单",在销售部门可能指的是客户的购买意向,在财务部门可能指的是已经付款的交易,在仓储部门可能指的是待发货的清单。你必须明确在你的系统中,"订单"到底指的是什么,它包含哪些信息,处于什么状态。

其次是场景和流程的认知一致。业务不是孤立的操作,而是一系列有先后顺序、有条件约束的活动。你需要和业务人员一起梳理清楚,在什么情况下会发生什么,每一步的前置条件是什么,可能的分支有哪些,异常情况怎么处理。

最终,这些沟通要沉淀出一套领域语言。这套语言不是技术术语,也不是业务黑话,而是一套所有参与项目的人都能理解、都必须使用的统一语言。当程序员、产品经理、业务人员、测试人员在讨论问题时,大家用的是同一套词汇,对这些词汇的理解也是一致的,没有歧义。这套语言会体现在你的代码中,体现在你的文档中,体现在你的会议讨论中。

而且这套领域语言不是一成不变的,业务在演化,语言也要跟着演化。所以你需要使用工具来动态地维护这套语言。可以是一个简单的术语表,也可以是一个知识库,关键是要让所有人都能方便地查阅和更新。

在整理领域知识的过程中,Event Storming是一个非常有效的方法。它的核心思想是让所有相关人员聚在一起,用便利贴在墙上贴出业务中发生的各种事件,然后围绕这些事件讨论触发条件、参与角色、产生的结果。这个过程能够快速地暴露出业务流程中的关键点和模糊点,让大家对业务有一个全局的认识。

有了对业务的深入理解之后,接下来就是通过划分上下文边界来管理复杂性。这是DDD战略层次的核心。复杂的系统之所以难以维护,很大程度上是因为所有东西都混在一起,改一个地方可能影响到其他十个地方。而上下文边界就是要把这种全局的复杂性分解成局部的复杂性。

划分上下文边界的原则是大系统小做。不要试图用一个统一的模型来描述整个系统,而是把系统分解成多个相对独立的上下文,每个上下文内部有自己的模型,上下文之间通过明确的接口进行交互。这样,当你需要理解或修改某个功能时,你只需要关注相关的上下文,而不用把整个系统都装进脑子里。

这种划分追求的是高内聚、低耦合。一个上下文内部的概念和逻辑应该是紧密相关的,而不同上下文之间的依赖应该尽可能少。但这说起来容易做起来难,因为现实世界的业务本身就是相互关联的。所以边界划分是一门平衡的艺术。

你需要综合考虑多种因素。首先是业务逻辑本身,哪些概念和规则是天然聚在一起的,哪些是相对独立的。其次是组织架构,如果两个上下文分别由两个团队负责,那么它们之间的边界就应该更加清晰,接口更加稳定,否则会导致大量的跨团队协调成本。还要考虑未来的变更,哪些部分可能经常变化,哪些部分相对稳定,把容易变化的部分隔离开来,可以减少变更的影响范围。

上下文的粒度也是个问题。粒度过粗,一个上下文包含了太多的概念和逻辑,内部的复杂性还是很高,没有起到简化的作用。粒度过细,上下文之间的交互就会变得频繁,集成的复杂性会上升,而且会导致概念的碎片化,同一个业务流程可能要跨越多个上下文,理解起来反而更困难。

所以在实践中,你需要不断地调整边界,这是一个迭代的过程。一开始可能划分得不够好,随着对业务理解的深入,你会发现有些边界划得不合理,需要重新调整。这是正常的,不要期望一开始就能划分得完美。

当不同的上下文需要交互时,使用防腐层来隔离变化是一个重要的技巧。防腐层就像是一个翻译器,它把外部上下文的概念和接口翻译成本上下文能够理解的形式。这样,即使外部上下文发生了变化,你只需要修改防腐层,而不用改动核心的业务逻辑。这种隔离不仅保护了你的代码,也让不同上下文之间的依赖关系更加清晰。

战术层次

战术层次是Eric Evans针对战略层次所提出的一种工程实践,其关键点在于如何在软件架构层面来治理领域上下文,包括上下文的粒度和边界。我把战术层次分成了组件和架构模式两部分。值得注意的是,你不必完全采用Eric Evans所提出的这些实践,你也完全可以提出你自己的实践来实现上述的战略。

先说组件。

Transaction Script 适合单一事务的简单业务场景,就是一个函数从头到尾完成一个业务操作,逻辑简单、步骤固定的功能。这种方式直观易懂,代码量少,对于简单场景来说是最合适的选择。

Active Record适合简单的CRUD场景,它把数据和对数据的基本操作封装在一起,每个Active Record对应数据库中的一条记录。这种模式在很多Web框架中都有体现,比如Rails的Active Record。它的优点是开发效率高,对于那些主要是数据展示和简单编辑的功能来说非常合适。

Value Object是一个很重要但经常被忽视的概念。它表示的是那些没有唯一标识、只关心值本身的对象,比如金额、日期范围、地址。Value Object是不可变的,一旦创建就不能修改,如果需要不同的值,就创建一个新的对象。而且Value Object应该自带验证规则,比如金额不能为负,邮箱地址必须符合格式。这样可以确保系统中流转的都是合法的值,避免了在各处重复验证的问题。

Entity是有唯一标识的对象,即使它的属性发生了变化,它还是同一个Entity。比如一个用户,即使改了名字、换了邮箱,只要ID不变,它就还是那个用户。Entity的状态是可变的,但在DDD中,Entity本身通常不包含复杂的业务逻辑,它更多的是作为状态的载体。

Aggregate是DDD战术层次中最核心也最难理解的概念。它本质上是一个维护领域一致性边界的状态机,封装了复杂的业务逻辑,负责修改Entity的状态。Aggregate有严格的边界,外部不能直接访问Aggregate内部的Entity,所有的操作都必须通过Aggregate的方法来进行。这样可以确保业务规则得到执行,状态转换是合法的。

Aggregate是层次化的,一个Aggregate可以包含多个Entity和Value Object,它们形成一个整体。而且Aggregate之间也可以有层次关系,一个Aggregate可以通过创建子Aggregate来调用子域的业务逻辑,而不是直接操作子域的Entity。这种层次化的设计让复杂的业务逻辑得以分解,每个Aggregate只需要关注自己职责范围内的事情。

Aggregate在面临复杂业务场景时非常重要,尤其是那些涉及复杂状态转换的业务。比如订单的生命周期,从创建、支付、发货、收货到完成,每一步都有严格的前置条件和业务规则,这种场景就非常适合用Aggregate来建模。

Domain Events表示领域中发生了什么事情,以及与之相关的数据。它通常采用过去式命名,比如OrderCreated、PaymentCompleted、ItemShipped。Domain Events不仅记录了事实,还可以用来触发后续的业务流程,或者用于不同上下文之间的通信。

Domain Service是无状态的,它封装并协调多个Aggregate,完成最终的业务逻辑。有些业务操作不属于任何一个Aggregate,或者需要多个Aggregate协作才能完成,这时候就需要Domain Service。比如转账操作,涉及到两个账户,这个逻辑就应该放在Domain Service中,而不是放在某个账户的Aggregate里。

再说架构模式。

Event Sourcing是一种不同于传统数据存储的方式,它不是只保存当前状态,而是记录所有导致状态变化的事件。这样你可以重放这些事件来重建任意时刻的状态,也可以分析历史数据来获得业务洞察。Event Sourcing的核心理念是记录历史事实,而非只是当前状态。业务并非一成不变,今天的业务规则可能明天就变了,但历史事实是不会变的,它为业务的演进提供了最坚实的基础。

CQRS是Command Query Responsibility Segregation的缩写,意思是命令查询职责分离。它把写操作和读操作分开,用不同的模型来处理。CQRS通常基于Event Sourcing,写操作产生事件,读操作从事件中构建出适合查询的模型。

CQRS解决的核心问题有两个。一是读取模式的多样性。不同的查询场景对数据的要求是不同的,有的需要低延迟的随机读,有的需要范围查询,有的需要全文搜索,有的需要聚合分析,还有的需要向量搜索。如果用同一个模型来满足所有这些需求,要么性能很差,要么设计非常复杂。而CQRS允许你针对不同的查询需求构建不同的读模型,每个读模型都可以选择最合适的存储和索引方式。

二是读写负载的不均衡。很多系统的读操作远多于写操作,如果读写用同一个数据库,写操作可能会成为瓶颈,或者读操作会影响写操作的性能。CQRS把读写分开之后,可以分别进行扩展,读模型可以部署多个副本来分担查询压力,写模型则可以专注于保证数据的一致性和完整性。

传统的分层架构大家都很熟悉,分为交互层、业务逻辑层、数据访问层。这种架构简单直观,对于大多数应用来说都够用。但它有个问题,就是业务逻辑层往往会依赖数据访问层,这导致业务逻辑和数据存储方式耦合在一起,不利于测试和演化。

六边形架构,也叫端口适配器架构,试图解决这个问题。它的核心思想是通过Port和Adapter来抽象输入和输出,让核心业务逻辑不依赖任何外部系统。Port定义了业务逻辑需要的接口,Adapter则实现这些接口,连接到具体的外部系统,比如数据库、消息队列、HTTP API等。这样,业务逻辑只依赖Port,而不依赖具体的实现,你可以很容易地替换Adapter,比如把MySQL换成PostgreSQL,或者把REST API换成gRPC,而不用修改业务逻辑。

六边形架构确保了核心业务逻辑的纯粹性,它不包含任何技术细节,只表达业务规则。这不仅让代码更容易理解和维护,也让测试变得简单,你可以用Mock的Adapter来测试业务逻辑,而不需要真正的数据库或外部服务。

最容易忽视的是战略层次

目前大多数关于DDD的讲述都只是停留在战术层面,解释其中的各种技术细节,甚至罗列大量的代码,而对于战略层面,提及的却很少,但战略层面其实才是DDD的真正核心,战术层面却相对并不那么重要。我觉着这一现象的主要原因,是由于程序员在业务方面的话语权缺失所导致的。

组织架构导致的话语权缺失是一个普遍现象。现代企业讲究精细分工,产品部门负责需求,开发部门负责实现,测试部门负责质量,运维部门负责上线。这种分工看似合理,实则造成了严重的筒仓效应。每个部门只关心自己的一亩三分地,部门之间的协作变成了扔需求文档、提bug单这种机械的交接。

在这种组织架构下,程序员被狭隘地定义为了需求实现者。产品经理写好需求文档,程序员照着实现就行了,至于为什么要这么做,背后的业务逻辑是什么,这些都不是程序员该关心的。但这种定位完全忽视了程序员真正的能力,那就是抽象和创造能力。程序员不应该只是一个代码搬运工,而应该是业务问题的解决者。

更糟糕的是,很多程序员自己也主动放弃了在业务方面的话语权。他们认为业务和技术毫无关系,业务是产品经理的事,自己只要把代码写好就行了。多一事不如少一事,反正需求是产品定的,出了问题也不是自己的责任。所以你会看到一个很矛盾的现象,他们一方面鄙视业务,崇尚所谓的"技术",觉得研究算法、学习新框架才是正道,做业务开发是没有技术含量的;另一方面,又不断抱怨产品提的需求不靠谱,需求总是不断变更,自己辛辛苦苦写的代码没几天就要推倒重来。

但他们没有意识到,需求之所以不靠谱,很大程度上是因为程序员没有参与到需求的讨论中。产品经理不是万能的,他们对业务的理解也可能是片面的,而且他们往往不了解技术实现的复杂性和约束。如果程序员能够在需求阶段就介入,和产品经理、业务人员一起讨论,很多问题是可以提前发现和解决的。

需求之所以频繁变更,也不完全是产品经理的问题。业务本身就是在不断演化的,市场在变,用户需求在变,竞争对手在变,业务规则也必然要跟着变。关键不在于避免变更,而在于如何应对变更。如果你对业务有深入的理解,知道哪些是稳定的核心,哪些是容易变化的边缘,你就可以设计出更灵活的架构,让变更的影响范围最小化。而这正是DDD战略层次要解决的问题。

所以,程序员不应该把自己局限在代码层面,而应该主动去理解业务,参与到业务的讨论中。这不是额外的负担,而是提升自己能力、让工作更有价值的途径。当你真正理解了业务,你会发现很多技术问题其实是业务问题,很多架构选择其实是业务选择。而DDD的战略层次,就是为程序员提供了一套方法论,让他们能够系统地去理解和抽象业务。

二、DDD不是面向对象

我们经常使用面向对象的思维去理解DDD,将面向对象的概念套用在DDD的概念上,比如将Aggregate理解为层次化的对象结构,将Active Record理解为充血模型,将Entity理解为贫血模型等等。但其实,DDD与面向对象毫无关系,也与其它任何程序设计范式都毫无关系。

我们之所以容易套用OO的思想去理解DDD,首先是因为OO已经深入人心。从Java到C#,从Python到JavaScript,主流的编程语言都支持面向对象,我们在学校里学的也是面向对象,工作中用的设计模式也是基于面向对象的。所以当我们看到Entity、Aggregate这些概念时,第一反应就是把它们当成类,然后开始考虑继承、多态、封装这些OO的特性。

其次,DDD的教程都是采用Java或C#这类OO语言作为示例,并且没有去强调DDD与OO的区别。Eric Evans的书里用的是Java,Vaughn Vernon的书里用的也是Java,国内的很多文章和书籍也都是用Java或C#来讲解DDD。这就给人一种错觉,好像DDD就是要用OO语言来实现,好像DDD就是OO的一种高级应用。

但事实上,我们可以使用任何语言,甚至是C语言来实现DDD。你完全可以使用struct来表示Value Object或者Entity,使用普通的函数,接受Entity和命令作为参数,来实现Aggregate。比如一个订单的Aggregate,在C语言中可以是这样的:

typedef struct {
    char* order_id;
    char* user_id;
    OrderItem* items;
    int item_count;
    OrderStatus status;
} Order;
​
Result order_place(Order* order, PlaceOrderCommand* cmd) {
    // 验证业务规则
    if (order->status != ORDER_STATUS_DRAFT) {
        return error("Order is not in draft status");
    }
    // 执行业务逻辑
    order->status = ORDER_STATUS_PLACED;
    // 返回领域事件
    return success(create_order_placed_event(order));
}

这段代码没有用到任何OO的特性,没有类,没有继承,没有多态,但它完全符合DDD的思想。Order是一个Entity,order_place是Aggregate的一个方法,它接受一个命令,验证业务规则,修改Entity的状态,返回领域事件。

实际上,我觉着,使用C语言,而不是那些OO语言,反而更能体现出DDD的本质,没有那么多"范式"的杂音。你不会纠结于这个方法应该放在哪个类里,不会纠结于要不要用继承,不会纠结于要不要用设计模式。你只需要关注业务逻辑本身,关注数据结构和函数如何组织才能清晰地表达业务规则。

文档才是DDD的第一公民

DDD不但跟面向对象没有关系,它甚至跟任何代码都没有关系,文档才是DDD世界的第一公民。这听起来可能有点反直觉,毕竟我们是程序员,代码才是我们的产出。但在DDD中,代码只是业务逻辑的一种表现形式,而不是唯一的表现形式。

你完全可以采用自然语言来对业务按照DDD的战术组件进行抽象和描述。比如,你可以这样描述一个订单的Aggregate:

"订单Aggregate包含订单ID、用户ID、订单项列表、订单状态等Entity。它提供以下操作:下单、支付、发货、收货、取消。下单操作接受用户ID和订单项列表作为输入,验证订单项不为空,创建订单Entity,设置状态为待支付,返回OrderPlaced事件。支付操作验证订单状态为待支付,调用支付子域的Aggregate进行支付,如果支付成功,设置订单状态为待发货,返回OrderPaid事件..."

这段描述没有任何代码,但它清晰地表达了订单Aggregate的结构和行为,任何人读了都能理解订单的业务逻辑。而且,这种描述是可以直接和业务人员沟通的,他们不需要懂代码,也能确认这个逻辑是否正确。

使用DDD的架构模式也是一样,你可以用图表和文字来描述系统的架构。比如,你可以画一个图,展示不同的上下文以及它们之间的关系,标注出哪些是核心域,哪些是支撑域,哪些上下文之间有依赖。你可以用文字描述每个上下文的职责,它提供哪些接口,依赖哪些外部服务。

DDD要求文档必须是活的,业务逻辑的变更必须先体现在文档上,然后才是代码。代码更像是文档的衍生物。这和现实当中的大多数开发流程是相反的,传统流程是先写代码,然后再补文档,而且文档往往是过时的,没人维护。但在DDD中,文档是第一位的,它是团队对业务理解的共识,是沟通的基础。

当业务发生变化时,你首先要更新文档,和业务人员确认新的逻辑是否正确,然后再去修改代码。这样可以确保代码的变更是有依据的,不是程序员自己的臆想。而且,文档的变更历史也记录了业务的演化过程,这对于理解系统的历史和未来的决策都是很有价值的。

DDD与规范驱动开发 (Spec-Driven Development, SDD)

AI时代,软件开发分化为了两大阵营:一个阵营是Vibe Coding,另一个阵营是SDD。Vibe Coding强调的是快速迭代,凭感觉写代码,有问题再改。而SDD强调的是先写规范,明确要做什么,然后再让AI生成代码。

DDD与SDD高度契合。DDD要求的文档,其实就是SDD中的规范。你用DDD的方式描述业务逻辑,这个描述本身就是一个规范,可以直接作为AI生成代码的输入。而且,DDD的战术组件,比如Entity、Aggregate、Domain Service,这些概念AI是理解的,你用这些概念来描述业务,AI生成的代码会更加符合DDD的架构。

尤其是,在过去,文档与代码的同步是个难题。写文档很费时间,而且业务变化快,文档很容易就过时了。在团队执行层面,很难做到每次改代码都同步更新文档。但在AI时代,这个难题将不复存在。你只需要维护文档,代码由AI生成,文档变了,重新生成代码就行了。文档和代码的同步变成了一个自动化的过程。

而Vibe Coding对于DDD也不是没有价值。Vibe Coding将主要应用在与领域专家的反复沟通过程当中。你可以快速生成原型,让业务人员看到一个可以交互的界面或者一个可以交互的流程,然后根据他们的反馈快速调整。这种快速的迭代可以帮助你更快地理解业务,发现那些隐藏的需求和约束。一旦通过原型确认了业务逻辑,你就可以把它正式地写成文档,然后用SDD的方式生成最终的代码。

所以,Vibe Coding和SDD不是对立的,而是互补的。在探索阶段用Vibe Coding,在实现阶段用SDD,这样既保证了灵活性,又保证了规范性。

三、不必完全套用所有的战术

当你决定在项目中采用DDD时,切忌不要将所有的战术一股脑的用在你的项目中,那将引发灾难性的后果,而是应该根据具体情况有所选择。很多团队在学习了DDD之后,就想着要把所有的概念都用上,Entity、Value Object、Aggregate、Domain Service、Repository、Factory,再加上六边形架构、CQRS、Event Sourcing,整个系统变得无比复杂,开发效率直线下降,团队成员怨声载道。

这种现象的根源在于对DDD的误解。DDD不是一套必须严格遵守的教条,而是一套可以灵活运用的工具箱。你应该根据问题的复杂度来选择合适的工具,而不是为了用工具而用工具。

仅采用适合的组件和模式

如果你的项目仅涉及到简单的CRUD,那么只使用Active Record就可以了,无需任何其它的组件和模式。比如一个内部的管理后台,主要功能就是对一些配置数据进行增删改查,业务规则很简单,甚至没有什么业务规则。这种情况下,用Active Record直接映射数据库表,提供基本的CRUD操作,就完全够用了。如果你非要引入Aggregate、Domain Service这些概念,反而会让代码变得复杂,增加不必要的抽象层次。

如果你的项目输入输出都比较单一固定,那么使用简单的分层架构就好。比如一个传统的Web应用,输入是HTTP请求,输出是HTTP响应和数据库操作,这种情况下,经典的三层架构就很合适。表现层处理HTTP请求和响应,业务逻辑层处理业务规则,数据访问层负责数据库操作。这种架构简单直观,团队成员都熟悉,没必要引入六边形架构。

只有当你的项目涉及到复杂的状态转换时,才需要考虑引入Aggregate。什么是复杂的状态转换?就是一个对象的状态变化不是简单的赋值,而是要遵循严格的业务规则,状态之间的转换有明确的路径和条件。比如订单的状态,不能从"已完成"直接变成"待支付",必须按照特定的流程来转换。再比如工作流系统,一个任务的状态转换可能涉及到权限检查、前置条件验证、后续任务的触发等复杂逻辑。这种场景下,Aggregate可以把这些复杂的规则封装起来,确保状态转换的合法性。

如果你的项目输入输出是多种类型的,比如既有API,还要处理UI界面,还要将状态和事件输出到数据库、日志以及消息队列,那么才需要考虑六边形架构。六边形架构的价值在于把业务逻辑和技术细节分离,让业务逻辑不依赖具体的输入输出方式。但如果你的输入输出很单一,这种分离就没有太大意义,反而增加了代码的复杂度。

如果你的项目开始面临着读取模式多元化的难题,那么才需要引入非常复杂的CQRS架构。什么是读取模式多元化?就是不同的查询场景对数据的要求完全不同,用一个数据模型无法高效地满足所有需求。比如一个电商系统,商品列表页需要快速的分页查询,商品详情页需要完整的商品信息,搜索功能需要全文索引,数据分析需要聚合统计,推荐系统需要向量搜索。这些需求如果都用同一个数据库来满足,要么性能很差,要么设计非常复杂。这时候CQRS就有价值了,你可以针对不同的查询需求构建不同的读模型,每个读模型用最合适的存储方式。但如果你的查询需求很简单,就是一些基本的列表和详情查询,那CQRS就是过度设计了。

完全不采用任何组件和模式

你甚至完全可以不采用任何上述这些晦涩的术语,使用自己更为熟悉的编程范式,比如模块、对象、函数来实现DDD,只要确保你在代码层面的架构设计与战略层面的领域上下文保持对齐就好。

DDD的核心不在于你用了哪些战术组件,而在于你是否真正理解了业务,是否把业务的复杂性通过合理的边界划分给管理起来了。如果你用自己熟悉的方式也能做到这一点,那就没必要强行套用DDD的术语。

比如,你可以用简单的模块来对应上下文边界,每个模块内部用函数来实现业务逻辑,用数据结构来表示业务对象。只要模块之间的依赖关系清晰,模块内部的逻辑内聚,这就是符合DDD精神的。

关键是要避免两个极端。一个极端是完全不管DDD,代码写得一团糟,所有逻辑都混在一起,没有任何边界和抽象。另一个极端是教条地套用DDD的所有概念,为了用而用,导致过度设计。正确的做法是理解DDD的核心思想,然后根据实际情况灵活运用,选择合适的抽象层次和架构模式。

四、不必需要微服务才能实践DDD

有些讲述DDD的资料中,将微服务视作DDD的实践方式,甚至有人认为不用微服务就不算是在做DDD。但其实两者的关注点并不相同,它们解决的是不同层面的问题。

DDD更关注如何理解业务,如何治理复杂。它的核心是通过领域建模和上下文边界来管理业务的复杂性,让复杂的系统变得可理解、可维护。DDD关心的是概念模型是否清晰,业务规则是否正确,上下文边界是否合理。

微服务主要解决的是规模问题。一个是团队的规模,当团队变大之后,如果所有人都在一个代码库里工作,协调成本会急剧上升,代码冲突频繁,发布流程复杂。微服务通过把系统拆分成多个独立的服务,让不同的团队可以独立开发、独立部署,减少了团队之间的依赖。这就是所谓的两个披萨定律,一个团队的规模应该是两个披萨能喂饱的人数,大概是5到10人。

另一个是业务增长的规模。当业务量增长时,不同的功能模块面临的压力是不同的。比如一个电商系统,商品浏览的流量可能是下单流量的100倍,如果所有功能都在一个服务里,你就只能整体扩展,这样很浪费资源。微服务让不同的业务可以单独扩展,浏览服务可以部署100个实例,下单服务只需要10个实例,这样更加经济高效。

但微服务并不能把复杂的业务逻辑变得简单,或者降低耦合。如果你的业务逻辑本身就是复杂的,拆成微服务之后还是复杂的,甚至可能更复杂,因为原来在进程内的函数调用变成了网络调用,原来的事务变成了分布式事务,原来简单的错误处理变成了复杂的重试和补偿机制。

微服务还会带来很多额外的问题。服务之间的通信需要定义接口,需要处理网络延迟和失败,需要考虑版本兼容性。数据的一致性变得困难,你不能再用数据库事务来保证,而要用分布式事务或者最终一致性。系统的可观测性变得复杂,一个请求可能跨越多个服务,你需要分布式追踪来定位问题。部署和运维的复杂度也大大增加,你需要容器编排、服务发现、配置管理、监控告警等一整套基础设施。

治理复杂以及解耦是DDD所关注的话题。DDD通过上下文边界来实现解耦,但这个边界不一定要对应微服务的边界。你完全可以在一个单体应用中实践DDD,通过模块化来划分上下文边界,通过清晰的接口来定义上下文之间的交互。这样既享受了DDD带来的清晰架构,又避免了微服务带来的复杂性。

微服务和DDD的主要重合点在于上下文边界的划分。

如果你已经用DDD的方式划分好了上下文边界,那么当你需要拆分微服务时,这些边界就是很自然的拆分点。DDD项目更容易划分微服务,因为边界已经清晰了,每个上下文的职责已经明确了,依赖关系已经梳理好了。

但反过来不成立,微服务的边界不一定就是领域上下文的边界。领域上下文的边界更多是按照业务逻辑划分的,关注的是概念的内聚性和业务规则的完整性。而微服务的边界除了业务逻辑,还必须考虑其他因素。

一个是组织架构,你不能违背康威定律。康威定律说的是系统的架构会反映组织的沟通结构。如果两个上下文分别由两个团队负责,那么它们最好是两个独立的微服务,否则会导致频繁的跨团队协调。反过来,如果两个上下文由同一个团队负责,即使它们在业务上是独立的,也可以放在一个微服务里,因为团队内部的沟通成本很低。

另一个是服务之间的依赖关系。微服务的依赖应该是有方向的,形成一个有向无环图,而不能彼此依赖形成循环。如果两个上下文相互依赖,那么它们可能应该合并成一个微服务,或者重新设计它们的边界,打破循环依赖。

所以,DDD和微服务是可以结合的,但不是必须结合的。你可以在单体应用中实践DDD,也可以在微服务架构中实践DDD。关键是要根据你的实际情况来选择,不要为了微服务而微服务,也不要因为没有微服务就觉得不能做DDD。

五、请谨慎对待事件驱动

领域事件(Domain Event)是DDD战术的核心组件,但这并不是说DDD就是事件驱动架构(EDA)。实际上,领域事件在DDD中的主要作用是记录事实,以便于业务日后的演化,而非是为了通信。

在DDD中,当一个Aggregate的状态发生变化时,它会产生一个领域事件,记录发生了什么。比如订单支付成功后,会产生一个OrderPaid事件。这个事件首先是一个历史记录,它告诉我们在某个时间点发生了某件事情。如果你采用Event Sourcing,这些事件会被持久化,成为系统状态的唯一来源。

领域事件也可以用于同一个上下文内部的通信。比如OrderPaid事件可以触发发货流程,但这个触发是在同一个上下文内部,通过事件处理器来实现的,本质上还是进程内的函数调用,只是用事件的方式解耦了订单和发货的逻辑。

但在DDD中,事件主要作用于领域之内,它是领域模型的一部分,用来表达业务概念。而在EDA中,事件主要作用于领域之外,它是系统之间的通信机制,用来实现异步解耦。这是两个完全不同的概念,虽然都叫事件,但用途和设计考虑是不同的。

EDA并不能简化业务逻辑和解耦。很多人以为用了事件驱动,系统就自动解耦了,但实际上,如果你的业务逻辑本身就是耦合的,用事件驱动只是把耦合从代码层面转移到了运行时层面,甚至可能让耦合变得更隐蔽,更难以理解。

EDA最主要的应用场景是异构系统的集成。当你需要把多个独立的系统连接起来,而这些系统可能用不同的技术栈,由不同的团队维护,甚至属于不同的公司,这时候用事件来通信是合适的。每个系统发布自己的事件,其他系统订阅感兴趣的事件,系统之间不需要直接调用,降低了耦合度。

但使用EDA必须注意很多问题。

首先是事件的元数据管理。事件的格式是什么,包含哪些字段,字段的含义是什么,这些都需要明确定义和管理。而且事件的格式可能会演化,你需要考虑版本兼容性,旧版本的消费者能否处理新版本的事件,新版本的消费者能否处理旧版本的事件。

其次是事件的可靠性。事件发布出去之后,能否保证被消费者接收到?如果消费者处理失败了怎么办?需要重试吗?重试多少次?如果一直失败怎么办?这些都需要考虑。你可能需要引入消息队列来保证可靠性,但消息队列本身也会带来复杂性。

再次是事件的时效性。事件从发布到被消费,中间有延迟,这个延迟可能是几毫秒,也可能是几秒甚至更长。如果你的业务对时效性有要求,事件驱动可能不合适。而且,如果消费者处理事件的速度跟不上生产者发布事件的速度,就会出现积压,导致延迟越来越大。

还有事件的顺序问题。如果同一个实体产生了多个事件,这些事件的处理顺序可能很重要。但在分布式系统中,保证顺序是很困难的,尤其是当你有多个消费者实例并行处理事件时。你可能需要引入分区或者其他机制来保证顺序,但这又会增加复杂性。

最后是事件的重复问题。由于网络或者系统故障,同一个事件可能被发送多次,消费者可能收到重复的事件。你需要确保消费者的处理逻辑是幂等的,也就是说,处理同一个事件多次和处理一次的效果是一样的。但实现幂等性并不容易,需要仔细设计。

这些问题都不容易解决,会为系统带来极大的混乱,并且非常难以调试。在单体应用中,你可以用调试器单步执行,可以看到完整的调用栈,可以很容易地定位问题。但在事件驱动的系统中,一个业务流程可能跨越多个服务,通过多个事件串联起来,你很难追踪一个请求的完整路径,很难知道某个事件是谁发的,被谁消费了,处理结果如何。

因此,建议将EDA的使用范围仅限于异构系统的集成,核心业务领域之间的通信尽量不要采用EDA。如果你的系统都在一个进程内,或者都是你自己团队维护的微服务,用同步调用或者进程内的事件机制就好,没必要引入消息队列和异步事件。只有当你真的需要跨系统集成,而且这些系统是异构的、独立演化的,才考虑用EDA。

六、要实施DDD不需要全面重构

很多团队在了解了DDD之后,觉得自己现有的代码完全不符合DDD的理念,想要推倒重来,进行一次彻底的重构。但这种想法是危险的,大规模的重构往往会失败,即使成功了,代价也是巨大的。

我们并不需要对现有项目进行彻底重构,才能实施DDD。DDD完全可以被渐进式的引入到项目当中。渐进式的好处是风险可控,每一步都是小的改进,即使出了问题,影响范围也有限,可以快速回滚。而且,渐进式的改进可以持续产生价值,不需要等到整个重构完成才能看到效果。

首先,可以先在战略层面进行实施。战略层面的工作主要是梳理和文档化,不涉及代码的大规模修改,风险很小。你可以开始梳理领域和上下文边界,识别出核心域、支撑域、通用域,明确各个子域的职责和边界。这个过程可以帮助团队建立对业务的共同理解,发现现有架构中的问题。

然后构建文档,把你对业务的理解用文档的形式记录下来。这个文档不需要一开始就很完善,可以从核心的业务流程开始,逐步补充细节。重要的是要让文档成为团队沟通的基础,所有人都参考同一份文档,用同样的语言讨论问题。

统一语言也是战略层面的重要工作。梳理出团队使用的术语,明确每个术语的含义,消除歧义。这个工作可以在日常的开发和讨论中逐步进行,不需要专门花大量时间。当发现有歧义的术语时,就及时澄清和记录,慢慢地就会形成一套统一的语言。

在战略层面的工作有了一定基础之后,可以开始在战术层面进行尝试。但不要一下子改动所有的代码,而是从新需求开始。当有新的功能要开发时,尝试用DDD的方式来实现,用合适的战术组件,遵循清晰的上下文边界。这样既可以实践DDD,又不会影响现有的功能。

随着新需求的不断开发,系统中会逐渐出现两种风格的代码,一部分是旧的代码,一部分是用DDD方式写的新代码。这是正常的,不要试图立刻统一它们。随着时间推移,旧代码会越来越少,新代码会越来越多,系统会自然地向DDD的方向演化。

对于旧需求,如果它们运行良好,没有频繁的变更需求,那就不要动它们。如果某个旧模块需要修改,而且修改的工作量比较大,那可以考虑在修改的同时进行重构,让它符合DDD的架构。但这个重构应该是局部的,只针对这个模块,而不是整个系统。

在新旧代码之间,可以通过设置防腐层来隔离。防腐层把旧代码的接口翻译成新代码能理解的形式,让新代码不需要直接依赖旧代码的实现细节。这样,即使旧代码的结构很混乱,也不会污染新代码的架构。随着旧代码逐渐被替换,防腐层也会逐渐缩小,最终可以完全移除。

这种渐进式的方式需要一定的耐心,不会立刻看到整个系统焕然一新,但它是最安全、最可持续的方式。而且,在这个过程中,团队会逐渐积累对DDD的理解和经验,这比一开始就进行大规模重构要有价值得多。

七、DDD不止适合业务开发

最后一个误解是认为DDD只适合业务型软件的开发,对于基础设施、工具、框架这类技术型软件不适用。但实际上,DDD针对的是所有软件的复杂性,并不限于业务型软件。

基础设施的开发也同样可以采用DDD,甚至基础设施往往更适合DDD。为什么?因为大多数基础设施都需要处理复杂的状态转移,而这正是DDD所擅长解决的问题。

想想Kubernetes,它管理着容器的生命周期,一个Pod可能处于Pending、Running、Succeeded、Failed等多种状态,状态之间的转换有严格的规则和条件。这种复杂的状态机,用Aggregate来建模是非常合适的。事实上,Kubernetes的设计就体现了很多DDD的思想,虽然它可能没有明确地说自己在用DDD。

再比如数据库系统,事务的状态管理、锁的获取和释放、日志的写入和回放,这些都涉及复杂的状态转换和业务规则。虽然这里的"业务"不是传统意义上的商业业务,但它同样是一个领域,有自己的概念模型和规则。

编译器也是一个很好的例子。编译的过程涉及词法分析、语法分析、语义分析、优化、代码生成等多个阶段,每个阶段都有自己的输入输出和处理逻辑。这些阶段可以看作不同的上下文,每个上下文有自己的领域模型,比如词法分析的领域模型是Token,语法分析的领域模型是AST,它们之间通过明确的接口进行交互。

所以,DDD的核心思想,比如领域建模、上下文边界、统一语言,这些对于任何复杂的软件系统都是适用的。不管你是在开发一个电商系统,还是在开发一个数据库,还是在开发一个操作系统,只要你的系统足够复杂,你就需要一种方法来管理这种复杂性,而DDD提供的就是这样一种方法。

当然,技术型软件和业务型软件在具体实践上会有一些差异。技术型软件的"领域专家"可能是系统架构师或者资深工程师,而不是业务人员。技术型软件的"领域语言"可能更偏向技术术语,而不是业务术语。但这些都是表面的差异,核心的方法论是一样的。

所以,DDD值得所有开发人员学习,不管你是做业务开发还是做基础设施开发。它提供的不仅仅是一套技术实践,更是一种思维方式,一种面对复杂性的方法论。掌握了这种方法论,你会发现很多看似不同的问题,其实有着相似的本质,可以用相似的方式来解决。

最后

DDD是一个强大但经常被误解的方法论。它的核心不在于那些战术组件和架构模式,而在于如何理解业务、如何管理复杂性。战略层次才是DDD的根基,战术层次只是实现战略的一种方式,而且是可以灵活选择的。

DDD与面向对象无关,与任何编程范式都无关,它关注的是业务逻辑的抽象和组织。文档在DDD中的地位甚至高于代码,因为文档是团队对业务理解的共识,是沟通的基础。

实践DDD不需要把所有的战术都用上,也不需要微服务,更不需要全面重构。你应该根据实际情况,渐进式地引入DDD,选择合适的工具和模式。而且,DDD不仅适用于业务开发,也适用于基础设施开发,它是一种通用的管理复杂性的方法论。

希望这篇文章能够帮助你更好地理解DDD,避免常见的误解,在实践中真正发挥DDD的价值。