DDD服务与规约:如何将业务规则从if-else中解放出来
07-服务与规约-DDD领域驱动设计这可能是DDD战术设计里最容易被误解的两个概念。我见过太多团队Service层堆积几百上千行代码里面什么业务逻辑都有但你要是去问“这个业务规则到底在哪定义的”没人能说清楚。也见过另一拨人听了规约模式的概念后非常兴奋结果在项目里搞出几十个Specification类最后维护成本比if-else还高。服务Service和规约Specification这两个东西单独拿出来都不难理解难的是搞清楚它们各自该管什么、不该管什么以及什么时候应该组合使用。这篇文章我会结合电力行业规约的思维方式把这套东西彻底讲透——不整虚的直接讲原理、讲代码、讲取舍。1. 阿里中台面试拷打之后我重新理解了服务与规约先交代一下背景。前阵子帮朋友做技术面试复盘他面某大厂中台部门被问到一个问题“你们的库存扣减规则校验库存、校验状态、校验限购这些规则如果按DDD来做应该放在哪一层”他说自己当时就懵了。因为这些规则在他的项目里全部堆在Service方法里用一堆if-else串联起来。面试官接着问“如果现在要新增一条规则比如‘新人首单限购1件’你打算怎么改”他意识到自己只能往Service里面再塞一个if。这个场景太典型了。很多团队其实比谁都清楚业务规则不能散落到各处但问题是——到底怎么组织这些规则这个问题恰恰就是DDD里服务和规约要解决的。先说我的结论领域服务解决的是“这个动作不属于任何单一实体但确实是领域行为”的问题。规约解决的是“这条业务规则需要被多处复用、组合、变换”的问题。这两者不是非此即彼的关系。规约经常作为领域服务的参数或内部组件出现领域服务则是承载业务动作的容器。我记得自己第一次真正理解“服务”这个概念是做一个电网调度数据交换系统的时候。那套系统里有前置机、有转发服务、有规约转换器。开发完回头看代码我发现一个有意思的现象真正稳定的代码业务规则都是封装在一个个独立的规约类里的而经常改的、容易乱的部分全是那些把规则写死在Service方法里的地方。后来专门读了Evans的《领域驱动设计》原版看到第5章和第11章用了大量篇幅讲Service和Specification才彻底串起来了。Evans说得很直白当领域中的某个操作不隶属于任何实体或值对象时就应该显式地在领域层声明一个服务来承载它。而规约则是一个用来封装业务规则的独立机制它让“校验”和“筛选”这两件事可以离开if外壳变成可组合的一等公民。2. 领域服务承载跨聚合动作而不是包揽一切逻辑很多人一提到Service就头疼因为在经典的三层架构里Controller-Service-DAO的Service层什么都在做。到了DDD里又冒出来一个Domain Service还有Application Service完全搞不清楚边界。2.1 先把三层Service的边界划清楚为了避免概念混乱我直接用一张对照表把这些Service的区别讲清楚Service类型所属层职责定位典型动作错误示范应用服务应用层编排用例流程协调领域对象不承载业务规则开启事务、调用领域服务、持久化聚合在应用服务里写业务判断领域服务领域层承载跨聚合/跨实体的领域行为表达领域概念转账、做报价、判断某账户是否满足出金条件实现和业务无关的技术逻辑基础设施服务基础设施层技术实现发送短信、调用外部API、读写消息发短信、写日志把技术逻辑混入领域逻辑实际开发里大多数人犯的错误只有一个把本该属于领域服务的内容下沉到应用服务里。我之前接手过一个订单系统下单逻辑全写在OrderApplicationService里面代码大概有800行。里面有参数校验、库存锁定、价格折算、优惠券校验、减库存、发消息……全混在一起。看起来功能也正常但一旦加需求就完蛋——比如“预售订单不允许使用优惠券”你要把这行代码加到哪加在优惠券校验那里可那里根本不知道订单是预售还是普通。然后你就在800行代码里上游下游各种传参改完心惊胆战。正确做法是下单这个动作的编排逻辑归应用服务管但“这单能不能这么下”的校验规则和扣减逻辑应该放到领域服务里再配合规约把各种规则解耦。2.2 什么情况下你必须用领域服务我总结了一个“三选一”判断标准只要满足其中之一强烈建议用领域服务动作横跨多个聚合比如转账涉及账户A和账户B两个聚合拆单涉及到订单聚合和物流聚合。动作依赖外部资源或服务比如做一次信用评估需要调第三方征信接口、风控系统。你要是不做领域服务让实体直接依赖接口实体的纯净性就废了。动作在领域里是一个“动词”但没有天然归属比如“对账”这个动作你很难说它属于账单还是属于支付单它天然就是一个领域服务。反过来如果一个操作只涉及单一聚合的内部状态变化而且不需要外部依赖那它就应该是聚合根的方法而不是领域服务的方法。比如“修改订单收货地址”这就是订单聚合自己的事塞进领域服务反而是过度设计。我在项目里给团队定的规矩很简单先试聚合方法塞不进去再考虑领域服务。领域服务不是你想用就能用的它同样是领域模型的一部分用多了会让模型空洞化。2.3 领域服务的完整示例我用一个电力采集系统的例子来演示。采集终端上报电量之后系统需要判定“这个终端上报的数据是否可信”——它涉及终端聚合、采集任务聚合、规约解析结果还要去查历史记录最合适的就是放进领域服务。Service public class MeterDataValidateService { private final MeterDataRepository meterDataRepository; private final TerminalRepository terminalRepository; private final CollectionTaskRepository taskRepository; public ValidateResult validate(CollectionTask task, MeterData rawData) { // 基础校验终端是否在线、是否在采集任务范围内 Terminal terminal terminalRepository.findByCode(rawData.getTerminalCode()); if (terminal null || !terminal.isActive()) { return ValidateResult.reject(终端离线或不存在); } // 规约组合校验连续多次上报电量偏差超过阈值则认为是异常 SpecificationMeterData deviationSpec new DeviationExceedSpec(terminal.getConfig().getThreshold()); SpecificationMeterData timeWindowSpec new ReportingTimeWindowSpec(task.getWindow()); SpecificationMeterData historyRepeatSpec new HistoryRepeatSpec(); SpecificationMeterData combinedSpec deviationSpec .and(timeWindowSpec) .and(historyRepeatSpec) .or(new ManualAuditSpec()); if (combinedSpec.isSatisfiedBy(rawData)) { return ValidateResult.pass(); } return ValidateResult.needReview(combinedSpec.remainReason()); } }注意这里面有两个耐人寻味的地方第一校验逻辑本身我没有堆在方法里而是交给了后面的规约对象——这就是服务和规约的组合打法。领域服务负责“整合需要什么数据”规约负责“规则到底是什么”。第二这个服务的状态完全是“无状态”的。我没有在服务里缓存任何中间结果因为领域服务的本质是“动作的载体”不是“状态的容器”。3. 规约模式把业务规则从if-else里解放出来3.1 什么是规约想到了一个特别准确的类比。搞电力行业的都知道104规约和101规约它们是电网调度系统里的通信规约。104规约定义了帧格式、报文结构、传输规则、状态机、超时参数T1/T2/T3。整套设备能互联互通不是因为大家约定好了“要通信”而是因为有这么一套被所有人共同遵守的、可验证的规则集合。DDD里的规约模式干的是同一件事把业务规则从“隐藏的if/else”提升为显式的、可命名的、可组合的领域对象。我曾经在一次电力项目周会上说“我们现在这套业务系统能不能像104规约一样把每一条业务规则都做成‘报文格式’那样的明示对象规则和规则之间可以按一定语法组合而不是散落在各个方法里谁也看不清”当时没人接话但我自己之后一直用这个思维建模。3.2 规约模式三个核心使命根据Martin Fowler对Specification模式的归纳它有三件事校验Validation检查一个对象是否满足某条规则。比如“该用户是否满足开信用卡的条件”。选择Selection/Query从集合中筛选出满足规则的对象。比如“查询所有满足风控条件的订单”。构建Construction根据规则生成一个新对象。比如“根据打折规则生成一张新的报价单”。这三个使命对应到代码里最核心的接口长这样public interface SpecificationT { boolean isSatisfiedBy(T candidate); default SpecificationT and(SpecificationT other) { return new AndSpecification(this, other); } default SpecificationT or(SpecificationT other) { return new OrSpecification(this, other); } default SpecificationT not() { return new NotSpecification(this); } }看到了吧规约的本质就是把规则对象化同时自带组合能力。一旦规则变成对象你就可以给它命名、给它复用、给它单元测试甚至放进列表遍历判断最后还可以组合成更复杂的规则树。我拿104规约报文来帮助理解104规约中每一帧报文都有类型标识如“总召唤”“遥测”“遥信”和限定词规约解析器先按类型标识分帧再按限定词分支处理。写出来的解析代码如果全是巨型if-else加一个新报文类型就要动老代码如果按规约模式每种报文就是一个规约对象解析器只需要遍历规约集合谁命中就用谁完全不用改老代码。3.3 一个容易误解的地方规约和策略模式的区别很多人会把规约模式和策略模式搞混。策略模式强调“同一目标算法可替换”比如排序策略、支付渠道策略规约模式强调“规则本身是业务概念且规则之间可组合、可校验”。策略模式是“怎么做”的替换规约模式是“是否满足”的判断。回到电力场景。不同的规约解析策略比如101规约解析、104规约解析、Modbus解析适合用策略模式因为它们是相互平行的不同算法而服务端要决定“这条报文是否合法、是否需要重发、是否需要告警”则适合用规约模式因为这类规则通常要叠加组合。理解这个区别后你的建模能力会上一个台阶。写代码前先问自己我要做的是“算法替换”还是“规则判断”前者找策略模式后者找规约模式。4. 实现规约的两种路线硬编码布尔判断 vs 可持久化查询规约模式的实现方法有讲究。如果只是内存对象校验直接“硬编码”即可。但如果规约还要承担“从数据库筛选”的职责你必须考虑怎么把规则转换为查询条件。4.1 纯内存规约适合校验和少量数据筛选一个典型的纯内存规约比如“大额订单规约”它只做判断public class LargeOrderSpec implements SpecificationOrder { private final Money threshold; public LargeOrderSpec(Money threshold) { this.threshold threshold; } Override public boolean isSatisfiedBy(Order candidate) { return candidate.getTotalAmount().compareTo(threshold) 0; } }这种规约实现简单、测试容易、语义清晰。缺点是不能直接翻译成SQL因为它跑在内存里。如果要筛选一万条订单全查出来再做内存判断性能是灾难。4.2 可查询规约让规则直接映射为查询条件对于查库筛选的场景我会让规约多一个“转SQL条件”的能力。这里我通常用两种方式规约里持有JPA Specification/MyBatis-Plus QueryWrapper规约本身附带查询构建细节。规约返回一个明确的条件对象由仓储层翻译成查询。第一种方式耦合了基础设施JPA/MyBatis很多人不喜欢但它确实最省事。第二种方式更纯净但需要额外建模一个查询条件对象。我个人的建议如果项目不追求100%的领域纯净性直接在规约里持有查询wrapper完全可接受。DDD是为了解决复杂度而生的不是为了给架构洁癖服务的。你完全可以在规约接口里定义toQuery()方法仓储层调用它拿到查询条件。这样规约对上层领域没有任何基础设施依赖又能和仓储无缝衔接。public interface SpecificationT { boolean isSatisfiedBy(T candidate); QueryCondition toQueryCondition(); // 可查询的前提 }QueryCondition是一个简单的DTO包含查询字段、操作符、值等仓储层拿到后翻译成具体数据库查询。4.3 组合规约的注意事项别为了组合而组合规约的and/or/not确实是卖点但实战里乱用组合会让代码不可读。我要求团队的规范是当组合数量超过三个必须给组合结果起一个业务名字封装成一个新的规约类而不是在调用方链式调用一大串。举个例子// 不推荐调用方又长又难读 spec new LargeOrderSpec(threshold) .and(new RiskControlPassedSpec()) .and(new ManagerApprovedSpec()) .and(new NotBlackListSpec()) .or(new EmergencyOrderSpec()); // 推荐组合细节被封装领域语义更强 public class NormalChannelOrderSpec implements SpecificationOrder { private final SpecificationOrder combinedRule new LargeOrderSpec(...) .and(new RiskControlPassedSpec()) .and(new ManagerApprovedSpec()) .and(new NotBlackListSpec()) .or(new EmergencyOrderSpec()); Override public boolean isSatisfiedBy(Order candidate) { return combinedRule.isSatisfiedBy(candidate); } }前者在代码里裸露出一串组合规则每次都重新组装一遍容易犯“漏一条”或“顺序错”的错。后者把组合命名为“正常渠道订单规约”看一眼就懂也只维护一处。这就像104规约里把复杂的状态转移矩阵封装成功能码一样外部只跟功能码打交道不跟底层数据结构打交道。5. 实战复盘用服务规约重构一个“报价单生成”案例理论说多了容易飘我直接用一个真实的库存报价场景串一遍完整重构过程。业务背景客户下采购询价单后系统要根据客户等级、产品分类、促销活动、历史成交价生成一份报价单。老代码的做法是报价应用服务里600多行各种if-else嵌套涉及新老客户判断、会员折扣、临时促销、低于成本保护等改一个规则要通读全方法。这段代码上线以来Bug率最高的地方就是它。5.1 第一步把行为放进领域服务“生成报价单”是典型跨聚合动作它涉及询价单、客户、商品、促销活动四个聚合还依赖外部价格策略配置。所以我建了一个QuotationService作为领域服务Service public class QuotationService { private final PricePolicyRepository pricePolicyRepository; private final QuotationRepository quotationRepository; Transactional public Quotation generateQuotation(Inquiry inquiry, Customer customer) { // 校验该询价单是否允许报价 customer.assertCanQuote(inquiry); // 组合规约判断报价是否有效 SpecificationQuotationContext effectiveSpec new EffectiveQuoteSpec(); QuotationContext ctx QuotationContext.of(inquiry, customer); if (!effectiveSpec.isSatisfiedBy(ctx)) { throw new QuotationNotAllowedException(effectiveSpec.reason()); } // 具体报价计算由另一套策略组合完成 Quotation quotation new QuotationBuilder() .baseOn(inquiry) .applyPolicies(pricePolicyRepository.findEffectivePolicies()) .build(); quotationRepository.save(quotation); return quotation; } }5.2 第二步把每条规则变成规约原来代码里对新老客户的判断、会员等级门槛、促销重叠限制、低价保护我全部改成了规约类。每个规约类都非常专一比如public class UserLevelSpec implements SpecificationQuotationContext { private final UserLevel minLevel; public UserLevelSpec(UserLevel minLevel) { this.minLevel minLevel; } Override public boolean isSatisfiedBy(QuotationContext ctx) { return ctx.getCustomer().getLevel().compareTo(minLevel) 0; } }然后再用组合规约把多个单一规则组合成可复用的“可报价规约”和“特殊审批规约”。这样区分的原因很直接两个产品形态标准流程和审批流程各自只需要自己关心的规则组合。5.3 第三步使规约可查询支撑列表筛选报价单列表页要根据“是否可报价”筛选这时候内存校验就不够了。我给规约加了一个toQueryCondition()让它可以翻译成数据库查询条件。public class UserLevelSpec implements SpecificationQuotationContext { ... Override public QueryCondition toQueryCondition() { return QueryCondition.of(customer.level, Op.GTE, minLevel); } }仓储层负责把这个QueryCondition翻译成MyBatis的QueryWrapper或JPA的Predicate。这样同一个规约既可以用在“单个报价校验”上也可以用在“列表筛选”上不变的是业务语义——这是老代码里绝对做不到的。全部重构完成之后报价模块的代码量从600行降到300行每个规约类都能独立测试。最有价值的是产品提需求的时候我们不再是“到哪里找代码改哪里”而是“新建一个规约类组合进去”就行。6. 踩过的坑和我的实操心得6.1 服务乱用比贫血模型更可怕的是“过度领域服务”见过一个团队连“计算订单总额”这种纯订单聚合行为都单独起一个领域服务类。订单聚合内聚性被彻底掏空变成一个只有getter的壳。领域服务的正确用法一定是动作没有天然宿主时才把它上升为服务。如果动作天然属于某个聚合——计算总额明显属于订单——就必须放在聚合里这是一个主权问题。务实建议先在实体聚合根里尝试做做不动再提取为领域服务这叫“先内聚后服务”。6.2 规约命名和放置位置不统一代码一样难维护规约模式最大的收益之一是“显式表达业务规则”但如果命名不规范收益会大打折扣。我经历过一个项目规约类的后缀五花八门有叫*Rule的有叫*Spec的有叫*Guard的还有叫*Validator的。一个新同事接手看代码根本不知道它们是同一类东西。建议团队里统一使用*Specification或*Spec后缀并放置在领域层的specification子包中。同时禁止在多个微服务之间共享同一个规约类的实体——不同服务可以拥有同名但不同实现的规约因为上下文不同规则细节会有差异。6.3 可查询规约别过度抽象KISS原则优先我曾试图做一套通用可查询规约框架把规约直接解析为所有ORM的查询条件结果花了三周最后被复杂到不可维护的翻译层拖垮。后来整个推倒重写只保留了一套轻量QueryCondition简单直接问题全解决了。做DDD和规约模式最容易犯的错误就是“为了模式而模式”。规约模式是为了让业务规则的变化点可视化、可控化不是为了引入一套全新的查询框架。既然JPA Specification和MyBatis QW已经提供了查询组合能力规约层只需要做极薄的适配就足够了。6.4 最后一个小建议从最容易失控的模块开始改造如果让我给你一个最务实的落地路径我会说别试图一次性把所有Service都重构成服务规约模式。选中一个最容易失控的业务模块比如审批流、报价、风控等先把规则全部抽取成规约把跨聚合动作归拢到领域服务打通一个完整闭环让团队其他人看到前后的对比。有了样板后面的推进自然水到渠成。我在不同项目里试过这个路径每次都是这么做才真正落地的。