想象你是一位城市规划师,接手一座混乱的城市:商店、住宅、写字楼彼此重叠,路牌各写各的,居民间流传着不同版本的地图。新建一栋楼,总是牵一发动全身。领域驱动设计(DDD)就是一套城市治理的方法论:先通过统一语言让所有居民对同一个地名达成共识,再按限界上下文把城市划分为清晰街区,街区内以聚合为单元规划建筑,各街区通过领域事件广播消息,由领域服务处理跨部门协作。最终,复杂业务的软件系统像一座规划有序的城市,边界清晰、演进从容。下面我们沿着这条城市规划主线,从统一语言走到限界上下文,从实体值对象走到聚合根,从领域事件走到领域服务,再看战略设计与战术设计如何配合落地。
学习 DDD 能让你面对复杂业务时不再被需求淹没,而是快速找到模型的边界与核心,设计出可维护、可演进的软件架构——这是架构师与技术负责人的核心能力。
目录
可视化演示
展示领域驱动设计中限界上下文、聚合根与领域事件的架构关系
本节配有交互动效(进入视口后自动加载,约 3KB)。
限界上下文与映射关系 每个上下文拥有独立的领域模型与统一语言 上下文映射定义跨边界的通信协议 上下文A → 防腐层 → 上下文B 限界上下文是DDD战略设计的核心划分单元
统一语言:让团队说同一门方言
想象你作为城市规划师请来各地工人:泥瓦匠管“墙体”叫“山墙”,电气师傅称同一面墙叫“遮挡板”,物业则叫“结构面”。开工第三天,工人拆错了墙。DDD的第一步就是给这座“软件城市”立法:统一语言。业务专家、产品经理、后端工程师、前端工程师坐在一起,为每个业务概念定下一个唯一的名字,让它在所有讨论、文档、代码里保持一致。
软件项目最常见的慢性病是“一词多义”:业务方说“订单提交了”,开发代码里叫 createOrder,前端按钮写“立即购买”,测试用例叫“生成预订单”。四个名字指向同一件事,需求评审时谁都以为对方懂了。统一语言要求所有角色使用同一套术语,并且让领域模型直接承载这些术语——类名、方法名、状态名都与业务词汇一一对应。模型不再是画在文档里的示意图,而是团队沟通时可指认的“实物”。
统一语言不是一次会议就能固化的。随着业务深入,术语会演进,模型也随之调整。这是好事:模型越贴近业务,代码越能抵抗需求变化带来的冲击。团队成员在评审中说出一个词,大家脑海中浮现的是同一个业务规则,这才是统一语言真正生效的时刻。
就像城市立法统一路牌命名后,陌生人也能按图索骥;统一语言让新人加入团队时,不必再忍受“黑话”带来的认知摩擦。
- 术语多义数指一个业务词汇在不同角色口中被赋予的不同含义数量总和。
- 术语总数团队内正在使用的全部业务词汇个数。
- 比值趋近于 0分子越小,说明每个词只有一种公认含义,沟通成本随之降低。
限界上下文:城市里的街区边界
城市不会让住宅楼和核电站共用一张图纸。每一块土地都有明确用途,建筑规范只在本街区内生效。DDD 中的限界上下文正是这样的“城市规划分区”:它给模型划定一个显式边界,边界内部的概念定义明确,跨出边界则定义可能完全不同。
“用户”是理解限界上下文的最佳范例。在电商系统里,下单上下文中的“用户”指下单人,有收货地址和积分;商品上下文中的“用户”是浏览者,只关心价格和库存;客服上下文中的“用户”则是工单发起人,带满意度标签。同一个词,三种内涵。若强行复用一个 User 类,模型就会坍缩成一个大泥球。所以 DDD 拒绝全局统一模型,转而要求为每个上下文独立建模,再通过上下文映射定义它们之间的协作关系。
上下文映射中常用模式包括:防腐层在上下游之间加一道翻译缓冲,让下游模型不因上游接口变动而崩溃;共享内核则在关系紧密的团队间共享一小块稳定模型。基于这些手段,各街区的建筑规范互不污染,各上下文内部的模型可以放心演化。
街区各管各的范围,城市才不会乱;上下文各自维护自己的模型,系统才不会腐化成一滩泥。
- M_A限界上下文 A 中的完整领域模型,例如订单上下文里的订单、订单行、金额。
- M_B限界上下文 B 中的独立模型,例如库存上下文里的商品、库存量、库位。
- 交集为空两个模型不能因为“长得像”就强行合并,跨界协作一律通过上下文映射完成。
实体与值对象:看人还是看物
在小区里,居民和门牌号是两种截然不同的东西:居民有身份证,走到哪里都是同一个人;门牌号只是一串地址,A楼301换成B楼401,原来的数字立刻失去意义。DDD 的实体和值对象正是对这层差别的建模:实体拥有唯一身份标识,哪怕属性全部改变,它依然是它;值对象只由属性集合定义,两个属性完全相同的值对象可以互相替换。
身份标识是实体的灵魂。一个订单从“待支付”变为“已支付”,属性变了,但它仍是同一笔订单——决定权在 ID。值对象则相反,面额 100 元的钞票和另一张 100 元钞票被视为同一金额;地址“北京市朝阳区某路1号”换成内容完全相同的副本,等价性不受影响。正因如此,值对象应当设计为不可变:任何“修改”都意味着创建一个新对象,从根上杜绝别名共享导致的意外副作用。
在建模决策中,一个业务概念应建模为实体还是值对象,取决于它是否需要被追踪身份,而不是它的“复杂程度”。电话号码起初可建模为值对象,但如果你需要记录换号历史,它就需要升级为实体。判断标准很朴素:这个对象变了,业务上还算同一个东西吗?
小区保安认脸不认门牌,核心是“这个人还是不是这个人”;领域模型中,ID 就是那张脸。
- 实体相等性两个实体当且仅当 ID 相同时才被视为同一个对象,无论其他属性如何变化。
- 值对象相等性两个值对象当且仅当所有属性值都相等时才等价,不需要任何 ID。
- 建模取舍需要被跟踪、被引用、有生命周期的对象建模为实体;只承载描述性数据的对象建模为值对象。
聚合与聚合根:一栋楼的承重墙
城市建好了,还得让每栋楼结实。一栋楼不会让访客随意敲掉任意一面墙——所有进出都走大堂,由物业确认来意。DDD 的聚合就是这栋“业务楼宇”:它把关联紧密的一组实体和值对象圈在一个边界内,对外只暴露一个大门,这个大门就是聚合根。外部对象不能直接操作聚合内的子实体,只能通过聚合根的方法发起请求。
聚合存在的根本目的是维护一致性不变量。以订单聚合为例,业务规定“订单总额不得为负”“订单行不能为空”。如果允许外部直接修改订单行集合,这些规则就会散落各处、随时可能被破坏。聚合根将所有变更操作收口,在每个入口统一校验业务规则,把不变量牢牢锁在边界之内。
聚合边界同时划定了事务范围。一次业务操作通常只提交一个聚合,多个聚合之间通过领域事件实现最终一致,而不是用分布式大事务硬拧在一起。判断边界是否合适,可以问自己:如果我改了聚合内一个对象,是否必须同时改另一个?如果不是,它们就不该被圈进同一个聚合。边界过小,一致性被打破;边界过大,并发吞吐急剧下降——这正是建模经验最珍贵的部分。
楼宇没有承重墙就会散架,聚合没有根就会失去约束;聚合根就是那面承重墙,所有力量的传递都必须经过它。
- 成员 x聚合内部的实体与值对象,构成楼宇里的各个房间。
- 不变量 Inv业务规则,任何时刻都不允许被破坏,例如订单总额非负、订单行不可为空。
- 全称量词聚合根通过收口所有变更入口,保证每个成员上的不变量都成立。
领域事件:街头的广播站
城市里经常发生“大事件”:停水通知、道路封闭、商场开业。这些消息由城市广播站统一发布,各街区的商家听到后自行决定怎么响应——面馆提前备水,商场调整开业时间,无人需要逐个敲门通知。DDD 的领域事件正是这样一座广播站:当聚合发生重要的状态变化时,它把“某事已发生”作为一条消息发布出去,其他聚合或上下文通过订阅作出反应。
领域事件最大的价值是解耦。以“订单已支付”为例:订单聚合不直接调用支付上下文或库存上下文的方法,它只在自己状态流转完成后发布支付事件。支付上下文和库存上下文各自订阅该事件,分别执行通知用户、锁定库存、请求发票等动作。发布者不知道订阅者是谁,订阅者也不依赖发布者的内部实现,协作关系从硬编码调用变成了松耦合的消息流转。
这一机制进一步衍生出事件溯源的思想:既然每个状态变化都能表达为事件,那么聚合的当前状态就可以用“重放全部事件”来恢复。事件成为一等公民被持久化,为审计、回放、追溯提供了天然素材。当然,事件溯源会带来额外复杂度,适用于对可追溯性要求极高的场景,比如银行流水或审计系统。
广播站让城市信息流动有序,领域事件让系统在松耦合中保持业务联动——每个街区都专注自己的事,又都能对外界变化做出恰当反应。
- 状态变化聚合 A 发生了值得对外广播的业务事实,例如订单从待支付变为已支付。
- 事件对象变化被封装为领域事件,携带业务发生时必要的数据,例如订单号与支付时间。
- 订阅者动作聚合 B 订阅该事件后执行自己的业务动作,与 A 完全解耦。
领域服务:跨部门的市政服务
有些业务动作不属于任何一栋楼。比如“开设新的公交线路”,需要交通局、城管、街道办共同协商,单一部门内部无法完成。DDD 面对这类跨实体协作时,会设立领域服务:把一段承接业务规则但无法自然放入某个实体或值对象中的逻辑,提炼为一个独立的服务。
领域服务的关键特征是无状态:它不保存任何业务数据,只接收实体或值对象作为输入,执行协调逻辑后返回结果。典型场景如银行转账:扣款发生在转出账户,入账发生在转入账户,两条规则都无法只归结到某一个账户实体上。此时可以设计一个转账服务,让它读取两个账户聚合根,校验余额与限额,执行扣款与入账操作,再发布领域事件记录转账动作。
领域服务必须与应用服务区分开。应用服务是面向用例的协调者,它负责事务边界、权限校验、调用领域服务或聚合方法,但本身不含业务规则;领域服务才是业务规则的载体。简单说,应用服务像前台接待,知道“该找谁办事”;领域服务像窗口办事员,真正执行“政策与法规”。
市政服务大厅不生产商品,却让城市运转有序;领域服务不拥有状态,却让领域规则落地有声。
- 领域服务 S_D无状态的业务规则执行组件,归属于领域层。
- 跨实体输入服务接收两个或多个实体作为协作对象,体现“跨聚合”的业务编排。
- 业务结果 R返回结果通常是值对象或领域模型,而不是裸数据或数据库记录。
战略与战术设计:从总体规划到施工图
城市规划分两个尺度:总规图纸确定城市的功能分区、交通骨架、各区块边界;施工图纸确定每栋楼的承重墙、门窗、管线细节。DDD 同样有一对孪生方法论:战略设计与战术设计。
战略设计回答“系统边界在哪里、模型之间如何协作”,核心产出包括统一语言、限界上下文、上下文映射。它站在宏观视角,把庞大业务切分成有清晰边界的子域,让团队围绕各自边界独立演进。战略设计的失误会像城市把住宅区建在化工园旁边一样,后期几乎无法补救。因此战略设计必须由领域专家与架构师共同完成,而不是让开发各自为政。
战术设计回答“边界内的模型如何落地为代码”,核心构件是实体、值对象、聚合、领域服务、领域事件。它站在微观视角,为每个限界上下文设计具体的类结构与行为规则。战略设计先定边界,战术设计再填充细节——两者是指导与反馈的关系:战略边界约束战术构件,战术实现又反向验证战略决策是否合理。模型驱动开发强调的正是这种从模型到实现的强映射:代码结构尽量与模型结构一一对应,让模型直接翻译成可维护的软件。
总规画错,再好的施工图也救不了一栋楼;战略边界错误,再精致的聚合根也会成为空中楼阁。
- 战略设计产物限界上下文与上下文映射,划定系统边界与协作方式。
- 蕴含关系战略决策为战术实现提供约束与语境,不是可选的参考。
- 战术设计产物聚合、实体、值对象在代码中落地,构成可执行模型。
DDD落地实践:让图纸变成真实建筑
图纸再完美,施工队不会“看图说话”也白搭。DDD 的落地同样依赖流程、协作与反馈。团队需要业务专家深度参与建模,而不是开发关起门来画类图。事件风暴就是一种广受欢迎的协作建模方式:业务专家与开发围坐一起,用便签贴出领域事件、命令、聚合,统一语言在讨论中自然涌现,限界上下文在碰撞中逐渐清晰。
落地时,代码结构应遵循模型结构。典型做法是使用分层架构:接口层处理用户输入输出,应用层编排用例流程,领域层承载实体、值对象、聚合、领域服务、领域事件,基础设施层负责数据库与消息队列等技术细节。领域层是整个架构的内核,不依赖任何外部框架——这正是“模型驱动实现”的代码化体现。
演进式设计同样重要。业务规则会变,模型必然要跟着变。每一次需求变化都是重构模型的信号:识别新的领域事件,调整聚合边界,合并或拆分限界上下文。DDD 不是一次性蓝图交付,而是持续演化的施工过程——城市永远在长,图纸永远在改。落地成功的关键,在于团队是否建立了“模型—代码—业务”三方持续对话的机制。
好建筑是在使用中不断翻新而历久弥新的;好模型是在反馈中不断演进而保持生命力的。DDD 不是终点,而是一套让软件城市持续生长的规划方法。
- 当前模型 M现阶段的领域模型,代表团队对业务的最新理解。
- 代码实现 C模型通过分层架构映射到可运行代码,领域层保持独立。
- 业务反馈 F新需求与线上问题构成反馈信号,推动模型审视。
- 新模型 M'模型在反馈中演进,开启下一轮设计循环。
AI 应用场景
电商订单系统的领域建模
阶段:业务分析与领域建模
解决的问题:传统做法中订单分布在订单、支付、库存多张表里,业务规则散落在 service 方法中,需求一变更就牵连多个模块。用 DDD 先建立统一语言,划分订单、支付、库存三个限界上下文,定义订单聚合根与“订单已支付”领域事件,让系统组件按业务边界解耦。
具体案例:某电商平台重构下单链路,先与业务专家做事件风暴,确定统一语言;再将订单设为聚合根,支付成功后发布领域事件,仓储服务订阅后锁库存。改造后,原本 200 多行 if-else 的下单流程变成各组件清晰协作,平均响应时间下降 40%。
银行转账场景的战术设计
阶段:系统重构与核心逻辑建模
解决的问题:跨账户转账逻辑既不属于转出账户也不属于转入账户,传统方案写在应用层导致业务规则下沉或重复。用领域服务承载转账规则,用金额值对象保证币种一致与计算安全。
具体案例:某银行核心系统重构,将转账封装为转账领域服务,账户建模为账户聚合根,金额建模为不可变值对象。在转账重试的并发场景下,不再出现金额不一致问题;新接入理财账户时,只需新增订阅者监听“转账成功”事件,无需改动转账主流程。
Python 演示
from __future__ import annotationsfrom dataclasses import dataclass, fieldfrom enum import Enum, autofrom typing import List, Dict, Callable class DomainEvent: """Base class for domain events.""" pass class OrderStatus(Enum): CREATED = auto() PAID = auto() CANCELLED = auto() @dataclass(frozen=True)class Money: """Value object: immutable amount with currency.""" amount: float currency: str = "CNY" def add(self, other: "Money") -> "Money": if self.currency != other.currency: raise ValueError("currency mismatch") return Money(self.amount + other.amount, self.currency) @dataclassclass OrderLine: """Entity inside the aggregate.""" product_id: str quantity: int price: Money @dataclassclass OrderPaidEvent(DomainEvent): order_id: str @dataclassclass Order: """Aggregate root of the order aggregate.""" id: str customer_id: str lines: List[OrderLine] = field(default_factory=list) status: OrderStatus = OrderStatus.CREATED _events: List[DomainEvent] = field(default_factory=list) def add_line(self, product_id: str, quantity: int, price: Money) -> None: if quantity <= 0: raise ValueError("quantity must be positive") self.lines.append(OrderLine(product_id, quantity, price)) def total(self) -> Money: result = Money(0) for line in self.lines: result = result.add(Money(line.price.amount * line.quantity, line.price.currency)) return result def mark_paid(self) -> None: if self.status != OrderStatus.CREATED: raise ValueError("only created order can be paid") self.status = OrderStatus.PAID self._events.append(OrderPaidEvent(self.id)) def collect_events(self) -> List[DomainEvent]: events = list(self._events) self._events.clear() return events class EventBus: def __init__(self) -> None: self._handlers: Dict[type, List[Callable]] = {} def subscribe(self, event_type: type, handler: Callable) -> None: self._handlers.setdefault(event_type, []).append(handler) def publish(self, events: List[DomainEvent]) -> None: for event in events: for handler in self._handlers.get(type(event), []): handler(event) def on_order_paid(event: OrderPaidEvent) -> None: print(f"[EventBus] order {event.order_id} paid, notify warehouse to ship") if __name__ == "__main__": bus = EventBus() bus.subscribe(OrderPaidEvent, on_order_paid) order = Order(id="A001", customer_id="user-42") order.add_line("P1001", 2, Money(199.0)) order.add_line("P1002", 1, Money(99.0)) print(f"order total: {order.total().amount} {order.total().currency}") order.mark_paid() bus.publish(order.collect_events())
@dataclass(frozen=True)冻结存储让 Money 不可变,值对象不能局部修改,只能整体替换。class Order:这是聚合根,所有订单行变更都必须通过它的方法收口。def add_line(self, ...)聚合根统一校验数量为正,保证聚合内不变量。def mark_paid(self)业务规则收口:只有“已创建”状态的订单才能支付。self._events.append(OrderPaidEvent(self.id))状态变化时发布领域事件,记录业务事实。order.collect_events()应用层事务边界内一次性取出并清空事件,再交给总线发布。bus.subscribe(OrderPaidEvent, on_order_paid)订阅方与发布方彻底解耦,仓库物流只认事件不认订单内部结构。
常见误区
误区我刚学 DDD 时总以为实体对应一张表、聚合根对应主表,值对象对应字段。写来写去,还是把 DDD 当成高级数据库建模课。
正解DDD 是从业务规则出发确认模型,持久化只是模型的落点之一。值对象往往内嵌在实体表里,聚合则可能是多张表的组合,甚至一个聚合根本不落库而只靠事件重建。如果先想表再想模型,等于把施工图当成了城市规划图,顺序反了。
为何常见:大多数开发者最熟悉的是数据建模,下意识把“模型”等同于“表结构”,忽略了 DDD 以业务语言为起点的本质。
误区我把订单行、地址、商品都做成聚合根,认为这样最灵活。结果系统中到处都是小聚合,改一个订单要跨五六个聚合,事务散落各处。
正解聚合根是聚合的边界入口,太多聚合根会让一致性约束破碎。正确做法是按业务不变式聚类:订单和订单行在同一事务内必须一致,就放进同一个聚合,由订单根统一管理。聚合粒度是设计决策,不是每个类一张入场券。
为何常见:只学了“聚合根”这个名词,没有理解它背后的“一致性边界”含义,容易把聚合根当标签到处贴。
误区值对象不就是“数据袋子”吗?金额、地址、电话号码这些,直接用 String 或 BigDecimal 就好了,何必专门建模。
正解值对象承载大量业务约束。例如金额必须带币种,两个不同币种的金额相加应直接报错;电话号码需要校验格式;日期区间需要判断重叠。忽略值对象,这些规则就会泄漏到应用层、工具类甚至各个调用方,重现散落一地。
为何常见:值对象初看“只是数据”,看不见其中隐含的业务规则,容易被降级为基本类型。
误区我们团队还在做单体应用,所以 DDD 用不上。等哪天微服务了再学吧。
正解DDD 是建模方法论,与部署形态无关。单体应用同样可以用限界上下文切分模块边界,用聚合约束一致性,用领域事件解耦内部模块;微服务也完全可以做出一个大泥球。DDD 和微服务都强调“边界”,但前者是思想,后者是部署单元。
为何常见:DDD 常与微服务一同被提起,媒体案例多是微服务架构,导致两者被概念绑定。
面试题
Q1. 在领域驱动设计中,下列哪类对象拥有唯一身份标识?
- A.值对象类型
- B.实体类型← 正确答案
- C.事件类型
- D.服务类型
答案:B。实体通过唯一身份标识(ID)保持同一性,哪怕属性全变也仍是同一个对象。值对象靠属性集合等价,领域事件记录事实,领域服务无状态,三者都不需要身份标识。就像居民靠身份证识别,而门牌号只是地址描述。
Q2. 值对象与实体的本质区别是什么?
- A.值对象没有任何内部状态
- B.值对象只需属性无需身份← 正确答案
- C.实体只存在于关系数据库
- D.值对象不能参与业务逻辑
答案:B。值对象的同一性完全由它的属性值决定,不需要 ID;实体则需要身份追踪。值对象有状态且可参与业务逻辑(如金额计算),实体也不是数据库专属概念。区别核心在于“是否需要身份识别”。
Q3. 聚合根在聚合中的核心作用是什么?
- A.提升列表查询的性能
- B.唯一入口并维护不变量← 正确答案
- C.负责对象序列化的规则
- D.决定数据库索引的字段
答案:B。聚合根是聚合内唯一允许外部访问的对象,所有变更请求都必须经过它,由它统一维护聚合内的一致性不变量。查询性能、序列化、索引都属于技术关注点,不是聚合根的设计职责。
Q4. 战术设计包括哪些建模要素?
- A.统一语言维护
- B.值对象设计← 正确答案
- C.聚合与聚合根← 正确答案
- D.上下文映射
答案:BC。战术设计回答“模型如何落地为代码”,其要素包括实体、值对象、聚合与聚合根、领域服务、领域事件等。统一语言和上下文映射属于战略设计层面,负责划定系统边界与协作方式。
Q5. 限界上下文间需要防上游变化影响下游时,优先采用哪种保护模式?
- A.共享内核模式
- B.防腐层模式← 正确答案
- C.事件风暴模式
- D.分层架构模式
答案:B。防腐层在下游模型与上游接口之间增加翻译缓冲,将上游的模型转换降级为下游的概念,从而隔离变化冲击。共享内核适合紧密合作的团队共享小范围稳定模型;事件风暴是建模方法;分层架构是单体内部结构,都不是专门的跨上下文保护手段。
Q6. 领域服务与应用服务的关键区别是什么?
- A.领域服务实现领域规则← 正确答案
- B.应用服务只负责数据库
- C.两者职责完全相同
- D.领域服务属于前端层
答案:A。领域服务承载的是无法归属到单一实体的领域业务规则,属于领域层;应用服务负责用例编排、事务边界和权限校验,属于应用层。应用服务不负责数据库,领域服务也不在前端。
Q7. 关于统一语言,下列理解正确的有?
- A.由业务专家与开发团队共建← 正确答案
- B.仅存在于会议口头沟通中
- C.与模型同步演进贯穿代码← 正确答案
- D.由架构师个人独立制定
答案:AC。统一语言是业务专家与开发团队共同维护的术语体系,只有双方共建才能确保模型承载真实业务含义。它不是会议记录,必须同步演进并映射到代码中的类名、方法名和测试用例。由架构师单方面制定会脱离业务实际。
Q8. 事件溯源中聚合状态如何恢复?
- A.读取数据库最新快照
- B.重放该聚合完整事件流← 正确答案
- C.从外部系统同步数据
- D.根据应用日志推断状态
答案:B。事件溯源将每次状态变化都保存为领域事件,聚合当前状态由重放完整事件流得到。数据库快照、外部同步或日志推断都不是事件溯源机制的核心——事件流本身才是唯一事实来源。
Q9. 关于值对象设计,下列推荐做法是?
- A.设计为不可变类型← 正确答案
- B.用全部属性判断相等← 正确答案
- C.为每个值对象生成唯一ID
- D.值对象包含生命周期状态
答案:AB。值对象应设计为不可变,修改即替换;相等性由全部属性值决定,无需 ID。值对象没有生命周期,不能有状态流转;生成唯一 ID 是实体的特征,硬加给值对象会破坏其语义。
Q10. 聚合设计争论“小聚合”与“大聚合”核心权衡是什么?
- A.存储介质如何选择
- B.事务边界与一致性的权衡← 正确答案
- C.依赖前端加载的性能
- D.微服务部署数量规模
答案:B。聚合大小本质上是事务边界与一致性之间的取舍。大聚合能保证强一致性,但并发冲突多、吞吐受限;小聚合并发友好,但一致性会被削弱,需要靠领域事件达到最终一致。存储介质、前端性能、部署数量都与聚合粒度设计的本质无关。