
你在写“催发货”这个功能时会不会纠结它到底该放在哪是给Order实体加一个remindShip()方法还是塞进OrderService里又或者直接在外面写一个静态工具类这种纠结的本质是动词在建模阶段没有独立的地位。我后来接触到的动词算子体系对这类问题给出了一个明确的答案——把动词当一等公民来看待再通过它和域对象之间的笛卡尔积关系把系统的能力空间完整铺开。这篇文章就把这套理论从头到尾讲透包含概念定义、原理推导、订单案例和落地的代码思路适合正在做领域建模、服务拆分或者总觉得“方法放哪都不对”的开发者。如果你已经读过一些 DDD 的书会发现这套理论和 DDD 有不少呼应但又不太一样。DDD 讨论的是限界上下文、聚合、实体动词算子体系则更聚焦在一个更基础的问题上动词到底应该以什么形态存在于系统里以及如何系统化地发现所有“动作与对象”的组合。这两个问题想清楚之后很多接口设计、权限设计、测试用例设计层面的麻烦会少很多。1. 动词的归属难题这套体系想解决的根问题1.1 一个动词引发的代码设计分歧先还原一个真实场景。产品经理提了一个很简单的需求订单详情页增加“催发货”按钮用户点击后通知仓储侧尽快处理。听起来就是调一个接口嘛。但真正到了写代码的时候不同程序员给出的方案通常不一样甲说给Order实体加一个remindShip()方法因为这是订单自己的行为。乙说不行实体不应该知道物流、通知这些外部概念应该放到OrderApplicationService里叫remindShip(orderId)。丙说你们说得都对但为了复用我打算建一个ShipReminder工具类谁想用谁用。这三种方案在小型系统里都能跑但时间一长就会出现奇怪的现象有些动词挂在实体上有些动词躺在 Service 里还有些动词变成了无人维护的工具方法。代码评审时最常出现的争论就是“这个逻辑放哪”。表面看是代码风格问题实际上是从建模开始就没有给动词安排一个清晰的归宿。1.2 传统面向对象建模对动词的隐性不友好面向对象建模有一个非常经典的动作顺序先找名词再给名词分配方法。订单、用户、商品都是名词它们是模型的主角方法则是附着在名词身上的附属品。这个思路长期占据主流以至于大家默认“行为就应该属于某个对象”。但真实业务并不总这么规整。一个动词可能同时涉及多个对象取消订单既改了订单状态又要释放库存还要通知支付平台一个对象也会被无数动词蹂躏订单会被创建、修改、锁定、取消、支付、退款、发货、签收、归档……当动词和对象之间的关系是多对多时硬把它们绑定在一起就必然出现“上帝类”或者“贫血模型”两个极端。前者把所有方法堆到一个大对象里后者把所有方法堆到 Service 里本质上都是动词找不到合适归宿的体现。1.3 动词算子体系的立场让动词先于对象存在动词算子体系的核心立场是动词不需要“属于”某个对象它应该作为独立的设计单元存在。域对象也不是动词的宿主而是动词的作用对象。建模时优先梳理“这个系统到底有哪些动作”而不是“这个对象有哪些方法”。这在思维方式上是反直觉的。绝大多数人拿到需求第一反应是画表、建实体动词算子体系要求你反过来先列动作清单再定义动作作用于哪些对象。这样做的好处是动作本身成为可以统一管理、统一约束、统一做权限控制的东西。你对系统能力的审视也从“对象有哪些行为”变成了“动词与对象的配对关系中哪些是允许的、哪些是禁止的、哪些是有条件的”。这套立场并不是要推翻面向对象而是要修正面向对象最容易被滥用的那一面把行为强行归属到名词名下。动词独立出来之后实体可以做得更薄服务可以做得更纯职责边界也会清晰很多。2. 三个核心概念的严格边界动词算子、域对象与笛卡尔积2.1 动词算子不止是方法这么简单动词算子英文对应 Verb Operator指的是业务领域中一个动词的抽象。为什么叫“算子”而不是“方法”或“服务”因为算子来自数学中的 operator 概念强调的是一个可操作、可组合、有输入有输出的映射单元。方法太随意一个方法里什么都干服务太庞大一个服务往往包含多个动作。算子恰恰是介于两者之间的那个颗粒度一个算子只表达一个领域动作。一个完整的动词算子应该包含以下信息名称使用动词短语命名例如“创建订单”“取消订单”“提交退款申请”。作用对象这个算子作用于哪个域对象例如取消订单作用于订单。输入参数执行动作所需的补充信息例如取消订单可能需要取消原因。输出结果执行后返回什么例如新的订单状态、事件列表。前置条件动作允许发生的条件例如只有“已支付”状态的订单才能申请退款。副作用执行动作后对系统产生了什么影响例如发送通知、写审计日志、触发消息。状态迁移域对象从什么状态变到什么状态。算子又分成两类原子算子和组合算子。原子算子是不可再拆的领域动作例如“变更订单状态”“扣减库存”组合算子则编排多个原子算子例如“提交订单”可能是“校验库存 锁定库存 创建订单 发消息”的组合。在名词上做文章之前先把这套算子清单列完整系统的行为边界也就有了雏形。2.2 域对象被动作作用的世界域对象Domain Object指业务领域中承载状态和身份的对象模型。它不限于传统意义上的“实体”也包括值对象和一些过程性记录。为了讲清楚我会把域对象粗略分成三类核心实体有身份、有生命周期、会被多个算子作用的对象例如订单、用户、商品。值对象没有身份、用于描述属性的对象例如金额、地址、联系电话。过程记录记录某次动作发生痕迹的对象例如操作日志、审批流节点、事件记录。在动词算子体系里域对象的定位非常清晰它是被动的、被作用的素材。它不承载核心业务逻辑主要承担状态表达和身份标识的作用。这跟传统“充血模型”的倡导刚好相反——动词算子体系刻意让域对象保持较薄把复杂逻辑集中在算子层。需要特别注意的是不要把域对象等同于数据库表。一张数据表在业务上可能对应多个域对象比如订单表和订单快照表后者更像过程记录而不是核心实体一个域对象也可能跨多张表聚合而来。域对象是语言层面的模型存储只是它的一个投影。2.3 笛卡尔积不是“全实现”而是“全枚举”笛卡尔积来自集合论给定集合 A 和集合 BA×B 是所有“由 A 中元素和 B 中元素组成的有序对”的集合。如果 A 有 3 个元素B 有 4 个元素那么 A×B 就是 12 个有序对。在动词算子体系里设 V 是系统中所有动词算子的集合D 是所有域对象类型的集合那么 V×D 就是所有“动词-域对象”配对的集合。这个配对空间的规模是 |V|×|D|。我经常用一个厨房的类比来解释这个概念。动词是菜谱里的动作切、炒、炖、蒸域对象是食材土豆、牛肉、豆腐、鱼。笛卡尔积就是把动作和食材全配一遍切土豆、切牛肉、炒土豆、蒸豆腐、炖鱼……有些配对完全合理有些配对在特定菜系里很荒谬比如“蒸牛肉”不是不行但要看这道菜允不允许。笛卡尔积表格本身不决定哪道菜该做而是强迫你想一遍这些组合里哪些是正宗做法、哪些绝对不能做、哪些可以做但需要额外条件。所以动词算子和域对象的笛卡尔积核心用途不是让你把每个配对都实现出来而是让你把整个能力空间全部枚举一遍一个组合都不遗漏然后再根据业务规则逐个标定。这个“先全枚举、后做取舍”的思路正是整套理论最有价值的地方。3. 全配对空间的价值完备性、可推导性与矩阵收敛3.1 完备性人脑最容易漏掉组合一个中等规模的业务系统梳理出 30 到 40 个动词算子很正常域对象类型也会有 10 到 15 个。按这个规模V×D 的配对数量是 300 到 600。凭脑子把每个组合过一遍基本不现实人只会记住常用的几十个组合创建订单、取消订单、支付订单、发货订单……那剩下的几百个组合里藏着什么往往是需求漏洞。比如“锁定购物车”这个组合很多人做系统时根本不会想到但在促销秒杀场景里提前锁定购物车中的商品再统一结算是非常合理的需求。如果不把动词和对象的笛卡尔积完整铺到桌面上这类组合就只能靠某个程序员某天灵光一现才发现。矩阵提供了一个反人类的强制力每一行、每一列都必须有一个标记你不能跳过任何一个格子。这种强制力虽然笨但极其有效它把人脑从“我能想到什么”拔高到“所有可能存在什么”。3.2 可推导性没被标定的空格就是需求缺口笛卡尔积矩阵的每一个格子在理论上都有三种标定结果支持、禁止、有条件。支持这个动词作用于该域对象是合理且系统要实现的。禁止业务语义上不允许例如你不能对已删除的订单执行支付。有条件可以发生但必须满足特定前置条件例如仅当订单处于“已支付”状态时才能申请退款。其中最有价值的是“禁止”和“有条件”。“禁止”把领域规则显式化了比如“支付”作用于“商品”这个格子标定为禁止这就是在告诉所有人钱的结算主体是支付单和订单商品不能脱离订单被直接支付。这个规则如果只写在代码注释里没人看但出现在矩阵里就是设计文档本身。更重要的是那些还没有标定的空格。当你在矩阵里发现某个格子不知道填什么时大概率不是“想不出来”而是需求没想清楚。比如“退款”作用于“订单项”很多电商系统早期只支持整单退款这一格标定为禁止但后续业务要做部分退款时这一格就得改成有条件。矩阵帮你把这个需求缺口提前暴露出来而不是等到上线后用户投诉了才补。3.3 矩阵收敛组合爆炸的现实解听到 |V|×|D| 这个规模很多人第一反应是这不就是组合爆炸吗几百个组合怎么可能全部实现这里必须澄清——笛卡尔积是分析工具不是实现清单。矩阵收敛有三种常用手段动词分层矩阵只放原子算子组合算子不占行列。组合算子作为原子算子的编排脚本在另一个层面管理。域对象聚合不在最细粒度的对象层面建矩阵而是先聚合。例如订单项通常不单独成行而是归并到订单聚合下只有个别需要按行操作的动词才展开到订单项。前置条件压缩同一动词作用于同一域对象在不同状态可能行为完全不同。这不是要拆成多个动词而是在同一个格子里写清前置条件和状态迁移即可。收敛之后矩阵规模会从“几百种组合”降到“几十种真正需要设计的组合”既保留了理论上的完备性又具备工程上的可行性。4. 一张订单场景的笛卡尔积矩阵从理论到可读的表格理论讲得再多不如直接看一张表。我以最常见的电商订单域为例先盘点域对象再盘点动词算子最后铺出一张简化版笛卡尔积矩阵。先看域对象集合。为了保证表格可读我选六个具有代表性的类型订单核心实体状态和金额的载体。订单项订单下的单条商品记录支持部分退款和评价。支付单支付流程的关键记录连接订单与支付渠道。购物车结算前的临时容器支持预占库存。物流单发货和签收的载体。发票税务相关往往独立于订单状态。再看动词算子集合。我先列八个在订单域里最常见的动作创建从无到有地建立一个对象。修改变更对象的部分信息。取消终止一次业务过程。支付完成资金结算。退款将已支付资金原路退回。发货生成物流单并出库。锁定暂时冻结对象的可用状态。开票生成发票。把两者做笛卡尔积就得到下面这张能力矩阵动词算子订单订单项支付单购物车物流单发票创建支持支持支持支持支持支持修改支持有条件禁止支持有条件支持取消支持禁止有条件支持有条件禁止支付有条件禁止支持禁止禁止禁止退款有条件有条件有条件禁止禁止禁止发货禁止禁止禁止禁止支持禁止锁定支持禁止禁止支持有条件禁止开票有条件有条件禁止禁止禁止支持这张表不需要多么智能它存在的意义就是让你“看一遍所有格子”。每当你看到一个奇怪的配对比如“支付 × 购物车”标为禁止你会本能地问一句为什么这个“为什么”逼出来的就是一条领域规则。我来拆三个有意思的发现。第一支付对订单是有条件支持对支付单是支持。原因是支付动作必须基于支付单这个中介来完成订单本身不是支付通道直接作用的载体。如果你把支付直接作用到订单上会让支付上下文和订单上下文过度耦合。很多支付回调的代码写成order.markAsPaid()本质上就是把支付动作强行挂到了订单对象上导致订单实体不得不感知支付渠道的存在。第二退款对订单项是有条件支持。这是很多系统早期不做的部分退款场景。如果你在矩阵里发现这一格是空白说明需求根本没定义清楚定义成“有条件”之后你再去看具体条件是什么会自然推导出“按行退款、按比例退款、退款限制在多久内”这类规则。第三锁定对购物车是支持但对订单也是支持。购物车锁定的意义在于结算前锁定商品价格和库存订单锁定的意义在于风控、审批、纠纷处理中冻结订单流转。同一个动词作用于两个不同域对象语义完全不同但在行为框架上是统一的。这个结论靠拍脑袋不一定会想到看矩阵反而一目了然。5. 从矩阵回到代码算子承载、策略注册与权限校验5.1 算子的接口设计范式矩阵是分析层的东西最终要落到代码上。动词算子体系在代码层的落地通常表现为一个明确的算子接口。下面是我在实际项目里比较喜欢的一种 Python 风格设计供参考class VerbOperator: domain None # 作用于哪类域对象 name # 动词名例如 cancel def precheck(self, obj): 前置条件校验不满足则抛异常 raise NotImplementedError def execute(self, obj, context): 执行领域动作返回结果或事件列表 raise NotImplementedError具体的“取消订单”算子可以写成class CancelOrder(VerbOperator): domain Order name cancel def precheck(self, order): if order.status not in {pending, paid}: raise DomainRuleViolation(当前订单状态不允许取消) def execute(self, order, context): order.status cancelled context.events.append(OrderCancelled(order.id)) context.stock_service.release(order.items) return order这个设计里核心业务逻辑都集中在execute中域对象只是被操作的数据载体。precheck单独抽出来是因为前置条件校验在权限、幂等等场景里经常需要被独立调用不必每次都触发完整的执行链路。5.2 注册中心把矩阵变成可运行的映射分析用的矩阵在代码里最好能找到一个一一对应的映射关系。最简单的方式是使用一个注册表键是“动词名域对象类型”值是对应的算子实例OPERATOR_MATRIX { (create, Order): CreateOrder(), (cancel, Order): CancelOrder(), (pay, Payment): PayPayment(), (refund, Payment): RefundPayment(), (lock, Cart): LockCart(), (ship, Shipment): CreateShipment(), }调用方不再自己去if else判断该调哪个服务而是直接走统一的分发入口def dispatch(verb_name, domain_obj, context): handler OPERATOR_MATRIX.get((verb_name, type(domain_obj))) if handler is None: raise UnsupportedCombination( f动词 {verb_name} 不能作用于 {type(domain_obj).__name__} ) handler.precheck(domain_obj) return handler.execute(domain_obj, context)这个分发逻辑写完之后你再回头对照那套笛卡尔积矩阵矩阵里每个标定为“支持”的格子都应该在OPERATOR_MATRIX里找到一个对应键矩阵里标定为“禁止”的格子dispatch 时会因为查不到 handler 而抛出UnsupportedCombination。分析模型和代码模型就真正对上了。5.3 矩阵驱动的权限、校验与测试用例矩阵里的每个格子不只是“能做不能做”它还是权限配置的天然挂载点。比如“取消 × 订单”需要订单归属人权限“退款 × 支付单”需要财务权限“锁定 × 购物车”在促销场景里需要活动运营权限。把这些信息提前标注在矩阵定义里生成权限表时就能直接引用。我常用的做法是为每个配对增加一个 policy 字段OPERATOR_MATRIX { (cancel, Order): { handler: CancelOrder(), policy: PermissionPolicy.OWNER_OR_ADMIN, }, (refund, Payment): { handler: RefundPayment(), policy: PermissionPolicy.FINANCE, }, }测试用例设计同样可以复用矩阵。标准的三层用例结构是对标记为“支持”的格子写主路径用例对标记为“禁止”的格子写异常路径用例验证确实会抛错对标记为“有条件”的格子按前置条件的不同分支写多组用例。只要矩阵没有漏标测试用例集就基本不会漏覆盖。6. 落地时最容易翻车的几个点与我的解决习惯6.1 别把笛卡尔积当成“必须全部实现”的清单我刚接触这套理论时犯过一个明显错误为了让矩阵看起来整洁我试图把凡是“支持”的格子全部在代码里实现结果一个迭代期直接爆掉。后来才明白矩阵是分析空间的工具不是开发的排期表。一个格子标为“支持”只代表它在业务上合理不代表当前阶段必须实现。你需要再引入一个“是否已实现”的标注维度或者用一张独立的实施清单来承接否则矩阵会把团队拖入形式主义。6.2 动词粒度的漂移问题矩阵里最尴尬的画面是某一行动词明显比其他动词大一号。比如“创建订单”是一个原子算子而“处理售后”却把校验、退款、回寄、补偿四条链路全塞进一个动词里。粒度不统一矩阵的格子标注就会失真。我的习惯是建矩阵前先做“动词粒度审查”如果某个算子里有超过一个业务意图就拆成组合算子和若干原子算子组合算子不进矩阵原子算子进矩阵。6.3 域对象不等于数据库表这也是很多团队落地时的误解。用数据库表来充当域对象会让矩阵里出现大量“没意义”的格子。比如订单快照表它就是一份历史数据拷贝业务上不会有人对它执行“取消”或“支付”你会得到一整行全是“禁止”的废格子。域对象应该从业务描述里找而不是从表结构里找。如果你发现某个域对象和任何动词的配对几乎都是禁止它大概率不是真正的域对象。6.4 让矩阵成为迭代流程的一部分矩阵不是画完一次就锁进文档里的。我在团队里定的规矩是每个需求迭代开始前先更新能力矩阵再更新代码设计。新需求来了先问一句这次是新增动词还是新增域对象还是给已有配对加前置条件这三种变更对应的代码路径完全不同。一个是新增算子类一个是新增域对象模型一个是修改既有算子的前置逻辑。这个问题在需求评审阶段问清楚比开发到一半再返工要划算得多。用这张矩阵做代码评审也是一个高效的方法。评审时把矩阵打开逐格问这个格子支持吗代码里有对应算子吗测试覆盖了吗权限配了吗五六个问题下来大多数职责边界问题、遗漏场景问题都会现形。我自己在实际操作中的体会是这套方法对个人设计能力的要求其实不高真正需要的是执行纪律——逼着自己把每一个可能的配对都看一遍想一遍而不是只做自己熟悉的那几个组合。