周五下午,运营在群里发了一句话:"下个版本要支持优惠券,另外爆款商品要走预售——先付定金,尾款开售再补。"
你打开代码库,心凉了半截。下单逻辑在一个两千行的
OrderService 里,优惠券要改价格计算,可价格计算散落在订单、支付、库存三处;预售意味着订单多了一种"等尾款"的状态,可状态判断的 if-else 补丁已经叠了七八层,分布在四个类里;履约那边更麻烦——仓储物流商只认"已支付"这一个信号,预售订单到底什么时候通知发货,没人说得清。你改完订单改支付,改完支付发现库存的扣减时机也错了。一个需求,牵动四个模块,每一处改动都可能碰坏另一处。(这是一个虚构的演示场景,但做过几年业务系统的工程师多半会觉得眼熟。)
问题出在哪?不是团队不努力,也不是框架不够好。Eric Evans 在 2003 年提出领域驱动设计(Domain-Driven Design,DDD)时给出的诊断是:代码失控的根源,是软件模型与业务本身脱节——业务在演进,代码里的模型却没有跟着演进,于是每个新需求都只能靠打补丁硬塞进去。DDD 就是一套让模型重新跟上业务的方法。
复杂业务的代码失控,根源不是技术选型,而是模型与业务脱节。DDD 的全部实践——通用语言、限界上下文、聚合、领域事件——都在回答同一个问题:如何让代码里的模型始终贴近业务。
本文用一个贯穿始终的"下单到履约"场景把这套方法走完一遍:买家在小型电商平台下单,商家运营管理订单,外部支付网关负责收款,仓储物流商负责发货。你会看到每个 DDD 概念各自解决哪一种失败,以及——同样重要的——什么时候不该用它。
为什么业务一复杂,代码就失控?
先看清楚敌人。大多数后端系统的默认结构是技术分层:Controller 接请求,Service 写逻辑,DAO(数据访问对象)存数据。这种分法按技术职责切分代码,对业务一无所知。于是业务规则没有自己的家:下单要校验库存,校验写在
OrderService 里;支付要校验金额,又在 PaymentService 里写一遍;后来运营后台也要改订单状态,第三份校验出现在另一个角落。规则散落各处,改一处漏两处,bug 就是这么来的。这里要先定义两个词。领域(Domain)是软件所服务的那个业务问题本身——在这个场景里,就是"下单到履约"这摊生意。领域模型(Domain Model)是团队对这摊生意的抽象:哪些概念存在(订单、支付、发货单),它们有什么关系,遵守什么规则。DDD 的主张一句话可以说完:把领域模型放在软件的中心,让代码结构反映业务结构,而不是反映技术分层。
与技术分层伴生的常见病,Martin Fowler 称之为贫血模型(Anemic Domain Model):
Order 类里只有字段和 getter/setter,没有任何行为,所有业务规则都写在外面的 Service 里。这样的"模型"只是数据库表的影子,它不保护任何业务规则——谁都可以绕过校验直接改字段。贫血模型本身不是罪,简单的增删改查(CRUD)系统用它完全没问题;但在规则复杂的领域,它就是大泥球(Big Ball of Mud,边界混乱、概念互相渗透的系统状态)的入口。团队如何说同一种语言?
回到场景。买家说"下单",运营说"审单",仓储物流商说"拣货",而代码里这些动作叫
processOrder、checkOrder、doStep3。每开一次需求会,业务术语都要先被翻译成技术术语,翻译就有失真——运营说的"预售订单"和开发理解的"预售订单"可能根本不是一回事,等到上线才发现。DDD 的第一味药是通用语言(Ubiquitous Language):团队——包括领域专家和开发者——共享同一套业务语言,并且把这套语言原样写进代码。类名、方法名、事件名都用业务词汇,代码本身就成为最准确的业务文档。
通用语言不是开会定出来的词表,而是在反复碰撞中"消化"出来的。Evans 把这个过程称为知识消化(Knowledge Crunching):开发者与领域专家一起迭代提炼模型,用具体场景不断测试语言是否准确。它不是一次性的需求评审,而是贯穿项目始终的持续学习。一段典型的碰撞长这样(虚构演示):
开发:"买家提交订单后,我们调用submitOrder。" 运营:"不对,预售订单不是'提交',是'预约'——买家先付定金锁库存,尾款付清了订单才算成立。" 开发:"那模型里'预约'和'提交'必须是两个不同的动作,状态机也不一样。" 运营:"对,而且预约超时未付尾款,定金不退。"
注意最后一句话:一条重要的业务规则(定金不退)在对话中浮出水面,并且立刻有了代码里的归宿。这就是知识消化的价值——模型在对话中被测试、被修正,而不是在上线后被 bug 修正。
两个边界要说清。第一,通用语言需要领域专家真实、持续地参与;没有专家参与,团队消化出来的只是自己想象中的业务。第二,通用语言只在一个边界内有效——"订单"在订单团队的语言里和在支付团队的语言里,可以合法地是两个东西。这正是下一节的问题。
系统太大,该从哪里下刀?
语言对齐了,但系统还是一个整体:订单、支付、履约、商品目录全在一个代码库里互相调用。业务太大,一个模型装不下,必须拆。问题是:按什么下刀?
DDD 的第一刀按业务价值切,把领域分成三类子域(Subdomain):
子域类型 | 业务价值 | 本场景的例子 | 投入策略 |
核心子域(Core Domain) | 差异化竞争力的来源 | 订单:定价、促销、下单规则 | 最强的团队,精耕模型 |
支撑子域(Supporting Subdomain) | 支撑核心业务,但本身无差异化 | 支付对接、履约调度 | 够用即可,不必精雕细琢 |
通用子域(Generic Subdomain) | 行业通用能力,别家也一样 | 商品目录 | 优先采购或用现成方案 |
子域分类的直接后果是资源投向:把最好的工程师和最多的建模精力投给核心子域,而不是平均用力。你的平台不会因为商品目录做得比别人好而赢,但会因为下单规则灵活、促销上线快而赢。
第二刀更关键。注意一个现象:同一个词"订单",在订单团队那里是一个含商品行、状态机、优惠计算的复杂对象;在支付团队那里只是"一个 ID 加一笔金额";在仓储物流商那里则是"一张拣货单"。如果强行用一个
Order 类服务所有人,它就会变成塞满互相矛盾字段的怪物。DDD 的解法是限界上下文(Bounded Context):为每个模型划定一个明确的生效边界,边界内模型和通用语言一致且自洽,边界外不作任何假设。于是订单上下文里的
Order 和支付上下文里的 Order 是两个类,各长各的,谁也不污染谁。一个必须说清的边界:限界上下文划错比不划更糟。边界一旦划定,跨边界的协作都要走显式的集成方式(下一节),划错了等于给错误的拆分加上昂贵的接缝。另外,限界上下文不等于微服务——它是模型的边界,微服务只是它的一种部署落地;一个限界上下文完全可以活在一个单体应用的模块里。后文「什么时候该用 DDD」一节的真实案例会回到这一点。
拆开的上下文如何协作?
拆完不能各过各的:下单要调支付,支付完要通知履约,履约要查商品目录。上下文之间必须集成,而集成正是耦合重新渗入的通道。上下文映射(Context Map)就是把这些集成关系显式画出来、显式选择模式的实践——它回答"谁依赖谁、用什么方式翻译、出问题找谁"。
先看本场景的映射图,再逐个解释模式:
三条边是三种刻意不同的选择:
- 订单 → 支付:防腐层(Anti-Corruption Layer,ACL)。支付上下文封装着外部支付网关,而网关的模型——"交易流水""渠道单号""异步回调"——是别人的语言,而且随时可能变。订单上下文不直接说这套话,而是在边界上建一层翻译:网关的"交易流水"进来,被译成订单语言里的"支付单";订单的"发起收款"出去,被译成网关的接口调用。ACL 保护的是本上下文模型的纯粹性,网关改版时,改动被挡在翻译层里。(这里聚焦订单一侧的翻译;支付上下文对外部网关的适配是同一手法的复用。)代价是多一层代码;如果集成很简单、上游模型也干净,用遵奉者模式直接采用上游模型反而更划算。
- 订单 → 履约:发布语言(Published Language)+ 客户/供应商(Customer/Supplier)。履约是下游,它向订单上下文提需求("我要收货地址和商品行"),订单作为上游按约定排期满足;双方交换的数据格式被文档化为一份共享的发布语言,谁都可以按文档解析,但不共享彼此的内部模型。
- 目录 → 订单:开放主机服务(Open-Host Service,OHS)。商品目录要服务的下游不止订单一个(还有搜索、推荐),于是它定义一套公开、稳定的协议,所有下游按这套协议接入,目录不为任何单一下游定制。
完整的模式清单有九个。ddd-crew 社区还把它们按团队关系分了类——相互依赖、上游—下游、自由——集成模式从来不只是技术决策,也是团队协作方式的决策:
模式 | 含义 | 团队关系 | 本场景 |
合作关系(Partnership) | 双方共享目标,接口共同设计,发布互相协调 | 相互依赖 | — |
共享内核(Shared Kernel) | 共享一小部分模型与代码,改动需双方同意 | 相互依赖 | — |
客户/供应商(Customer/Supplier) | 下游提需求,上游排期满足,接口按合同演进 | 上游—下游 | 订单 → 履约 |
遵奉者(Conformist) | 下游无条件采用上游模型,放弃翻译 | 上游—下游 | 简单集成时可用 |
防腐层(Anti-Corruption Layer,ACL) | 下游自建翻译层,把上游模型挡在边界外 | 上游—下游 | 订单 → 支付 |
开放主机服务(Open-Host Service,OHS) | 上游定义公开协议,供多个下游统一接入 | 上游—下游 | 目录 → 订单 |
发布语言(Published Language) | 双方交换的数据格式被文档化,成为公共语言 | 上游—下游 | 订单 → 履约的事件载荷 |
各行其道(Separate Ways) | 刻意不集成,各自实现,避免无谓的耦合 | 自由 | — |
大泥球(Big Ball of Mud) | 边界混乱、模型互相渗透的系统状态——这是反模式标记,不是集成模式 | — | 要避免的结局 |
最后一行要单独强调:大泥球不是第九种集成方式,而是给"没有映射的系统"贴的标签。画上下文映射时把它标出来,是在承认"这部分系统已经烂了,别再往里加新依赖"。
一个上下文内部,谁来守住业务规则?
边界划好了,镜头推进到订单上下文内部。这里的核心问题是:业务规则——空订单不能提交、已支付的订单不能改商品、预售订单未付尾款不能发货——由谁来保证不被违反?
先分清两种建模对象。实体(Entity)是有身份的对象:两个订单哪怕内容一模一样,也是两笔订单,因为 ID 不同;它的属性会随业务变化,身份不变。值对象(Value Object)没有身份,由属性值整体定义:两个"100 元人民币"没有任何区别,可以互换;它是不可变的,要"改"就换一个新的。
维度 | 实体(Entity) | 值对象(Value Object) |
判据 | 有身份标识,属性可变而身份不变 | 无身份,由属性值整体定义 |
可变性 | 随业务演进改变状态 | 不可变,修改即整体替换 |
相等性 | 按 ID 判断 | 按全部属性值判断 |
本场景例子 | Order、OrderItem | Money、Address |
建模选择 | 仅当必须跟踪身份时才用实体 | 默认优先——能值对象就不实体 |
最后一行是实操中最常被问的问题:"这个该建成实体还是值对象?"Microsoft 的架构指南给出的默认策略是:优先值对象,因为它不可变、可共享、没有一致性负担;只有当业务上必须区分"这一个"和"那一个"(必须跟踪身份)时,才提升为实体。
细节:值对象存进数据库时为什么也有主键?
值对象在领域里没有身份,但很多持久化方案(如 ORM(对象关系映射)框架的从表)会要求每行有主键。Vaughn Vernon 在《实现领域驱动设计》中把这类主键称为"存储身份"——它是数据库的实现细节,不是领域身份,不应泄露到领域模型里:实体不引用它,比较相等性时也不看它。注意"存储身份"是 Vernon 的实践词汇,不是 Evans 原书的正典术语;团队沟通时说明出处可以避免概念混用。
回到规则守护的问题。订单的规则大多横跨多个对象:"订单总额必须等于各商品行金额之和"涉及
Order 和全部 OrderItem;"已支付不可改商品"涉及 Order 的状态和它的商品行。这类跨对象的规则叫不变量(Invariant)——无论发生什么操作都必须成立的条件。如果允许外部代码随意 new 一个 OrderItem 塞进订单,或者绕过方法直接改状态字段,不变量就形同虚设。DDD 的解法是聚合(Aggregate):把一组强相关的实体和值对象圈成一个一致性单元,并指定其中一个实体为聚合根(Aggregate Root)——外部代码只能通过聚合根操作整个聚合,聚合内部的对象不允许被外部直接持有和修改。在订单上下文里,边界这样划:
注意边界外的两个对象:
Payment 和 Product 也是聚合,但 Order 只持有它们的 ID,不持有对象本身。这带来两个直接好处:加载订单时不必拖出整个支付和商品的对象图;更重要的是,它强制每次修改只落在一个聚合上——跨聚合的一致性不靠对象引用,靠下一节讲的领域事件。用一段最小代码演示聚合根如何守住不变量(仅作演示,完整可运行的 TypeScript 实现见文末的配套文章):
每一行都对应一条不变量:
items 和 status 是私有的,外部无法绕过方法直接改——这是"聚合根是唯一入口"在语言级的落实;addItem 先查状态,把"提交后不可改商品"钉死在代码里;submit 拒绝空订单;total 的金额计算收在聚合内部,规则只有一个家。外部代码想违反规则,只能不编译。聚合的边界同样要说清:聚合只保证单个聚合在一次事务内的强一致。它不管、也管不了跨聚合、跨上下文的一致性——那是领域事件的职责。把聚合划得巨大无比来"保证一致"是常见误用,结果只是锁竞争和性能灾难。
还有一类规则无处安放:"扣减库存并锁定优惠券"既不完全属于订单,也不完全属于库存——这种跨聚合的领域逻辑放进领域服务(Domain Service):一个无状态、只表达领域操作的对象。别把它和应用服务(Application Service)混淆:应用服务只做编排——加载聚合、调用领域逻辑、提交事务、发消息——它本身不含任何业务规则。判断标准很简单:这句话删掉,业务规则会不会被违反?会,就是领域逻辑;不会,就是编排。
边界警告:聚合和实体建好了,业务逻辑却继续写在外面的 Service 里,实体退化成只有字段的贫血模型(Anemic Domain Model)——这是 Fowler 点名的经典反模式,也是战术层面最常见的失败。检验方法:打开你的实体类,如果里面只有 getter 和 setter,那么本节讲的不变量保护就从未真正生效。
跨聚合、跨上下文的一致性怎么办?
支付成功了,履约必须开始拣货发货。但订单上下文和履约上下文是两个边界,不能共用一个数据库事务——强行共用,等于把刚拆开的系统又焊了回去。
DDD 的答案是领域事件(Domain Event):已经发生、且领域专家关心的业务事实。注意是"事实"不是"指令"——
OrderPaid(订单已支付)陈述一件过去的事,谁关心谁订阅;订单上下文不需要知道履约的存在。时序如下:最后两步之间有一个刻意保留的间隙:订单状态先落库,事件随后投递,履约晚一点收到消息。这就是最终一致性(Eventual Consistency)——系统保证业务最终正确,但不保证同一时刻处处一致。对"支付完晚 200 毫秒才开始拣货",业务完全可接受;换来的收益是上下文之间只剩事件这一条窄缝,订单上下文挂了,履约照常运转。
代价也要说清:你引入了消息基础设施的复杂度(投递可靠性、重复消费、顺序问题),以及一个需要向运营解释的一致性窗口("买家已付款但仓库还没看到单"的那几秒)。事件不是免费的解耦。
扩展:怎么系统地发现这些领域事件?(EventStorming 简介)
事件风暴(EventStorming)是 Alberto Brandolini 提出的协作工作坊:把领域专家、开发、测试拉到同一面墙前,用便签按时间轴贴出所有领域事件("订单已提交""支付已完成""发货单已创建"),再反向追问每个事件的触发者、规则和参与者。它以领域事件为发现媒介,往往几个小时就能暴露在文档评审中藏了几个月的歧义——比如本文场景里"预售订单何时算成立"。EventStorming 是发现工具,不是建模产出物本身;工作坊的结论仍要沉淀为通用语言和限界上下文。
最后一块拼图:聚合要存取。仓储(Repository)是聚合的持久化入口,原则只有一条:每个聚合根一个仓储,按聚合整体加载、整体保存。Microsoft 的持久化设计指南把反例说得很直白——绝不要为每张表建一个仓储。理由是聚合根维护整个聚合的事务一致性:如果
OrderItem 有自己的仓储,外部代码就能绕过 Order 直接改商品行,上一节建的不变量防线会从持久化层被抄了后路。一个补充边界:仓储服务的是"按 ID 加载聚合去改它"的写路径;查询密集型场景(列表页、报表)不该硬穿聚合,需要另设读模型——这是 CQRS(Command Query Responsibility Segregation,命令查询职责分离)的地界,本文只标记边界,不展开。什么时候该用 DDD,什么时候是过度设计?
走完整个链路,回到最实际的问题:你的项目该不该上 DDD?
DDD 的回报与领域复杂性成正比。Fowler 和 Microsoft 的指南态度一致:它特别适合业务规则复杂、且规则频繁变化的领域;反过来,一个以增删改查为主、业务逻辑就是"存进去读出来"的系统,上 DDD 是纯粹的过度设计——你为不存在的问题支付了全部建模成本。Khononov 在《Learning Domain-Driven Design》里还补了一刀:战略层面最常见的失败不是"没用 DDD",而是跳过战略设计直接套战术模式——不划限界上下文、不做上下文映射,直接给每个类贴上"聚合""值对象"的标签,得到的是一个用词昂贵的贫血模型。
动手前,用这份清单自测:
业务规则多到没人能一次说全,而且每个版本都在变?
有真正的领域专家(运营、业务方)愿意持续参与建模讨论?
系统里已经出现"同一个词在不同模块含义不同"的现象?
团队愿意为模型和边界投入时间,而不是只赶需求排期?
如果大部分答案是"否"、业务以 CRUD(增删改查)为主——那就别用 DDD,简单的分层架构对你更好。
最后一对真实案例,用来钉死一个流传最广的误解——"DDD 就是微服务":
Uber:DDD 启发的微服务分组
Uber 在 2200 多个微服务的规模下推出 DOMA(Domain-Oriented Microservice Architecture),官方措辞是"深度借鉴领域驱动设计等成熟方法"(draws heavily from … DDD):按领域把微服务分组,为每组定义明确的网关与契约。它证明 DDD 的战略思想可以治理超大规模的微服务丛林。
Shopify:用领域边界守住一个单体
Shopify 没有拆微服务,而是按业务领域(订单、发货、库存、账单)重新组织代码、强制组件间的边界——这套边界划分与限界上下文同构——并刻意选择了模块化单体(Modular Monolith):边界划在模块之间,部署仍是一个应用。它证明清晰领域边界的价值不依赖微服务——边界是模型决策,不是部署决策。
两家公司,相反的技术选型,却共享同一种思想:按领域边界组织代码。结论:DDD 回答的是"边界划在哪",微服务回答的是"进程怎么拆",前者不蕴含后者。
回头看这条链:买家、运营、支付网关、物流商各说各话,于是用通用语言和知识消化消除翻译失真;业务太大,于是按价值拆子域、用限界上下文圈住模型;拆开的上下文要协作,于是用上下文映射显式管理集成、用防腐层挡住外部模型的污染;上下文内部的规则要有人守,于是聚合根成为唯一入口,把不变量钉死在代码里;跨边界不能共用事务,于是领域事件加最终一致性把上下文连成完整的业务链路;聚合要落地,于是仓储按聚合根整体存取,守住最后一道防线。每一步都对应一种具体的失败——这就是 DDD 和"概念列表"的区别:它不是模式目录,而是一条因果链。
想动手的话,站内有两份配套材料:《深入浅出领域驱动设计(DDD):从理论到实践》是本文的配套代码实现,用完整的 TypeScript 电商订单代码把聚合、仓储、事件总线落地;DDD 交互演示是配套的交互式讲解页面,用可视化方式演示这些概念。
延伸阅读
- Eric Evans《Domain-Driven Design Reference》:Evans 官方的免费模式速查手册,术语的权威出处
- Evans 原书第 1 章免费 PDF:知识消化与通用语言的原始论述(原书 2003 年出版)
- Vernon《实现领域驱动设计》:战术设计最系统的落地指南,聚合设计原则的出处
- Khononov《Learning Domain-Driven Design》(O'Reilly 2021):现代视角的入门书,对"什么时候别用 DDD"讲得最坦率
- Martin Fowler:BoundedContext、UbiquitousLanguage、AnemicDomainModel:三篇短而准的 bliki 词条
- Microsoft:Tactical DDD:免费的完整无人机配送示例,实体/值对象建模选择策略的出处
- Microsoft:持久化层设计:"每聚合根一个仓储"原则的权威出处
- ddd-crew/context-mapping:九种映射模式与三种团队关系的维护中清单
- ddd-crew:EventStorming 速查表:工作坊的便签语法与流程
- Author:Ximou Zhao
- URL:https://ximouzhao.com/article/ddd-guide
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!
Relate Posts





