领域模型驱动开发:让代码说业务的语言
领域模型驱动开发:让代码说业务的语言
很多业务系统刚开始都很清爽。
Controller 接收参数,Service 查询数据库,改几个字段,再调用 Repository 保存。需求不多时,这套结构直接、明快,几乎没有什么可挑剔的。
直到业务开始生长。
退款要判断支付状态、退款期限、已退金额和营销补贴;取消订单要考虑库存是否锁定、商品是否出库、优惠券是否退回;同一个“客户”,在销售眼里是商机,在履约眼里是收货人,在财务眼里又是结算主体。
规则像雨后的藤蔓,沿着接口、定时任务、消息消费者和数据库脚本四处攀爬。最危险的不是代码变多,而是再也没有一个地方能完整回答:
一笔订单,到底在什么条件下可以退款?
有人说要看 RefundService,有人说 Controller 里还有一段校验,老同事提醒“别忘了消息消费者里也会改状态”。至于为什么这样做,只能去翻需求文档、聊天记录,或者等待某位熟悉历史的人上线。
领域模型驱动开发,就是从这里开始的。
它试图把散落在人脑、会议和 if/else 里的业务知识,提炼成一套团队共同使用的模型;再让这套模型进入代码,成为系统真正执行规则的地方。
先说清楚:什么是领域模型驱动开发
“领域模型驱动开发”并不是一个与 DDD 分离的新流派。
本文用它指代 Domain-Driven Design,领域驱动设计中的核心方法:让领域模型不只停留在需求文档和架构图上,而是持续推动代码结构、对象行为、模块边界与团队语言。
它也不是另一种“模型驱动工程”:不是先画一套 UML,再让工具自动生成代码。真正的模型不是图本身,生成代码也不是“驱动”的关键。
按照 Eric Evans 的 DDD Reference,DDD 关注三件事:聚焦核心领域,让领域专家与软件人员共同探索模型,并在明确的限界上下文中使用统一语言。Martin Fowler 对它的概括也很直接:DDD 把软件开发的中心放在一个能够深刻表达领域过程与规则的模型上。
这句话里有三个关键词:领域、模型、驱动。
领域:软件真正要解决的问题
领域不是数据库、框架或微服务。
电商系统的领域是交易、定价、库存和履约;保险系统的领域是投保、核保、保全和理赔;内容平台的领域可能包括创作、审核、推荐和版权。
数据库只是保存这些业务事实的一种技术,HTTP 只是传递请求的一种方式。技术会更新,业务问题却会长期存在。
因此,领域驱动设计首先问的不是:
这张表应该有哪些字段?
而是:
业务中发生了什么?有哪些概念?规则是什么?什么变化必须保持一致?
模型:对业务最有用的那部分抽象
模型不是现实世界的完整复制品。
一张地铁图不会画出沿途的每棵树和每幢楼,却能帮助乘客换乘;一张地形图看起来完全不同,因为登山者关心海拔、坡度和水源。它们描述的是同一座城市,却服务于不同问题。
领域模型也是这样。
它会主动忽略无关细节,只保留解决当前问题所需的概念、关系、规则和状态变化。好的模型并不追求“像现实”,而追求对当前业务问题有解释力。
驱动:让模型真正进入代码
如果架构图上写着“订单聚合”,代码里却只有 order.status = 3;如果业务一直说“申请退款”,接口却叫 update_order,那么模型仍然只是墙上的装饰。
“驱动”意味着模型与实现相互咬合:
- 业务中的关键名词,成为代码中的类型和模块;
- 业务动作,成为对象上的行为或明确的领域服务;
- 业务规则,进入实体、值对象和聚合边界;
- 代码暴露的问题,又推动团队修正模型与语言。
领域专家与开发者用统一语言提炼模型,模型塑造代码;运行反馈与新认知再回来修正模型。
这不是一次建模会议,而是一个持续循环。
领域模型不是数据库模型
数据库模型关心的是数据怎样存:表、列、主键、外键和索引。
领域模型关心的是业务怎样成立:行为、规则、身份、边界和状态变化。
两者会有关联,却不是一回事。
假设我们有一张订单表:
orders
id
customer_id
status
paid_at
total_amount
refunded_amount
把它原样映射成一个类,很容易得到:
@dataclass
class Order:
id: str
customer_id: str
status: str
paid_at: datetime | None
total_amount: Decimal
refunded_amount: Decimal
这个 Order 有数据,却没有业务含义。任何代码都可以把 status 改成任意字符串,也可以让 refunded_amount 大于 total_amount。
于是规则只能写在外面的 Service 里:
def refund_order(order_id: str, amount: Decimal) -> None:
order = repository.get(order_id)
if order.status != "paid":
raise ValueError("only paid orders can be refunded")
if datetime.now() > order.paid_at + timedelta(days=30):
raise ValueError("refund window has closed")
if order.refunded_amount + amount > order.total_amount:
raise ValueError("refund exceeds paid amount")
order.status = "refunding"
order.refunded_amount += amount
repository.save(order)
第一版似乎没有问题。
可一旦 API、后台任务和消息消费者都能退款,三处代码就可能各写一套判断。后来退款期从 30 天调整到 45 天,某处改了,某处没改,系统便产生了三个不同版本的“退款规则”。
这种对象只有字段、业务行为都堆在外部 Service 的结构,常被称为贫血模型。贫血模型不一定永远错误:简单 CRUD 用它足够清楚。问题出现在业务规则复杂且持续变化时——数据和行为被拆开,系统就很难守住一致的业务含义。
从“修改数据”转向“执行业务行为”
领域模型驱动的做法,不是把 Service 里的每一行代码机械地搬进实体,而是先重新描述这件事:
旧表达:把订单 status 更新为 refunding
领域表达:订单申请退款
前者暴露存储结构,后者表达业务意图。
代码也应该跟着语言改变:
class DomainError(Exception):
pass
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def __post_init__(self) -> None:
if self.amount < 0:
raise DomainError("money cannot be negative")
def add(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise DomainError("currency mismatch")
return Money(
amount=self.amount + other.amount,
currency=self.currency,
)
class Order:
def request_refund(
self,
amount: Money,
requested_at: datetime,
) -> "RefundRequested":
if amount.amount <= 0:
raise DomainError("refund amount must be positive")
if self.status != OrderStatus.PAID:
raise DomainError(
"only paid orders can be refunded"
)
if requested_at > self.paid_at + timedelta(days=30):
raise DomainError("refund window has closed")
next_refunded = self.refunded_amount.add(amount)
if next_refunded.amount > self.total_amount.amount:
raise DomainError("refund exceeds paid amount")
self.refunded_amount = next_refunded
self.status = OrderStatus.REFUNDING
return RefundRequested(
order_id=self.id,
amount=amount,
requested_at=requested_at,
)
现在,外部代码不能随意拼凑退款状态,而要请求订单执行 request_refund()。
订单自己守住四个不变量:
- 退款金额必须大于零;
- 只有已支付订单才能退款;
- 退款不能超过时限;
- 累计退款金额不能超过实付金额。
Money 也不再是一个裸露的 Decimal。它保证金额不能为负,只有相同币种才能相加。
这就是领域模型的价值:无论请求来自网页、定时任务还是消息队列,只要最终经过同一个领域行为,核心规则就只有一份。
统一语言:让会议里的词走进代码
模型要进入代码,团队首先要拥有一套共同语言。
DDD 把它称为 Ubiquitous Language,统一语言。它不是整理一张业务词汇表就结束,而是要求产品、业务、开发和测试在讨论、文档、用例、接口与代码中,尽量使用同一套经过约定的词。
比如业务人员说:
已支付订单可以在 30 天内申请退款。
那么代码中最好真的能看见:
Paid Order
Refund Window
Request Refund
Refund Requested
而不是:
type = 2
flag = true
updateStatus(4)
OrderProcessManagerV2
统一语言的意义不只是“命名好看”。当业务人员听到 request_refund 能指出“这里还缺一条例外规则”,模型就在接受检验;当开发者发现“取消”和“关闭”在代码里无法区分,也应该回到业务讨论中追问它们是否真是两个概念。
语言变化,往往意味着模型也在变化。Martin Fowler 对统一语言的解释强调,团队正是在与领域专家反复使用这套语言的过程中检验并演进模型。
构成领域模型的几块积木
DDD 有一组常见的战术模式。它们不是必须集齐的徽章,而是帮助我们表达不同类型的业务概念。
| 模式 | 它回答的问题 | 订单场景中的例子 |
|---|---|---|
| Entity | 哪个对象需要用身份持续追踪 | Order、OrderItem |
| Value Object | 哪个概念只关心值,不关心身份 | Money、Address、DateRange |
| Aggregate | 哪些对象必须在一次业务操作中保持一致 | Order 与内部的 OrderItem |
| Repository | 怎样按领域语义取得和保存聚合 | OrderRepository |
| Domain Service | 哪条业务规则跨越多个对象,不适合放进单个实体 | RefundPolicy、PricingService |
| Domain Event | 领域中有什么值得其他部分关注的事情发生了 | RefundRequested |
Entity:身份比属性更重要
两笔订单即使金额和商品完全一样,也仍然是两笔不同的订单,因为它们拥有不同的订单号和生命周期。
Entity 的关键是稳定身份。属性会变化,身份仍然把对象在时间中的不同状态连接起来。
Value Object:把隐含规则变成类型
值对象没有需要长期追踪的身份。两个同币种、同金额的 Money 可以互换;两个起止日期相同的 DateRange 也可以视为相等。
值对象通常适合做成不可变对象。每次“修改”都返回一个新值,这能减少共享状态带来的意外,也让单位、范围、格式和组合规则有固定住所。
Aggregate:为一致性画边界
聚合不是“彼此有关的对象都放在一起”,而是一条一致性边界。
在订单聚合中,外部只能通过聚合根 Order 修改内部状态。不能绕过订单,单独把某个 OrderItem 改成一个与订单总额矛盾的状态。
聚合根像一扇门:所有修改从这里进入,规则也在这里把守。
边界之内,在一次事务里保持强一致;边界之外,通过 ID、领域事件或明确的应用流程协作。聚合太大会制造锁竞争和加载成本,太小又守不住业务不变量,因此识别聚合的核心问题始终是:
哪些数据必须在同一次业务动作结束时一起正确?
Repository:隐藏存储,保留领域语义
Repository 让应用层像使用集合一样取得和保存聚合:
order = order_repository.get(order_id)
order.request_refund(amount, now)
order_repository.save(order)
它隐藏 SQL、ORM、缓存和数据库连接等细节。Repository 接口可以属于领域或应用边界,具体实现则放在基础设施层。
Domain Event:描述已经发生的业务事实
orders 表更新了一行,不是一个好的领域事件;“退款已申请”才是。
领域事件使用过去式表达已经成立的事实,例如:
OrderPaid
RefundRequested
ShipmentDispatched
CouponReturned
其他聚合或系统可以根据事件继续工作,而不必把库存、营销、财务全部塞进订单聚合的一次大事务里。
一次退款请求怎样穿过系统
领域模型不是要包办所有事情。
身份认证、事务控制、调用外部支付平台、发送消息和持久化,仍然需要应用层与基础设施层。关键在于:它们负责编排和实现机制,领域层负责业务判断。
应用服务组织一次用例,订单聚合执行并守住退款规则;Repository 与消息设施处理存储和对外通信。
应用服务可以写得很薄:
class RequestRefundHandler:
def __init__(
self,
orders: OrderRepository,
unit_of_work: UnitOfWork,
) -> None:
self.orders = orders
self.unit_of_work = unit_of_work
def handle(self, command: RequestRefund) -> None:
order = self.orders.get(command.order_id)
event = order.request_refund(
amount=command.amount,
requested_at=command.requested_at,
)
self.orders.save(order)
self.unit_of_work.add_event(event)
self.unit_of_work.commit()
它负责:
- 根据命令找到订单;
- 调用领域行为;
- 保存聚合并提交事件。
它不应该重新判断“是否超过 30 天”,因为那是领域规则。否则 Web API 和消息消费者各写一个 Handler 时,规则又会重新散开。
领域层也不应该自己打开数据库连接、读取 HTTP Header 或调用短信 SDK。这些是实现机制,不是退款业务本身。
可以用一句话区分:
Application Service:这次用例按什么顺序执行
Domain Model:每一步在业务上是否成立
Infrastructure:数据库、消息和外部服务具体怎样工作
大系统里,不应该只有一个模型
初学 DDD 时,很容易试图画出一张覆盖整家公司的终极领域模型。
但同一个词放进不同业务场景,含义可能完全不同。
以“商品”为例:
- 在商品中心,它有标题、类目、属性和详情;
- 在库存上下文,它是一个需要占用和释放数量的 SKU;
- 在营销上下文,它是参与活动、满足门槛的促销对象;
- 在推荐上下文,它可能只是一组特征和排序信号。
强行让它们共享一个巨大的 Product,最终只会得到一个拥有上百个字段、谁都不敢修改的公共对象。
DDD 使用 Bounded Context,限界上下文为模型划定适用范围。边界之内,术语和规则保持一致;跨越边界时,通过 API、事件或转换层明确翻译。
商品上下文 库存上下文
Product StockItem
title sku_id
category available_quantity
attributes reserved_quantity
ProductPublished
-> 转换 -> 创建或更新 StockItem
这不是重复建模,而是承认:不同问题需要不同地图。
DDD 的战略设计还会继续区分核心领域、支撑子域和通用子域,并用 Context Map 描述上下文之间的协作关系。战术模式帮助我们把一个模型写好,战略设计则帮助我们决定哪里应该使用哪个模型。
DDD 不等于某种固定技术架构
DDD 经常和微服务、事件驱动、CQRS、Event Sourcing、六边形架构一起出现,于是很多人把它们当成一套必须捆绑安装的套餐。
其实不是。
- DDD 可以用在单体应用,也可以用在微服务;
- 领域模型可以用面向对象实现,也可以使用函数式风格;
- Domain Event 不要求所有系统都部署消息队列;
- Repository 不等于必须使用 ORM;
- 使用实体和值对象,也不代表必须上 CQRS 或 Event Sourcing。
DDD 首先是一种处理复杂业务知识的方法。技术架构应该服务于模型,而不是为了“看起来像 DDD”把简单系统改造成基础设施展览馆。
什么情况下值得使用
领域模型驱动开发最适合业务复杂度高的系统。
通常会出现这些信号:
- 业务规则很多,并且持续变化;
- 同一个动作需要考虑多个条件和状态;
- 错误状态会产生真实的业务损失;
- 产品、业务和研发经常因为术语不同产生误解;
- 多个团队在同一业务领域协作;
- 系统需要维护很多年,业务知识比框架寿命更长。
支付、交易、物流、保险、供应链、风控和企业协作系统,通常容易从 DDD 中受益。
相反,如果系统只是简单后台 CRUD,规则很少、生命周期也短,那么 Transaction Script 可能更加经济。微软的 DDD 指南也特别提醒:贫血模型在简单 CRUD 中未必是反模式,是否需要丰富领域模型取决于业务复杂度。
设计的目的不是模式越多越好,而是让复杂度有合适的住所。
怎样开始,而不是怎样一次设计完
领域模型不是架构师关起门来画出来的,也很难在项目第一天一次定稿。
一个更实际的起点是:
1. 从真实业务场景开始
不要先列名词,先讲故事:
客户付款后发现商品缺货,客服申请退款;如果使用过优惠券,还要判断优惠券是否退回。
场景会自然暴露动作、条件、参与者和例外。
2. 找出规则密集的地方
哪些地方 if/else 最多?哪些需求每次修改都容易漏?哪些问题必须问业务专家?
这些往往是领域模型最值得投入的地方,而不是所有表都要先包装成实体。
3. 建立并反复使用统一语言
把“退款”“撤销”“退货”“关闭订单”说清楚。一个词是不是多个意思?两个词是不是其实同一个动作?
让这些词进入会议、验收用例、测试名称和代码。
4. 先找到一个小的聚合边界
选择一条关键不变量,例如“累计退款不能超过实付金额”,让一个聚合真正守住它。不要一开始就设计整个公司的对象图。
5. 用测试写下业务事实
领域测试应该像业务规则一样可读:
def test_paid_order_can_request_refund_within_30_days(): ...
def test_refund_cannot_exceed_paid_amount(): ...
def test_shipped_order_cannot_be_cancelled_directly(): ...
这些测试既验证代码,也保存团队对领域的理解。
6. 随认知深入持续重构
发现“退款申请”和“退款执行”其实是两个状态,就修正模型;发现 30 天不是订单规则,而是由渠道和商品共同决定的策略,就把它提炼成 RefundPolicy。
模型不是需求分析结束时封存的答案,而是团队当前最好的共同理解。
常见误区
一张表对应一个 Entity
表是存储结构,Entity 是需要用身份持续追踪的领域概念。关联表、报表宽表、历史快照未必都是 Entity;一个聚合也可能跨越多张表保存。
给类加几个方法,就算丰富领域模型
如果方法只是 set_status()、set_amount(),业务规则仍然在外面。真正的领域行为应该表达意图,并守住不变量,例如 request_refund()、reserve_inventory()。
所有规则都塞进 Entity
有些规则跨越多个对象,适合 Domain Service;有些是用例顺序,属于 Application Service;有些是权限和技术限制,属于 Harness 或基础设施。
高内聚不是把所有代码塞进一个大类,而是让每条规则待在最能解释它的位置。
聚合越大越完整
聚合是事务一致性边界,不是对象关系全景图。巨型聚合会导致并发冲突、加载膨胀和团队耦合。
Domain 层直接依赖 ORM 和框架
如果领域对象只有连上数据库才能测试,业务模型就已经被实现技术绑住。领域层应该尽量保持普通、独立,让基础设施依赖领域边界,而不是反过来。
只学战术模式,不理解业务
Entity、Value Object、Repository 都只是工具。没有领域专家参与,没有统一语言,也没有对业务边界的持续探索,项目最多只是把 CRUD 换成了一组更昂贵的类名。
当 Agent 进入领域系统
现在再回看 Agent,会发现领域模型驱动开发反而变得更重要。
Agent 擅长理解一句模糊的话:
帮这个客户退掉订单,再补偿一张优惠券。
但它不应该自己决定退款期限、补偿标准和订单状态。更稳妥的方式是让 Agent 调用具有领域语义的能力:
request_refund(order_id, amount, reason)
issue_compensation(customer_id, policy_id)
Agent 负责理解意图和编排步骤,领域模型负责判断业务行为是否成立,Harness 负责权限、审批、幂等与审计。
Agent 处理不确定性
Domain Model 守住确定性
Harness 控制真实副作用
领域模型越清楚,对人类开发者越容易理解,对 Agent 暴露的工具也越自然。与其给 Agent 一个万能的 update_order,不如给它一组边界明确、失败语义清楚的领域命令。
小结
领域模型驱动开发,不是把数据库表换成一批类,也不是在项目里集齐一套 DDD 术语。
它真正关心的是:
- 和领域专家一起理解业务;
- 用统一语言表达这份理解;
- 为不同模型划定清晰的限界上下文;
- 把关键行为与规则放进领域模型;
- 让代码、测试和模型在变化中一起演进。
数据库会迁移,框架会换代,接口形式也会变化。但一家公司真正有价值的软件,往往沉淀着它独特的业务知识。
领域模型驱动开发所做的,就是给这些知识一个不会轻易走散的家。