ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MVC与DDD:从贫血模型到领域建模,Service层失控的解法

MVC与DDD:从贫血模型到领域建模,Service层失控的解法 1. 先说结论MVC和DDD根本不是同一个维度的东西1.1 “有了MVC为什么还要DDD”这句话本身就是概念错位大概两年前我在公司技术群里看到有人吐槽Java 后端干了三年Spring Boot MyBatis 的 Controller-Service-DAO 三件套用得挺顺手没事学什么 DDD底下点赞一片。我当时也点了赞。后来订单模块把我折腾得连续两周加班我才发现自己当年的点赞有多天真。先把这个最基础的问题掰开揉碎说清楚MVC 和 DDD 放在对立面比较属于概念错位。MVC 是一种架构模式关心的是界面和逻辑怎么分离、请求怎么流转。Controller 管入口和应答Service 管处理DAO 管数据访问这是按“技术职责”把代码横着切成了三层。DDD 是一种建模方法论关心的是“业务规则到底怎么建模、放在哪里”限界上下文怎么划、实体和值对象怎么设计、聚合边界在哪这是按“业务能力”把代码竖着切成了一个个域。一个是横切刀一个是纵切刀根本不是一回事。所以“有了 MVC 为什么还要 DDD”这句话就像问“有了钢筋水泥为什么还要户型图”。施工队知道怎么砌墙、怎么排管线但不知道这套房子要设计成几室几厅、卫生间放哪、厨房和餐厅怎么衔接。后端项目也一样MVC 保证了请求-处理-响应这条链路干净通畅但“业务规则该放哪个对象、哪些规则必须内聚在一起”这个灵魂问题MVC 完全没有回答。它只给了你三个抽屉至于哪个抽屉放什么靠的是社区惯性和项目经理一句“全写在 Service 里”。1.2 技术分层管不到的地方恰恰是复杂度失控的地方不管你是用 Spring Boot、Gin 脚手架还是别的 MVC 框架只要底层是这种三层结构Service 层就天然成了“业务垃圾场”。一开始在 Service 里写几个 CRUD 方法很轻松但业务一复杂起来库存校验、价格计算、状态流转、消息发送、外部系统调用全往一个方法里堆。这时候你会发现MVC 并没有做错什么它只是把一个它本不该回答的问题留给了程序员。这也是为什么“DDD 搞不懂”能成为热搜词。DDD 不教你用哪个框架不教你设计表结构它逼着你先想清楚业务本身。对于习惯了“Controller 调 Service、Service 调 Mapper”的同学这个思维转换非常痛苦。但痛苦归痛苦等真把业务想清楚了你会回头看那些“挺顺手”的分层代码其实早就埋下了大坑。2. 贫血模型MVC项目里Service层失控的根源2.1 贫血模型是怎么养出来的聊 DDD 绕不开“贫血模型”这个词。很多团队业务代码写了好几年却从来没意识到自己一直在贫血模型里打转。它的养成路线基本是一致的第一步看着数据库表建实体类order 表对应一个 Order 类字段一一映射第二步lombok 一键生成 getter/setter实体变成纯粹的数据容器第三步所有业务逻辑写进 Service校验、计算、发消息全部堆在 Service 方法里。这套流程IDE 帮你流水线化完成所以大部分人不觉得别扭。但 Martin Fowler 早就给这种写法起了名字贫血领域模型并且直接定性为反模式。反在哪里反在业务规则不在它本该存在的领域对象里。贫血模型下实体只剩数据没有行为业务逻辑全在 Service 层。短期看好处很明显——写起来快、思路直、CRUD 一遍过。但代价是业务规则被拆成 Service 里零散的语句无法在对象上表达也无法复用。一旦业务量上来这个隐患就会变成事故。2.2 一个Service方法从10行长到300行的完整经过用一个电商系统下单场景来还原这个过程。一开始方法长这样Service public class OrderService { Transactional public Long createOrder(Long userId, Long productId, int quantity) { Product product productMapper.findById(productId); Stock stock stockMapper.findByProductId(productId); if (stock.getAvailable() quantity) { throw new BusinessException(库存不足); } double total product.getPrice() * quantity; Order order new Order(); order.setUserId(userId); order.setProductId(productId); order.setQuantity(quantity); order.setTotal(total); order.setStatus(CREATED); orderMapper.insert(order); stockMapper.deduct(productId, quantity); return order.getId(); } }20 行上下很清爽。但业务是活的。第二个月产品说“满 10 件打 95 折”你加了一段折扣计算第三个月运营说“订单满 1000 元免运费”你加了一段运费判断第四个月销售说“大客户订单走审批流”你加了个 if (isVip) 分支第五个月订单创建后要发优惠券、发短信、同步 ERP、生成履约单……等我一年后再看这个方法它已经膨胀到 300 多行圈复杂度逼近 50。我试着列了一下里面混杂的东西库存校验和扣减、价格计算与折扣、运费规则、审批流程分支、消息发送、外部系统同步、各种补丁式修复留下的 if 嵌套。问题接踵而至这段逻辑没法单测方法里全是副作用你没法单独验证“折扣计算”这件事它也没法复用另一个 Service 想调用折扣计算只能把逻辑抽出来抽来抽去就成了工具类大杂烩改起来更是危险动一下库存扣减可能影响审批流没人说得清全局影响。这就是 MVC 项目里 Service 层失控的完整路径。MVC 没说 Service 该怎么组织于是你靠“一个需求改一个方法”硬扛。扛到系统变成一座屎山然后开始找原因是当初表设计不好是需求变化太频繁其实都不是是一开始就没把业务逻辑建模出来让它在贫血模型里到处流浪。3. 充血模型与聚合设计让业务逻辑回到它该待的地方3.1 实体、值对象、聚合三个最容易混淆的概念DDD 战术设计里最核心的三个对象类型是实体、值对象和聚合。实体Entity有唯一标识、有生命周期、状态会发生改变的对象。订单就是一个典型实体它有订单号状态从 CREATED 到 PAID 再到 SHIPPED你关心的是“这是哪一笔订单”以及它的状态流转。在充血模型下实体会主动承载自己的行为public class Order { private OrderId id; private OrderStatus status; private ListOrderItem items; public void pay() { if (this.status ! OrderStatus.CREATED) { throw new IllegalStateException(订单状态不允许支付); } this.status OrderStatus.PAID; // 支付相关规则全部内聚在这里 } }值对象Value Object没有唯一标识、不可变、完全靠属性来描述的对象。两个订单的金额只要数值一样业务上就是同一个值对象。金额、地址、电话号码、时间段都是典型的值对象。比如金额很多老项目用 double 或者 long 存打折、返现在 Service 里来回来去 new BigDecimal样板代码一堆还容易埋精度坑。如果一开始建模成值对象public final class Money { private final BigDecimal amount; private final Currency currency; public Money(BigDecimal amount, Currency currency) { this.amount amount; this.currency currency; } public Money add(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException(不同币种不能相加); } return new Money(this.amount.add(other.amount), this.currency); } // equals/hashCode 基于金额和币种 }加减乘除比较的规则都收敛在类里Service 层立刻少了一大堆重复计算代码。聚合Aggregate一组必须保持业务一致性的对象集合有一个聚合根作为统一入口外部不能直接操作集合内部的子对象。订单聚合由订单、订单项、收货地址组成订单是聚合根。你只能通过order.addItem(product, quantity)去增加商品而不能直接修改order.getItems().get(0)的数量因为后者会绕过订单自身的完整性校验。3.2 聚合边界决定事务边界聚合里最关键的思想是“边界”。一个聚合内部的对象必须保证强一致性聚合与聚合之间允许最终一致。这件事直接对应到数据库事务设计。拿下单举例涉及库存扣减和订单创建。传统思路是下意识放进一个事务先扣库存再建订单一个事务搞定。但实际上扣库存这种操作在并发场景下要加锁、大量写入订单创建也要频繁插入两者放一个事务里锁冲突和事务时长都会成倍增长。从领域上看“库存”和“订单”分属不同聚合它们之间不需要同一个数据库事务保证强一致——只需要先扣库存订单创建成功后再通过事件或服务调用处理由最终一致性兜底。如果你一开始用聚合思维思考你会先问库存和订单在一个聚合边界内吗大概率不是。于是你会自然选择“本地事务只保证订单聚合库存扣减通过领域事件或远程调用处理”而不是硬塞进一个长事务。这个决策不需要架构师拍脑袋建模建清楚了边界自然浮出来。聚合边界的另一个价值是防止模型被引用得乱七八糟。如果订单项可以被 Service 直接 new 出来、随便改数量那 Order 的“总价等于所有订单项价格之和”这个不变量就守不住。把订单项隔离在聚合内部只通过聚合根操作业务不变量才守得住。3.3 领域服务实体装不下时的解药实体的行为是按对象组织的但有些业务动作天生不属于某个实体。比如转账fromAccount 和 toAccount 两个账户钱从 A 转到 B扣钱方法放 A、加钱方法放 B 都不够中立。这时候需要领域服务public class TransferService { public void transfer(Account from, Account to, Money amount) { from.freeze(amount); to.credit(amount); // 跨聚合的业务规则校验额度、登记流水 } }注意这里的领域服务和贫血模型里的 Service 完全不同。DDD 的领域服务是“瘦”的它只负责编排需要多个聚合协作的领域动作不负责数据库读写、消息发送这些基础设施操作。真正能放到实体上的规则都应该放到实体上放不下的、跨越多个聚合的才轮到领域服务。它像一个裁判尽量不抢实体的戏。这三样东西组合起来解决的核心问题只有一个让业务规则待在最合适的位置不再挤在一个 300 行的 Service 方法里。即便你的项目整体不套用完整 DDD只要学会这一层也能把业务代码从泥潭里救出来一大截。4. 限界上下文比实体和值对象更值钱的战略设计4.1 同一个“商品”在不同的上下文里根本不是同一个东西很多团队学 DDD上来就啃实体、值对象、聚合觉得这就是 DDD 的全部。其实真正让 DDD 区别于“UML 画图课”的是战略设计里的限界上下文。这个词有点抽象我用电商里的“商品”举例你立刻会明白。在“商品搜索”上下文里商品是一个展示对象有标题、主图、价格标签核心是“怎么展示得更吸引人”在“库存”上下文里商品是一个库存条目有关系复杂点的 SKU、可用库存、锁定库存核心是“够不够卖、什么时候补货”在“下单”上下文里商品是价格和购买约束的来源核心是“靠它算钱、卡购买规则”在“物流”上下文里商品又成了包裹里的一件货关心重量、体积、是否易碎。如果强行建一个全公司统一的 Product 模型给它挂上展示字段、库存字段、物流字段、价格策略字段会怎么样无数个 if (bizType SEARCH || bizType STOCK) 的判断每次都从大泥球里挑自己需要的字段改一个上下文的需求顺手把另一个上下文弄崩。限界上下文就是来治这个病的它在模型之间画边界每个上下文里的模型只对自身负责该独有的字段绝不共享。4.2 防腐层、开放主机服务上下文之间怎么通信有了边界不代表老死不相往来。订单要查库存商品要同步到搜索都是跨上下文协作。DDD 常用的协作方式有几种。防腐层Anti-Corruption Layer当下游系统不希望上游系统的模型“污染”自己时在边界上做一层转换和保护。比如订单上下文调用库存上下文不直接让库存的 InventoryItem 对象穿过边界闯进订单领域层而是通过防腐层接口把库存数据翻译成订单上下文需要的可售量对象。翻译隔离在边界上内部模型保持干净。开放主机服务Open Host Service 发布语言Published Language上游把自己的能力发布成稳定的 API 或领域事件下游只通过这个稳定接口消费不直接访问上游数据库。这也是微服务拆分后最常见的通信方式。共享内核Shared Kernel两个上下文共享一部分模型但范围要尽量小。现实中很容易退化成“全公司用一个 Model 对象”那就违背初衷了。这些概念听起来有点学院派但工程价值非常大。很多跨团队接口扯皮、A 系统改字段 B 系统崩溃根源都是没划好上下文边界。DDD 的战略设计就是提前用一张“地图”把系统和团队之间的边界画清楚让技术实现有据可依。4.3 限界上下文与MVC是正交关系到这里可以回答最开始那个困扰了限界上下文和 MVC 是什么关系答案是正交。MVC 是系统内部的代码分层限界上下文是系统之间的业务边界。你可以把系统拆成订单上下文、库存上下文、支付上下文每个上下文内部依然用 MVC 三层组织代码Controller 管请求Service 管用例DAO 管持久化。DDD 在这里做的事是让每个上下文内部的模型更内聚、上下文之间的耦合更可控。换句话说DDD 不是要推倒 MVC而是给 MVC 的 Service 层指出一条“内部如何组织”的路。没有 DDD 的 MVC 项目Service 是业务代码的堆料场有了 DDD 的 MVC 项目Controller 之下会多出一层“领域层”Service 瘦身成只负责用例编排的应用服务。这也是“DDD 是 MVC 补强而非替代”这句话的真正含义。5. 什么业务真正需要DDD什么业务会被DDD拖死5.1 三个适用信号第一个信号业务规则复杂且经常变化。如果你的核心业务不是“增删改查的搬运工”而是有大量状态流转、业务校验、价格计算、规则判断并且这些规则每隔几个迭代就要变一次那你需要一个清晰的领域模型帮你把变化的规则收敛到明确对象里。否则每条新规则都会变成一个 if 分支慢慢塞满 Service。第二个信号多人协作共同维护同一套核心系统。当三五个后端同时在一个订单模块里加需求如果没有领域边界每次合并代码都是噩梦。DDD 的限界上下文和聚合边界给每个人划了清晰的“责任田”——改你的聚合不动我的聚合冲突自然少。第三个信号系统需要长期演进。如果是核心业务系统要运行五到十年、持续迭代前期建模的投入完全值得。DDD 真正的回报不在第一个月而在系统跑了两三年之后。反过来一次性交付的原型、外包站、临时活动系统根本不需要考虑长期演进别上 DDD。5.2 哪些项目别赶这个时髦生搬硬套 DDD 的项目我见过不少。最典型的是后台管理类系统配置管理页面、报表查询、权限管理本质是表数据的 CRUD。这种项目硬上 DDD你会发现要花大量时间做领域建模、设计聚合、写仓储接口最后做出来的东西和 MVC CRUD 一模一样只是代码量翻倍、交付周期翻倍、同事骂声翻倍。还有一种场景也容易踩坑团队里没有真正的领域专家产品经理是传话筒团队从头到尾闭门造车、不拉着业务方做建模。DDD 的第一原则是“业务驱动技术设计”如果连业务本身都没人讲得清建模建得再漂亮也是空中楼阁。我见过有团队在报表系统里硬上 DDD建模建了两周需求十分钟列完结果被交付压力压垮领导觉得上了一堆没用的东西。5.3 一句话判断标准我自己总结了一个特别简单的判断标准打开你的 Service 类如果里面大多是“查一张表、改两条记录、调一个接口”的流水账那 MVC 完全够用如果每加一个新规则就要动老方法的逻辑改动点越来越多、影响范围越来越难评估那就是领域逻辑在呼唤建模。不要把“DDD 听起来高级”当成上它的理由。工具匹配场景这比什么都重要。6. 渐进式落地在现有MVC工程中引入DDD的三条路径6.1 第一步先做领域建模不写一行代码最稳妥、成本最低的第一步是纯建模。找一个待迭代的大模块把产品经理、后端、前端叫到一起画一张大的限界上下文地图系统有哪些上下文每个上下文的核心概念是什么哪个是核心域、哪个是支撑域、哪个是通用域这一步不写代码产出就是一张地图和几个高内聚的领域概念。但它的价值往往被低估。以前大家讨论需求时各说各话“商品”到底指什么都讲不清边界一画很多争论戛然而止。即便此后一行 DDD 代码不写团队沟通效率都能明显提升。这也是为什么我一直推荐从这步开始——建模本身的收益是独立于技术框架的。6.2 第二步给核心实体加行为建模完成后选一个最让你头疼的核心实体通常是订单、客户、商品这类开始做充血模型改造。把 Service 里属于实体自身的校验、计算、状态流转逻辑一个一个搬进实体方法。下单逻辑重构的对比很直观。重构前贫血模型逻辑全在 Servicepublic Long createOrder(Long userId, Long productId, int quantity) { Product product productMapper.findById(productId); Stock stock stockMapper.findByProductId(productId); if (stock.getAvailable() quantity) { throw new BusinessException(库存不足); } double total product.getPrice() * quantity; if (quantity 10) { total total * 0.95; } // 扣库存、发消息、插入订单…… return order.getId(); }重构后充血模型Service 只做编排Transactional public OrderId createOrder(UserId userId, ProductId productId, int quantity) { User user userRepository.find(userId); Product product productRepository.find(productId); Order order user.placeOrder(product, quantity); // 业务规则全在内部 orderRepository.save(order); return order.getId(); }代码变短只是表象真正的变化是库存校验、折扣计算、金额合计这些规则成了 Order 对象自身固化的行为。任何地方调用 placeOrder规则都会一致执行不会再因为换一个 Service 方法就忘加折扣。6.3 第三步用Application Service瘦身把指挥棒收回来实体收编了大部分规则之后原来的 Service 会瘦一大圈。此时它更准确的角色是应用服务Application Service接收 Controller 的请求参数组装领域对象调用一个或多个领域方法协调事务最后返回结果。这里有个容易搞混的点事务放在哪一层我的实践是应用服务是事务边界最合适的承载者因为它编排了若干领域操作需要统一用一个事务包住才能保证一致性。领域层则尽量避免直接操作连接和事务保持领域对象纯粹。这不是教条而是职责分明后自然得出的结论。6.4 落地后的分层长什么样渐进式改完之后工程目录会从“Controller / Service / Mapper”三层逐渐演变成四层接口层InterfaceController、DTO 汇聚地负责 HTTP 参数解析、响应封装。应用层ApplicationApplicationService负责用例编排、事务、权限校验。领域层Domain实体、值对象、聚合根、领域服务、仓储接口这是 DDD 的核心资产也是业务逻辑的家。基础设施层InfrastructureMapper、外部 API client、MQ 发送实现实现仓储接口的地方。这就是经典的洋葱架构或六边形架构思想的落地。它没有抛弃 MVC 的 Controller 和分层思想只是改变了 Service 的定位并在 Service 和 Mapper 之间插入一个真正承载业务的领域层。按这个路径走完MVC 骨架还在业务逻辑却终于回到了它该待的地方。7. 实战踩坑我建议你先避开这四个大坑7.1 只学战术不学战略模型还是糊成一团这是我第一次带团队做 DDD 犯的错。当时我们把实体、值对象、聚合、仓储都按书上模板搭好了代码结构确实“看起来很 DDD”。但没过多久订单上下文和用户上下文共用了一个 User 模型订单上下文和支付上下文又共用了一个 Payment 模型两个团队改着改着就撞车。原因很简单跳过了限界上下文这个战略步骤直接拿战术工具往老代码上套等于没画户型图就开始砌墙。后来补做上下文梳理、把共用模型拆开才真正缓解冲突。我的建议很直接无论项目多小开工之前先花半天把上下文地图画出来。这一步省了后面大概率加倍偿还。7.2 把实体塞成了几千行的垃圾桶另一个极端是学了充血模型之后恨不得把世界上所有逻辑都塞进实体。有同事把 Order 实体写到 2000 多行包含库存校验、物流对接、消息通知、优惠券计算……看起来很内聚其实是一个新垃圾场。复盘后才明白实体只应该承载与自身状态和身份强相关的规则比如订单金额重算、状态流转。跨实体的协作比如下单后发优惠券、跟外部系统对接的逻辑都不该塞进实体应该放到领域服务或应用服务。判断标准很简单如果一段逻辑不需要改变 Order 自身的任何属性它大概率就不属于 Order。7.3 仓储做成DAO的换皮白搭了一层抽象第三个坑是仓储实现。刚开始我们设计的 Repository 接口和 Mapper 几乎一一对应每个实体一个 Repository方法名都是 findByXxx、updateXxx、insertXxx。最后效果是白抽一层接口没有任何额外价值反而多了一堆抽象。DDD 的仓储核心职责是“持久化聚合”对外提供按聚合根存取的能力。它关注的是聚合整体不是一张表一条记录。比如 OrderRepository 应该有成对的方法save(Order)和findById(OrderId)至于 Order 内部几个订单项怎么落库那是基础设施层实现的细节。仓储接口的设计整体要以领域模型为准而不是以表结构为准。这是“技术倒置”和“领域驱动”之间的分水岭做对这一步仓储才有意义。7.4 为了DDD而DDD最后死于交付压力最后一个大坑也是我最想提醒的如果接手的是短期项目或者是典型的 CRUD 后台请克制住用 DDD 的冲动。DDD 是手段不是目的它服务于复杂业务规则的可维护性而不是服务于“代码结构看起来很高级”。工具匹配场景这句话在 DDD 这里尤其适用。个中甜头我也是在订单模块被逼到墙角之后才尝到的。Ddd 这些年越来越流行但也越来越被神化。有人把它当银弹有人把它当洪水猛兽。踩过坑之后我对它的态度很朴素它不是一套必须全套照搬的框架而是一面镜子逼你正视“业务逻辑到底该放哪儿”这个问题。哪怕最终一个聚合都没建、一个仓储都没写只要你在写代码前愿意画一遍上下文边界、愿意把散落的规则收敛到实体上你的 MVC 项目就已经从 DDD 里受益了。这大概就是“有了 MVC 还是值得一学”的真正理由。
返回列表