5253 字
26 分钟
关于DDD

DDD(Domain Driven Design)#

我的第一个Golang项目跟着一个博主做的,当时急着转技术栈,几乎两三天啃下了整个项目设计,上来直接建了一堆目录,domain、aggregation之类的。虽然项目是写完了,各个层级的作用也搞明白了,但对于ddd本身的思考聊胜于无。这之后,在我做了一些自己的项目,看了一些基于ddd的设计之后,对这种架构设计的理解更加深入了,所以写下这篇博客,来把我的理解和思考记录下来

Definition#

首先给DDD下一个定义,领域驱动设计是一种软件系统的架构设计。 在现代软件系统设计中,始终有三个隐疾影响着整体的开发和性能评估

  1. 隐晦: > 系统的设计其实是一种从具象到抽象的过程,软件的设计亦是如此,是将现实世界的业务设计设计成代码实现的一种抽象。所以在设计的过程中,每个人拥有的角色不同,站定的立场不同,拥有的视角也就不同,往往要站在对方的视角上才能明白他做某件事的缘由,代码是具体的技术实现,而往往整个软件设计是是从现实世界的业务概念中诞生的,这无形中增加了理解的成本
  2. 耦合 > 在代码层面,层与层、模块之间的交互产生了代码耦合,模块层面的耦合需要跨模块、服务进行交互,而系统层面的耦合就需要团队的协作,耦合过深就会增加开发成本,影响着开发效率
  3. 变化 > 业务需求的敲定仅仅决定了系统功能,但不同的用户需求不同(尤其体现在ToC项目中),用户需求推动着软件开发的迭代,不同的业务发展阶段需求在不断变化,系统功能要随着业务需求的变化来不断调整,同时也牵扯着系统改动的频次和范围

而DDD,就是针对软件设计复杂性的方法之一,它能很好的解决以上三种顽疾,我们下面介绍一下DDD

DDD是一种软件设计方法,它主要用于处理复杂业务需求。我们可以将其分解为“领域”、“驱动”和“设计”三个部分来理解。

  1. 领域: “领域”指的是特定的业务范围或问题域,比如在一个短视频平台项目中,可能会有以下领域:流媒体、推荐、计费等。并且在一个项目中,往往存在着一个或几个核心域,在上面的举例中,流媒体域就是短视频的核心域。 领域内部又可以进行细分: 领域由三部分组成:领域里有用户,即涉众域;用户要实现某种业务价值,解决某些痛点或实现某种诉求,即问题域;面对业务价值,痛点和诉求,有对应的解决方案,这是解决方案域。 通俗地讲,DDD是针对特定业务,用户在面对业务问题时有对应的解决方案,这些问题与方案构成了领域知识,它包含流程、规则以及处理问题的方法,领域驱动设计就是围绕这些知识来设计系统。
  2. 驱动: “驱动”有两层含义:一是业务问题域驱动领域建模的过程;二是领域模型驱动技术实现或代码开发的过程。 所以在实际开发中,往往推动落地实现的往往不是代码技术实现,而是领域模型的完善,由领域建模来推动开发
  3. 设计: “设计”在DDD中通常指的是领域模型的设计,DDD强调领域模型是系统的核心,它反映了业务概念和业务规则 所做的设计外表是按照领域模型来落地代码设计,但内在还是领域建模,包括领域语言统一、界限上下文确定等

process

Concepts#

我们可以将DDD分为两个部分 Strategic DesignTactical Design,前者解决的是在域设计时每个人所使用的语言一致,也可以说是为了需求分析和知识提炼所必须的一步,它使得软件内对象的语言和业务语言一致,我们称它为 ubiquitous language (通用语言)。而后者则有效帮助开发者捕获域的复杂性,同时还可以提高可维护性、灵活性和可伸缩性

Strategic Design in Domain-Driven Design (DDD) focuses on defining the overall architecture and structure of a software system in a way that aligns with the problem domain. It addresses high-level concerns such as how to organize domain concepts, how to partition the system into manageable parts, and how to establish clear boundaries between different components. In Domain-Driven Design (DDD), tactical design patterns are specific strategies or techniques used to structure and organize the domain model within a software system. These patterns help developers effectively capture the complexity of the domain, while also promoting maintainability, flexibility, and scalability. Let us see some of the key tactical design patterns in DDD

Bounded Contexts#

  • A specific area within a problem domain where a particular model or language is consistently used.
    问题域中始终使用特定模型或语言的特定域。
  • Sets clear boundaries for terms that may have different meanings in different parts of the system.
    为在系统的不同部分可能具有不同含义的术语设置明确的边界。
  • Allows teams to develop models specific to each context, reducing confusion and inconsistency.
    允许团队开发特定于每个上下文的模型,从而减少混淆和不一致。
  • Breaks down large, complex domains into smaller, more manageable parts.
    将大型、复杂的域分解为更小、更易于管理的部分。

界限上下文很容易理解,它是一类屏障,用来框定语义作用的范围,比如有些情况下 user 这个语义往往作用于多个领域,甚至有可能在某些领域中,他们的实体模型都一致,此时在进行域交互的过程中,模型就无需收敛。总而言之,界限上下文是DDD防腐的重要要素之一。

tips: 很多时候,不同域中会分割出相同名称的子域,即使名称相同,但不代表相同语义的界限上下文一样。

Context Mapping#

  • The process of defining relationships and interactions between different Bounded Contexts.
    定义不同界定上下文之间的关系和交互的过程。
  • Identifies areas where contexts overlap or integrate.
    确定上下文重叠或集成的区域。
  • Establishes clear communication and agreements between different contexts.
    在不同上下文之间建立清晰的沟通和协议。
  • Ensures different parts of the system can work together effectively while maintaining boundaries.
    确保系统的不同部分可以有效地协同工作,同时保持边界。
  • Includes methods like Partnership, Shared Kernel, and Customer-Supplier for effective mapping
    包括 Partnership、Shared Kernel 和 Customer-Supplier 等方法,用于有效映射

ddd_context_mapping

上面说了,在域与域之间是有界限上下文来限制语义的范围的,不同域之间的语义交互需要收敛,就需要上下文映射来收敛,上下文映射概述了哪些子域相互通信、它们如何通信以及通信的方向

 Anti-Corruption#

我觉得在聊这块之前,不妨先讲讲为什么会有 ”腐化“ 的现象,更确切地说, ”腐化“ 是必然的,在软件系统设计中,随着需求的更新,一次次迭代下,代码的复杂度是一定会增加的,这是不可避免的,采用某些设计方法论无非是减缓了这个过程。

这么说可能不太立体,我可以举一个场景,本来你只有寥寥几个业务模型,随着时间的发展,这五个业务模型的逻辑膨胀到令人发指(往往不用等太久,四五次迭代即可..),此时每个模型的代码都有几万行,这时候想重写已经太晚了,拆分和维护的成本都很高,最后只能部分切流,缓慢地迁移业务,是不是很恶心?

  • A strategic pattern designed to protect a system from the influence of external or legacy systems that use different models or languages.
    一种策略模式,旨在保护系统免受使用不同模型或语言的外部或遗留系统的影响。
  • Acts as a translation layer between the external system and the core domain model.
    充当外部系统和核心域模型之间的转换层。
  • Transforms data and messages to ensure compatibility between systems.
    转换数据和消息以确保系统之间的兼容性。
  • Keeps the core domain model pure and focused on the problem domain while allowing necessary integration with external systems.
    保持核心域模型的纯粹性并专注于问题域,同时允许与外部系统进行必要的集成。

很多设计方法里都有自己的防腐设计,比如 MVC 是严格规定层与层之间禁止跨层调用(ctl -> repo),在DDD中,防腐大多体现在语义维护上,一方面必须在需求分析时,进行清晰严明的领域划分和知识提炼,一方面在跨域交互时必须做到语义的正确收敛

Entity#

An entity is a domain object that has a distinct identity and lifecycle. Entities are characterized by their unique identifiers and mutable state. They encapsulate behavior and data related to a specific concept within the domain.

实体是具有不同标识和生命周期的域对象。实体的特征是其唯一标识符和可变状态。它们封装了与域中特定概念相关的行为和数据。

就是一个域中的实体,由于DDD主张采用充血模型来进行实体构建,所以Entity既包含数据,又包含行为,举个例子:在银行应用程序中,BankAccount 实体可能具有帐号、余额和所有者等属性,以及用于存款、提取或转账的方法。

实体就是领域中需要唯一标识的领域概念。因为我们有时需要区分是哪个实体。有两个实体,如果唯一标识不一样,那么即便实体的其他所有属性都一样,我们也认为他们两个不同的实体;因为实体有生命周期,实体从被创建后可能会被持久化到数据库,然后某个时候又会被取出来。所以,如果我们不为实体定义一种可以唯一区分的标识,那我们就无法区分到底是这个实体还是哪个实体。另外,不应该给实体定义太多的属性或行为,而应该寻找关联,发现其他一些实体或值对象,将属性或行为转移到其他关联的实体或值对象上。

Value Object#

A value object is a type of domain object that represents a value that is conceptually unchangeable. Unlike entities, value objects lack a unique identity and are usually used to describe attributes or characteristics of entities. They are compared for equality based on their properties rather than their identity.

值对象是一种域对象,它表示概念上不可更改的值。与实体不同,值对象缺少唯一标识,通常用于描述实体的属性或特征。根据它们的属性而不是它们的身份来比较它们的相等性。

在领域中,并不是没一个事物都必须有一个唯一标识,也就是说我们不关心对象是哪个,而只关心对象是什么。 值得注意的是值对象没有唯一标识,这是它和实体的最大不同。另外值对象在判断是否是同一个对象时是通过它们的所有属性是否相同,如果相同则认为是同一个值对象 所以如果使用 Java 或者 CSharp 进行DDD的实践,要注意所有值对象的设计必须重写 hashCodeequals

Aggregate#

聚合,它通过定义对象之间清晰的所属关系和边界来实现领域模型的内聚,并避免了错综复杂的难以维护的对象关系网的形成。聚合定义了一组具有内聚关系的相关对象的集合,我们把聚合看作是一个修改数据的单元。

说起来有些复杂, 一句话说明白就是: 聚合是 EntityValue Object 的组合体。同时聚合也是一个事务边界,因此无论何时进行更改,都应该在单个事务中提交或回滚到数据库。这样,聚合始终处于一致状态。与 Entities 一样,聚合也有一个 ID,因此可以从应用程序的其他部分引用它们。

其实聚合本质上还是语义维护,它是在 “寻找拥有内在联系、内聚关系的对象,并将他们放在一个聚合内” 聚合使得很多领域复杂操作变得清晰易懂,但添加到聚合中的规则越多,执行更新所需的时间就越长,这可能会影响性能,因此,通常需要在性能和一致性之间进行一些权衡,在某些情况下,添加所谓的定期运行的纠正策略更有意义

process

Congestion Model#

充血模型中,领域对象不仅存储数据(如属性),还包含处理这些数据的业务逻辑(方法)。例如,一个“账户”对象可能有余额属性,同时能执行存款或取款操作。这与贫血模型不同,贫血模型的领域对象只包含数据,业务逻辑由外部服务层处理。

其实看下来,贫血模型才更适合面向过程语言,主张逻辑分散,服务分层。但现在,以Golang为主力的开发者或者说公司更倾心于DDD,Javaer大多数都在用MVC,有点好笑www

充血模型是DDD中一种重要的设计模式,通过将业务逻辑与数据封装在同一领域对象中,实现了更好的封装、可测试性和与业务领域的紧密契合。它在处理复杂业务逻辑时尤为有效,但需要开发团队具备较高的面向对象设计能力和实践经验。建议在业务复杂、团队经验丰富的情况下优先考虑充血模型;在简单场景下可选择贫血模型,但需注意避免长期依赖贫血模型可能导致的代码主营地(Twitter)等社交媒体平台分享

Last#

最后谈谈我在落地实践中的一些理解 我觉得在DDD架构下开发,最重要的一点就是 Domain 的实现(前提是战略设计已经完成,各种语义都被统一了),其实落地实践中,尤其是Java和C#这些OOP语言里,DDD的实现是有某些定式的,大多把某个 Domain 看做一个类( Class ),然后在Domain里去实现技术细节。但是在 Golang 的DDD实现中不太一样,Go 是更倾向于把方法逻辑封装在一个个函数里,也就是 small function 原则,然而具体怎么实现,则要看业务模型的搭建。

所以DDD是一种业务建模主导技术实现的设计方法论,所以如果你试过至少一次实现后,那你会发现以下两件事:

  1. 当战略设计(业务建模、语言通义、知识提炼)结束后,技术实现(代码)将会异常轻松快捷
  2. 代码的逻辑分支非常业务友好(只要你懂业务,就能读懂对应的代码)

可以举个例子 如果说某流媒体平台的业务(比如说NetFlix)是MVC架构的,具体是这样的:

package service
import (
"context"
"errors"
"time"
)
type SubscriptionService struct {
userRepo UserRepository
subscriptionRepo SubscriptionRepository
billingService BillingService
}
func (s *SubscriptionService) UpdateSubscription(ctx context.Context, userID string, newPlan string) error {
// 获取用户
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return err
}
if user == nil {
return errors.New("user not found")
}
// 获取当前订阅
currentSubscription, err := s.subscriptionRepo.GetByUserID(ctx, userID)
if err != nil {
return err
}
if currentSubscription == nil {
return errors.New("no active subscription found")
}
// 检查订阅状态
if currentSubscription.Status != "active" {
return errors.New("subscription is not active")
}
// 检查新计划是否与当前计划相同
if newPlan == currentSubscription.Plan {
return errors.New("new plan is the same as current plan")
}
// 检查用户是否有资格升级到新计划
if !isEligibleForPlan(user, newPlan) {
return errors.New("user is not eligible for this plan")
}
// 计算价格差
priceDifference, err := calculatePriceDifference(currentSubscription.Plan, newPlan)
if err != nil {
return err
}
// 存储旧计划以便回滚
oldPlan := currentSubscription.Plan
// 更新订阅计划
currentSubscription.Plan = newPlan
currentSubscription.UpdatedAt = time.Now()
err = s.subscriptionRepo.Update(ctx, currentSubscription)
if err != nil {
return err
}
// 调整账单
err = s.billingService.AdjustBilling(ctx, userID, priceDifference)
if err != nil {
// 回滚订阅更新
currentSubscription.Plan = oldPlan
s.subscriptionRepo.Update(ctx, currentSubscription)
return err
}
return nil
}

在 MVC 架构中,业务逻辑通常由服务层处理,控制器负责接收用户请求并调用服务层。 所有业务规则(如订阅状态检查、计划验证、价格计算)都在 UpdateSubscription 方法中实现。这导致方法体较长,包含多个条件分支。这些 if 看起来人畜无害,但是这样的函数往往会随着时间变得越来越复杂。函数越复杂,未来开发中出现潜在逻辑漏洞的可能性就越大(比如if顺序不对,就会漏分支)

在 DDD 中,业务逻辑被封装在领域实体中,应用服务层仅负责协调领域对象与基础设施

Entity实体首先需要被定义:

package domain
import (
"errors"
"time"
)
// Plan 代表订阅计划
type Plan struct {
Name string
Price float64
}
func (p *Plan) CanUpgradeFrom(current *Plan) bool {
// 定义升级规则
return true // 简化示例
}
func (p *Plan) PriceDifference(current *Plan) float64 {
return p.Price - current.Price
}
// Subscription 代表用户订阅
type Subscription struct {
ID string
UserID string
CurrentPlan *Plan
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
func (s *Subscription) IsActive() bool {
return s.Status == "active"
}
func (s *Subscription) CanUpdateTo(newPlan *Plan) error {
if !s.IsActive() {
return errors.New("subscription is not active")
}
if newPlan.Name == s.CurrentPlan.Name {
return errors.New("new plan is the same as current plan")
}
if !newPlan.CanUpgradeFrom(s.CurrentPlan) {
return errors.New("cannot upgrade to this plan from current plan")
}
return nil
}
func (s *Subscription) UpdateTo(newPlan *Plan) {
s.CurrentPlan = newPlan
s.UpdatedAt = time.Now()
}

为了构建一个 Domain,你首先需要知道你这个系统是干什么的,或者说他的核心功能,核心业务是什么。基于此,将业务抽象成一个Domain的实体,一般来说就是一个名词。然后将业务逻辑封装到这个实体内,在 Application 里“编排”来实现业务逻辑:

package application
import (
"context"
"domain"
)
type UpdateSubscriptionHandler struct {
subscriptionRepo domain.SubscriptionRepository
planRepo domain.PlanRepository
billingService domain.BillingService
}
func (h *UpdateSubscriptionHandler) Handle(ctx context.Context, cmd UpdateSubscriptionCommand) error {
// 获取当前订阅
subscription, err := h.subscriptionRepo.GetByUserID(ctx, cmd.UserID)
if err != nil {
return err
}
if subscription == nil {
return errors.New("no active subscription found")
}
// 获取新计划
newPlan, err := h.planRepo.GetByName(ctx, cmd.NewPlan)
if err != nil {
return err
}
// 检查是否可以更新计划
err = subscription.CanUpdateTo(newPlan)
if err != nil {
return err
}
// 计算价格差
priceDifference := newPlan.PriceDifference(subscription.CurrentPlan)
// 调整账单
err = h.billingService.AdjustBilling(ctx, cmd.UserID, priceDifference)
if err != nil {
return err
}
// 更新订阅
subscription.UpdateTo(newPlan)
return h.subscriptionRepo.Update(ctx, subscription)
}

你能看到的是后续的新增和维护就是不断的组合、编排,开发量几乎全在domain里的逻辑,其他地方也只是添加进去。在比较复杂的项目里,这个优势是非常大的。你可以想象一下一个函数里全是复杂的逻辑判断,某些地方还有特殊处理,膨胀到几百行。跟这样一眼看过去就能翻译成人话的代码,哪个更好维护。

在我看来DDD并不是某种定式规范,而是一套方法论,DDD是一种开放的思想体系,其核心在于通过领域模型的建立来引导整个设计过程。

而且DDD明确地指出一点,也是我非常认同的一点:设计是十分广泛的。 现在很多人都认为软件设计被框定在代码实现,但往往在设计过程中,开始聊需求的时候,设计就已经开始了,也就是说我们的设计应该高度依赖业务分析,作为工程师,也该培养用户思维、业务思维和产品思维,这有助于深入理解业务和问题域。基于这样的理解,工程师可以运用结构化思维来分解问题,并通过抽象思维来提炼模型。另一方面,结合分层、分治和工程思维,工程师可以有效地将设计转化为实际的代码实现。

所以作为工程师,也不该自甘沦为 CURD BOY,我认为,只有懂业务的工程师,才能一直当工程师,而且是某个 Domain 的工程师。

Ref#

关于DDD
https://fuwari.vercel.app/posts/ddd/
作者
Simon
发布于
2025-05-07
许可协议
CC BY-NC-SA 4.0