AI 编程时代,如何理解和设计领域模型
本文目录
date
slug
ai-code-domain-ddd
author
status
Public
tags
summary
type
Post
thumbnail
category
💻 Backend
updatedAt
Sep 14, 2026 03:29 PM
把“实现一个订单占用库存的接口”交给 AI,接下来可以得到 Controller、Service、数据库访问代码,以及一组测试。编译通过,接口也能返回成功,但这还没有回答几个业务问题:占用和实际出库有什么区别?请求超时后重试,会不会再占一次?订单取消时,应该释放哪一笔库存?
这些问题决定软件是否做对了事。生成实现之前,它们需要被说清楚;实现完成之后,它们又是验收的依据。
领域模型就在这里发挥作用。它把业务中的概念、行为和约束组织起来,让开发者能够讨论,也让代码有明确的规则可遵循。在 AI 辅助编程中,这种表达还可以成为任务上下文,帮助我们发现生成结果遗漏了什么。
下面用一个简化的库存占用场景展开。它是用于解释设计的教学案例,不对应某个已上线系统。我们只讨论单仓库、单 SKU、按件计数的库存,暂不包含批次、冻结、预售和多仓分配。
1. 先把“占用库存”说清楚
假设仓库里有 10 件商品,订单 A 需要 3 件。为这个订单占用库存后,实物还在仓库里,只是其中 3 件不能再分配给其他订单。
在这个案例中,可以约定:
- 在库量是仍在仓库里的商品数量。
- 占用量是已经分配、尚未出库的数量。
- 可用量等于在库量减去占用量。
- 取消一笔有效占用,会恢复对应的可用量,在库量不变。
于是,第一次占用后,在库量仍为 10,占用量为 3,可用量为 7。取消后,占用量回到 0,可用量回到 10。
接下来才是更容易遗漏的约束:占用数量必须为正,累计占用不能超过在库量,同一业务请求不能重复生效,已经释放的占用不能再释放一次。
这些约束通常称为不变量,也就是系统在规定的业务边界内必须始终守住的条件。对涉及多个字段或对象的更新,要明确哪些条件在一次业务操作完成、事务提交时必须成立,避免其他请求观察到半次修改。它们应当落实为校验、状态转换和存储约束,并由相应测试验证。
如果只提供表结构和“数量减一下”的描述,AI 得到的信息不足以确定以上语义。它可以采用一种看起来合理的实现,但这个选择未必符合业务。开发者首先需要做的,是把这些选择从隐含假设变成明确约定。
2. 领域模型在描述什么
“领域”是软件关注的业务范围。仓储系统关注库存、收货和出库,结算系统关注费用、账单和结算规则。领域模型则是为解决其中的问题,对相关概念及其关系作出的抽象。
Martin Fowler 对 Domain Model 的定义强调,它同时包含行为和数据。[1] 对库存而言,模型既要表达“占用了多少”,也要表达“何时允许占用、释放会带来什么变化”。
因此,仅有一个包含
skuId、quantity、status 的类,还不足以说明库存业务。如果任何调用方都能随意修改这些字段,规则就可能散落在不同入口中:接口校验了一次,定时任务又写了一套,消息消费时漏掉其中一个条件。一个有用的模型应当让业务动作可辨认。例如,“释放占用”有明确前提和结果,比“更新数量与状态”更容易审查。底层即使仍是更新几列数据,上层也保留了动作的业务含义。
这里还要区分几种经常混在一起的东西:
表达形式主要回答的问题库存场景中的例子领域模型业务概念是什么,哪些变化合法库存、占用、释放,以及数量约束数据库模型数据怎样存储、关联和约束库存表、占用表、唯一键、版本号DTO一次调用需要传输什么占用请求、查询返回结果
三者可以有相似字段,但承担不同职责。DTO 不等同于 DDD 中的值对象;某些项目把接口返回类称为 VO,也不能据此判断它就是领域值对象。
领域模型与领域驱动设计也有区别。前者是模型本身,后者是一套围绕业务知识持续建立、修正模型,并使实现与模型保持一致的设计方法。学习 DDD,不必从创建一整套目录开始。
3. 模型先要有适用范围
同一个“库存”,在不同业务里可能有不同含义。仓库关心实物在哪里、能否拣出;销售关心还能承诺多少;财务关心数量对应多少价值。
强行让一个对象承接所有含义,会让字段和规则不断增加,也容易把不同口径当成同一个数字。
DDD 用限界上下文明确模型和语言的适用范围。[2] 在一个上下文内,术语应当保持一致;跨上下文时,要明确数据和含义如何转换。它不要求每个上下文都立即拆成独立微服务。
本文的“可用量”只适用于前面的简化库存上下文。真实系统如果允许预售,销售可售量就未必等于仓库在库量减去占用量。此时需要分别建模,并说明两者怎样协作。
给 AI 提供上下文时,这个范围尤其值得写出来。让它实现“库存扣减”,与让它实现“仓储上下文中的占用,不减少实物在库量”,对应的任务已经不同。准确的术语可以减少歧义,但前提是术语本身来自经过确认的业务知识。
4. 从业务规则找到实体、值对象和聚合
在这个场景中,一笔占用需要被持续追踪。即使状态从有效变成已释放,它仍然是原来的那一笔。这种依靠身份保持连续性的对象,可以建模为实体。
占用实体可以拥有占用 ID、关联的库存标识、业务请求号、占用数量和状态。业务请求号用于识别调用意图,占用 ID 用于追踪对象,二者是否相同,应由系统约定决定。
而“3 件”通常不需要独立身份。只要数值与单位一致,就可以视为相同的数量。这类按值比较的概念,可以建模为值对象。常见例子还有金额、时间区间和地址快照。
值对象通常设计为不可变,变化通过产生新值表达。它完全可以持久化,例如把收货地址快照保存为订单的一部分。有没有独立业务身份,与是否写入数据库,是两个问题。Microsoft 的值对象文档也明确讨论了它的持久化方式。[3]
知道对象是什么之后,还要决定谁守住规则。
如果库存占用量和一笔占用的状态可以被任意分别修改,就可能出现“占用已释放,但总占用量没恢复”的矛盾。聚合用来组织需要作为一个整体维护一致性的领域对象,聚合根负责控制对内部状态的修改。[4]
为了说明这层关系,本例可以把某个仓库、某个 SKU 的库存余额作为聚合根,由它控制有效占用的建立和释放。这是一种教学设计;生产系统若有大量历史占用,把全部记录装入一个对象会造成明显的加载和竞争成本,必须重新评估边界与存储方式。
下面的 Java 风格伪代码只展示一次释放中的领域判断,省略对象构造、持久化、并发控制和其他状态:
ReleaseResult release(ReservationId id) { Reservation reservation = requireReservation(id); if (reservation.isReleased()) { return ReleaseResult.ALREADY_RELEASED; } if (!reservation.isActive()) { throw new InvalidReservationState(); } Quantity nextReserved = reserved.minus(reservation.quantity()); reservation.markReleased(); reserved = nextReserved; return ReleaseResult.RELEASED; }
这里的
requireReservation 表示查找属于当前库存聚合的占用,Quantity.minus 表示拒绝产生负数量的值运算。计算先完成,再改变状态,避免已知的数量校验失败后留下半次修改。这段代码的价值在于明确了释放的业务前提、重复调用的结果和数量变化。它本身没有解决两个请求同时释放的问题,稍后还需要补上存储层的保证。
如果某项规则涉及多个领域概念,又没有自然所属的实体或值对象,可以用领域服务表达。例如“从多个候选仓库中选择满足约束的分配方案”。它应有清楚的业务含义,不必把所有逻辑都集中进一个通用 Service。
5. 从领域判断走到可靠执行
一次完整的释放请求,还需要解析参数、检查权限、读取数据、执行规则、提交修改和返回结果。这些职责可以按下面的方式组织。图中的箭头表示主要调用方向:
应用层组织一次用例,领域层处理业务决策,基础设施层实现存储和外部通信。仓储接口可以由内层定义、外层实现,因此不能把分层简单理解成源代码依赖永远沿着图向下。
划分职责以后,仍要解决执行中的冲突。
假设两个请求都读到可用量为 7,各自准备占用 5 件。它们在各自的内存对象中都会通过校验。仅把规则放进领域方法,无法阻止两个请求依据同一份旧数据作出决定。
持久化时可以选择乐观锁版本校验、适当的悲观锁,或带数量条件的原子更新。采用乐观锁时,一个提交成功后,另一个必须识别冲突;如果重试,应重新读取状态、重新执行规则,不能直接重放之前计算出的新值。
幂等也需要完整实现。相同业务键、相同请求内容应按约定返回已有结果;相同键却带来不同数量,应报冲突。唯一约束可以协助挡住并发重复写入,但还需要配套的事务、冲突处理和结果读取。取消后的旧占用请求再次到达,也不应重新建立占用。
这还要求系统保留足够的处理记录。如果释放后立刻删除占用与幂等记录,就可能无法识别迟到的旧请求。记录保留多久、业务键能否复用、超出保留期的请求如何处理,都需要明确约定。
本例若把占用记录和余额保存在同一个数据库,就应保证相关更新在一个本地事务内共同提交。订单服务与库存服务如果跨库,则需要另行设计失败恢复:如何重试释放、如何记录待处理动作、如何发现长期不一致。一个领域方法或者一个事务注解,都不能独自解决跨系统协调。
领域建模使这些保证的对象和范围更清楚,可靠执行还需要相应的技术机制。
6. 怎样把领域模型交给 AI
模型可以成为 AI 编程任务中一份具体、可审查的上下文。它不一定很长,但应该明确到足以判断实现是否越界。
例如,最初的任务可能只有:
实现库存占用和释放接口,使用 Java,补充单元测试。
补上模型之后,可以写成:
业务范围 单仓库、单 SKU、按件计数,暂不支持批次和部分释放。 占用只增加占用量,不减少在库量。 必须成立的规则 R1:占用数量必须大于 0,累计占用量不得超过在库量。 R2:相同业务键与相同参数重复调用,不能重复增加占用量。 R3:相同业务键与不同参数调用,返回冲突。 R4:释放只针对当前库存下的有效占用;重复释放不再改变数量。 R5:已经释放的占用收到旧占用请求时,返回已有状态,不重新占用。 实现约束 先读取现有代码,确认业务键范围、异常约定和事务机制。 状态变化由领域行为表达,应用层组织用例。 余额和占用记录共同提交;检查并发提交是否会破坏 R1 至 R5。 缺失的业务定义列为问题,给出建议及影响,等待业务确认。 验收要求 逐条列出规则对应的实现位置和测试场景。 区分纯领域单元测试、数据库并发测试与跨服务集成测试。 说明实际运行了哪些测试,哪些仍未验证。
这里增加的是业务信息和验收依据。它不会保证 AI 一定实现正确,但能让评审具体到“R3 是否遗漏”“R5 在重试路径上是否成立”。
AI 也可以参与模型形成:整理术语、指出描述冲突、列出失败场景、提出多种边界方案。涉及业务含义的选择,仍需要由了解业务的人确认。没有确认的猜测不应直接进入实现,再被测试固化成事实。
在已有项目中,还应让 AI 对照实际入口、状态枚举和调用关系。文档描述的是预期行为,现有代码和数据可能暴露例外;遇到冲突时,需要查明原因,不能只选择写起来更顺的一方。
维护这些上下文也有成本。可以把术语、规则和代表性测试放在代码附近,随业务变更一起评审。一份简短但当前有效的规则说明,比没有跟随实现更新的完整设计文档更有参考价值。
7. 验收从规则出发
让 AI 生成实现之后,再要求它“根据这段代码补测试”,可能得到一组忠实于实现的测试。如果实现一开始误解了“占用”,这些测试也可能继续沿用这个误解。
更有用的起点是先写出独立于实现的业务例子:
场景本例约定的结果在库 10、占用 0,新占用 3在库 10、占用 3、可用 7占用数量为 0 或负数拒绝请求,数量不变可用 7,申请占用 8拒绝请求,不建立有效占用以相同业务键和参数再次占用数量不变,返回已有结果相同业务键把数量改成 4返回冲突,原记录不变释放后再次释放数量不变,返回已释放结果释放后重放旧占用请求保持已释放,不重新占用可用 7,两个不同请求并发各占用 5最多一笔业务占用成功,另一笔不能超额提交
前几类数量和状态规则可以通过纯领域测试验证。并发场景需要真实数据库连接和明确的并发时序,还要核对最终余额与占用记录是否一致。使用内存仓储的单元测试,无法证明数据库锁和事务配置有效。
接口编译通过、领域测试通过、数据库并发测试通过、跨服务流程验证通过,分别提供不同范围的证据。评审 AI 产出的代码时,除了看实现,还应看验证结论是否超过了这些证据。
这套方式同样适用于人工编码。AI 参与之后,把规则与验证依据写出来,有助于让生成、修改和复查使用同一套标准。
8. 从一条值得维护的规则开始
并非每个功能都需要丰富的领域模型。一个字段含义稳定、状态简单的维护页面,用清晰的事务脚本和数据库约束,可能已经足够。增加实体、工厂、仓储和转换层,会带来阅读与维护成本。
当业务开始出现多入口复用的规则、复杂状态变化、反复修补的例外,或者必须守住的一致性约束,投入建模更容易产生价值。可以先选一条最容易被写错的业务链,把语言统一、行为集中,再根据变化继续调整。
“领域模型”不应成为要求 AI 批量生成类的口令。它需要回答的是:这个概念指什么,允许发生哪些变化,由谁维护,失败以后如何恢复。
下一次让 AI 实现一个业务功能之前,可以先选出一条规则,为它写出正常、重复和失败三个例子,再检查实现与测试是否都遵守它。当需求变化时,也从这条规则开始修改。模型便有了持续发挥作用的位置。
参考资料
- Martin Fowler:Domain Model,领域模型同时包含行为与数据。
- Martin Fowler:Bounded Context,模型的适用边界、统一语言及上下文间关系。
- Microsoft Learn:Implementing value objects,值对象的身份、不可变性与持久化。本文仅采用概念说明,不讨论其中的框架版本实现。
- Martin Fowler:DDD Aggregate,聚合、聚合根与一致性边界。