对于DDD的学习以及个人的看法
最近在看前端的架构设计这方面,偶然间看到了DDD的方法论,于是结合我自己最近重构live2d的项目,对其进行了深入的学习和理解
最近在学习前端架构设计时,我接触到了领域驱动设计(Domain-Driven Design,简称 DDD)。一开始,我很容易把 DDD 和后端、微服务、实体、聚合这些词绑定在一起,觉得它是一套主要用于服务端的架构模式。后来,我尝试用 DDD 的视角重新审视自己正在重构的 Live2D 桌宠项目,才逐渐意识到:DDD 更像是一套分析和组织复杂问题的思维方式,而不是必须照搬的目录结构或技术模板。
本文记录我对 DDD 的理解、误区,以及它在前端项目中的适用边界。案例项目是 我的个人桌宠项目,这是一个基于 Electron、React 和 Live2D 的桌宠应用。
我一开始如何理解 DDD
最初,我把 DDD 理解成这样一条链路:先把系统拆成几个域,再为每个域建立实体和聚合根,通过领域服务完成业务事务,最后用限界上下文、防腐层和领域事件连接不同的域。
按照这个思路,当前项目似乎可以被划分成四个模块:
- Live2D,负责模型渲染、窗口布局和用户交互,可以看作核心模块;
- 控制面板,负责配置和用户操作,可以看作支撑模块;
- AI,负责对话、ASR、RAG 和 TTS,可以看作核心模块;
- Electron,负责窗口、IPC、文件和系统能力,可以看作通用基础设施模块。
项目通过 DI Container 统一创建和注入这些 service。这样看来,每个 service 都有自己的职责,模块之间的依赖也比较清楚。
这个类比对理解架构是有帮助的,但它还不能直接等同于严格意义上的 DDD。Service、子域、限界上下文和微服务并不是同一个概念。一个限界上下文可以由多个 service 组成,一个 service 也可能同时承担应用编排、领域逻辑和基础设施调用。微服务则是部署和运维上的单元,不是领域划分的必然结果。
DDD 的核心不是“多几个层”
DDD 首先要解决的是:系统中的业务概念是什么,规则由谁负责,哪些模型属于同一个边界,以及不同模型之间应该如何协作。
它最有价值的部分通常是:
- 建立团队和代码共同使用的通用语言;
- 识别核心域、支撑域和通用域;
- 根据模型和规则划分限界上下文;
- 明确数据和业务规则的所有权;
- 让聚合维护真正需要保持一致的状态;
- 让外部系统通过清晰的端口和契约接入。
因此,DDD 不是“把项目拆成实体、领域服务、仓储和事件”这么简单。那些是战术工具,只有在模型复杂度确实需要它们时才值得使用。
子域、限界上下文与服务
子域属于问题空间,描述业务问题的不同部分。限界上下文属于模型空间,描述一套语言和规则在哪个范围内有效。服务属于代码和运行时,是实现职责的一种方式。
用当前项目举例,子域可以先按业务问题来观察:Live2D 运行时解决“模型如何显示、播放动作和响应交互”,AI 运行时解决“如何完成对话、记忆、检索和语音合成”,控制面板解决“用户如何管理模型和运行参数”,Electron 则提供窗口、IPC 和文件访问等通用能力。这种划分回答的是“系统要解决哪些不同的问题”,所以它是问题空间中的子域候选。
限界上下文要进一步回答“这些词在什么模型中具有什么含义”。例如,“模型”在 Live2D 上下文中可能指可加载、可播放动作的视觉模型;在 AI 上下文中可能指 LLM 或 TTS 模型;在控制面板上下文中可能指用户选择的模型配置。如果这些上下文分别拥有自己的规则和数据,并且一个上下文不能直接把自己的对象当成另一个上下文的对象使用,就有理由划分边界。反过来,如果它们共享同一个模型、由同一个进程运行,也没有独立演化的需求,那么继续使用模块化单体可能更合适。
服务则是代码中的职责入口。例如,当前的 Live2dService 负责模型、动作、气泡和布局运行时的协调,ControlPanelService 负责配置页面的状态和操作,ConfigService 负责配置快照及持久化调用。这些 service 是应用层或模块门面,用来组织流程和副作用;它们不自动等于 DDD 中的领域服务。DDD 领域服务通常承载不适合放进某个实体、但仍然属于领域模型的无状态业务规则,例如“根据命中区域和当前配置决定应该播放哪个动作”。
另一方面,“没有同名多义词”也不能证明不需要限界上下文。限界上下文的划分还与数据所有权、业务规则、团队协作和系统演化方式有关。如果几个模块共享同一个模型、由同一个进程运行,也没有独立部署或独立演化的需求,那么使用模块化单体就可能比划分多个严格的上下文更合适。
聚合根不是系统中最重要的对象
我曾经认为,Live2D 是整个桌宠应用的核心,所以它应该是唯一的实体和聚合根,其他模块都围绕它展开。这个想法并不是完全没有道理。以订单为例,订单项、收货地址和金额等对象通常只有放在订单的业务语境中才有意义,外部也应该通过订单来修改它们。按照同样的直觉,模型、动作、气泡和窗口布局似乎也可以全部归入 Live2D 这个根对象。
但这里需要先区分实体、值对象和聚合根。
实体具有稳定的身份和生命周期。它的属性可以变化,但身份仍然不变。例如,同一个订单可以从“待支付”变成“已支付”,同一个模型实例也可以从未加载变成已加载、从播放待机动作变成播放点击动作。判断实体时,应该问“它是否需要被持续识别为同一个对象”,而不是只问“它是否有字段”。
值对象没有独立身份,它只表达一组描述性属性,通常通过属性值判断是否相等,并且适合设计成不可变对象。窗口坐标、窗口尺寸、模型缩放值、气泡位置、颜色和布局参数,都更接近值对象:坐标从 (100, 200) 变成 (120, 220) 时,我们关心的是新的坐标值,而不是坐标对象本身是不是原来的那个对象。值对象脱离模型可能没有业务意义,但这并不意味着它必须拥有自己的身份,或者必须成为独立聚合。
聚合是一个由实体和值对象组成的边界,聚合根是其中唯一允许外部直接访问的根实体。外部通过聚合根修改内部对象,聚合根负责保护“必须一起成立”的业务规则。例如,订单根可以保证订单项总额、支付状态和发货状态之间的约束;外部不能绕过订单直接把一个已发货订单改回待支付。
实体的判断依据是稳定身份和生命周期,不是重要性。聚合根的判断依据是一致性边界,也不是谁被依赖得最多。
一个聚合应该回答这样的问题:哪些状态必须一起修改,哪些规则不能被外部绕过,哪些对象需要由同一个入口保证一致性?在 Live2D 语境中,如果“模型实例、当前动作、交互区域和展示布局”必须在同一个业务操作中保持一致,并且所有修改都必须经过模型根对象,那么把它们放在一个 Live2D 聚合中是可以成立的。模型实例可以是实体,动作选择和交互区域配置可以是内部对象,窗口尺寸、模型位置和气泡位置可以是值对象。
但“其他对象脱离模型没有意义”,只能说明它们在产品语义上从属于模型,不能单独证明整个项目应该是一个聚合。当前项目中,窗口、模型、气泡和控制面板配置确实会通过 Live2dService、ControlPanelService、ConfigService 和 StateBusService 共同协调,运行时上看起来像一个整体。但这更准确地说是模块之间的应用编排和状态同步:这些 service 没有共同维护一组必须原子成立的领域规则,也没有一个领域根对象统一保护所有修改。
如果把窗口、模型、对话、TTS 和配置全部放进一个名为 Live2D 的聚合,最终很容易得到一个跨越整个系统的巨型聚合。这样做只有在这些状态必须共享同一事务、必须由同一个根对象保护,并且具有相同生命周期时才合理。当前项目中,对话和 TTS 即使暂时没有模型窗口也可以继续处理,控制面板的配置编辑也不需要和每一帧的窗口布局进行原子提交,因此它们没有足够理由共享同一个 DDD 聚合边界。
所以,我把整个项目看成一个“运行时巨根”的直觉,可以解释当前代码为什么需要由几个 service 共同协调,但它还不是严格意义上的 DDD 聚合根。当前实现确实还没有体现出清晰的聚合拆分,这也是它更接近模块化架构、而不是完整 DDD 架构的原因。
“脱离模型就没有意义”可以帮助我们理解产品的中心,但不能单独决定聚合边界。边界最终还是要由身份、生命周期和一致性规则决定。
领域事务表达一次完整的业务行为
这里所说的领域事务,可以用两个问题来概括:做了什么事情,以及这件事情对业务产生了什么影响。它更接近一次完整的业务操作或业务用例,不是由交互形式决定的。
例如,用户点击了一下 Live2D 模型,系统根据点击区域识别交互类型、选择并播放对应动作,同时更新模型的交互状态。虽然入口只是一次点击,但整个过程完成了“与模型交互”这件业务,所以可以把它理解成一次领域事务。相反,用户只是在输入框中键入一个字符,通常只是修改尚未提交的 UI 草稿,并没有产生完整的业务结果。
表单也是同样的道理。编辑表单字段只是准备数据;提交表单后,系统创建了一份业务记录、改变了状态,并跳转到结果页面,这才构成“创建表单记录”这一完整的业务操作。页面跳转本身属于表现层反应,真正的业务影响是系统中新增了记录。如果提交失败,事务也没有完成,界面应该继续呈现失败或待处理状态。
因此,领域事务不能简单地按照“点击是不是 UI 事件”来判断。一次点击可以触发领域事务,一次后台任务也可以触发领域事务;关键在于它是否执行了有业务含义的规则并产生了可识别的结果。保存模型配置、完成一次对话、提交 TTS 任务,都可以按照各自的业务语义理解成不同的领域事务。
领域事件则描述领域事务中已经发生的业务事实。模型交互完成后,可以产生“模型动作已触发”;表单创建成功后,可以产生“表单已提交”;配置真正生效后,可以产生“模型配置已生效”。领域事件通常使用过去式,因为它描述的是已经发生且值得其他模块关注的事实。它并不是普通的组件事件总线,也不代表每个领域事务都必须发布事件;如果没有其他模块需要响应,直接由应用服务返回结果可能更简单。
防腐层不是上下文之间的必经之路
防腐层(Anti-Corruption Layer,ACL)位于两个限界上下文之间。它负责把外部上下文的接口、数据结构和业务术语,转换成本上下文能够理解的模型,避免本地代码被迫按照外部模型思考。这里的“防腐”不是说外部系统质量差,而是防止它的模型侵入并污染本上下文的语言和规则。
仍然以订单为例。面向应用展示的订单模型,可能只关心订单价格、创建人和商品列表,因为页面只需要把这些内容展示给用户。负责商家履约的上下文则可能更关心订单关联的商家、各商家的商品数量、备货状态和发货批次。同一个订单进入两个上下文后,会形成两种用途不同的模型:
订单查询上下文:订单编号、总价、创建人、商品列表
↓ 防腐层转换
商家履约上下文:商家编号、商品数量、备货状态、发货批次
防腐层可以接收查询上下文提供的订单数据,按商家拆分商品,并转换成履约上下文自己的对象。这样,履约规则只依赖“商家订单”和“备货项”等本地概念;即使上游把 creator 改成 buyer,或改变商品列表的传输结构,也只需要修改转换层,而不需要让整个履约模型跟着变化。
需要注意的是,如果这里只是同一上下文内部为了页面展示而选取订单的部分字段,那么它通常只是 DTO、ViewModel 或查询投影,不一定是防腐层。防腐层成立的前提是两边确实存在不同的模型和语言,而不只是应用层与领域层所需字段不同。
如果两个模块共享同一套模型、由同一个团队维护、没有独立演化需求,并且通过简单接口就能协作,那么强行增加转换对象和防腐层,可能只是在增加间接层。当前 Live2D 项目中的 service 大多仍处在同一个应用和同一套产品语义下,暂时没有明显的模型翻译需求,因此不必为了符合 DDD 的形式而给每次 service 调用增加防腐层。
我之前把“减少 RPC 耦合”当成 DDD 的目标之一,这个说法也需要修正。DDD 通过模型边界和集成契约减少的是语义耦合、模型泄漏和规则混乱。RPC 只是通信方式,使用 RPC 并不代表低耦合,不使用 RPC 也不代表边界清晰。
DDD 与微服务没有必然关系
DDD 可以用于微服务,但 DDD 不等于微服务。更合理的推导关系是:
领域分析 -> 子域划分 -> 限界上下文 -> 集成方式 -> 是否独立部署
是否拆分成微服务,还要考虑团队规模、部署成本、故障隔离、数据边界和运维能力。一个边界清晰的模块化单体,也可以很好地实践 DDD 的思想。
后端更常使用 DDD,并不是因为后端直接和数据库打交道,而是因为后端通常承载更复杂的业务规则、数据一致性和组织协作。前端当然也可能存在复杂领域,例如编辑器、流程设计器、权限系统和实时协作,只是普通单页面应用的主要复杂度往往来自 UI 状态、异步流程和副作用,这时完整套用 DDD 战术模式通常不划算。
前端应该选择性使用 DDD
前端不是不能使用 DDD,而是不应该把所有组件都改造成实体、聚合和领域服务。
更适合前端的做法是:
- 让 UI 组件负责展示和收集输入;
- 让应用服务或运行时负责协调流程;
- 把复杂规则和计算放进独立、可测试的领域逻辑;
- 把 IPC、文件、网络和系统 API 放进基础设施层;
- 让状态的所有权保持清晰;
- 只在真实复杂度出现时引入聚合、事件或上下文转换。
前端的副作用并不会否定领域建模。比较实用的方式是把“规则判断”和“副作用执行”分开:领域逻辑决定应该发生什么,应用层或基础设施层负责真正调用 React、MobX、Electron 或浏览器 API。
回到 Live2D 项目
在 live2dMurasame 中,我更愿意使用“模块化单体 + 选择性借鉴 DDD 思想”来描述当前架构,而不是说它已经实施了完整 DDD。
项目目前已经具备一些符合 DDD 精神的设计:
- Live2D、AI、控制面板和 Electron 有相对明确的职责;
- DI Container 作为组合根,统一创建和连接 service;
- 布局、几何和交互计算逐步从 UI 组件中抽离;
- Electron IPC、窗口操作和文件访问集中在基础设施层;
- SharedWorker 负责跨窗口状态同步,而不是让组件彼此直接维护状态。
从产品角度看,Live2D 和 AI 可以被看作核心子域,控制面板是支撑模块,Electron 是通用基础设施。这个划分可以帮助我们讨论职责和依赖,但不必把它进一步强行转换成多个微服务、多个聚合根或复杂的事件驱动系统。
最终理解
我现在对 DDD 的理解是:它的价值不在于规定一套固定目录,也不在于让代码看起来更“领域驱动”,而在于帮助我们识别业务模型、规则所有权和变化边界。
对于后端复杂系统,DDD 可以深入到限界上下文、聚合、领域事件和上下文映射。对于前端应用,则应该根据实际复杂度选择性使用。单页面、进程内调用和缓存并不会自动消除建模问题,但如果业务规则本身简单,确实没有必要为了 DDD 而增加抽象层。
因此,当前项目暂时不需要改造成严格的 DDD 架构。保留清晰的模块职责、明确的数据所有权、合理的依赖方向、可测试的纯逻辑、集中的基础设施副作用,以及由 DI Container 负责的应用组合,已经足够体现 DDD 的思想性价值。
DDD 更像是一种判断标准,而不是一张必须照着施工的建筑图。能否减少混乱、降低沟通成本、让规则找到合适的位置,才是采用它时真正应该验证的结果。


COMMENTS
留言
正在读取留言...