上一篇文章中,我们先简单聊了一下 DDD 是什么,以及为什么要学习领域驱动设计。
不过上一篇里面已经提到了不少 MVC 项目常见的问题,比如 Service 越来越胖、业务规则散落、数据库驱动开发等等。
所以这一篇就不重复讲 DDD 的概念了,我们把视角收回来,先认真看一下我们最熟悉的 MVC 三层架构。
为什么要先讲 MVC?
因为大多数 Java 后端项目,基本都是从这一套架构开始的。只有先看清楚 MVC 三层架构到底解决了什么问题、又在哪些场景下开始吃力,后面再聊贫血模型、充血模型、领域模型、聚合根的时候,才不会觉得 DDD 是在硬凑概念。
这一篇只做一件事:从日常开发角度,把 MVC 三层架构捋一遍。
一、MVC 三层架构是什么
我们平时说的 MVC 三层架构,在 Java Web 项目里面一般会落成下面这种结构:
controller service mapper entity dto vo如果项目稍微规范一点,可能会再细分一下:
controller service service.impl mapper entity dto vo query config从请求链路看,大概就是这样:
简单来说:
- Controller:负责接收请求和返回结果;
- Service:负责处理业务逻辑;
- Mapper / Dao:负责和数据库打交道;
- Entity:一般对应数据库表;
- DTO / Query:一般用于接口入参;
- VO:一般用于接口出参。
比如一个最简单的用户新增接口,基本就是下面这个流程。
Controller 接收前端请求:
@RestController@RequestMapping("/users")publicclassUserController{privatefinalUserServiceuserService;publicUserController(UserServiceuserService){this.userService=userService;}@PostMappingpublicLongcreateUser(@RequestBodyUserCreateDTOrequest){returnuserService.createUser(request);}}Service 处理用户新增逻辑:
@ServicepublicclassUserServiceImplimplementsUserService{privatefinalUserMapperuserMapper;publicUserServiceImpl(UserMapperuserMapper){this.userMapper=userMapper;}@OverridepublicLongcreateUser(UserCreateDTOrequest){Useruser=newUser();user.setUsername(request.getUsername());user.setPhone(request.getPhone());user.setStatus(1);userMapper.insert(user);returnuser.getId();}}Mapper 负责落库:
publicinterfaceUserMapper{intinsert(Useruser);}这就是我们最常见的一套写法。
对于简单业务来说,这样写没有任何问题。代码路径很短,请求从 Controller 进来,到 Service 处理,再到 Mapper 落库,最后返回结果。
定位问题也很直接。
接口不通,看 Controller。
业务结果不对,看 Service。
SQL 有问题,看 Mapper。
这也是 MVC 三层架构非常受欢迎的原因。
二、MVC 为什么好用
讲 DDD 之前,我们得先承认一件事:MVC 三层架构真的很好用。
尤其是对大多数后台管理系统来说,它简单、直接、成本低。
1. 学习成本低
Controller、Service、Mapper 这几个名字,本身就很容易理解。
新人接手项目时,只要知道:
- 请求先到 Controller;
- 业务逻辑写在 Service;
- 数据库操作写在 Mapper;
基本就能开始看代码。
这点很重要。
很多项目不是一个人从头写到尾,而是中间不断有人加入、离开、交接。结构太复杂的话,新人光理解工程分层就要花很久。
MVC 的优势就在于,它足够直观。
2. 开发效率高
对于简单 CRUD 来说,MVC 的开发效率非常高。
比如后台常见的这些功能:
- 用户管理;
- 角色管理;
- 菜单管理;
- 字典配置;
- 参数配置;
- 操作日志查询。
这类功能大多数就是单表或者少量关联表的增删改查。
这时候我们直接写 Controller、Service、Mapper,很快就能把接口做出来。
如果这种业务也强行拆领域模型、聚合根、仓储、防腐层,反而会显得很重。
所以不是一讲 DDD,就要否定 MVC。
简单业务用 MVC,本身就是很合适的。
3. 技术职责清晰
MVC 至少帮我们做了一件很重要的事情:把不同技术职责分开了。
比如:
- Controller 不直接写 SQL;
- Mapper 不关心 HTTP 请求;
- Service 不直接处理前端响应格式;
- DTO 和 VO 可以隔离入参、出参。
虽然这种分层还不是业务维度的分层,但在工程维度上已经足够清楚。
如果团队规范执行得好,Controller 保持薄一点,Service 方法不要乱写,Mapper 只负责数据库操作,一个普通系统完全可以维护得不错。
三、一个需求在 MVC 里是怎么落地的
接下来我们换一个更真实一点的角度。
平时我们接到一个需求,大概会经历下面这个过程:
当然,真实工作里不一定每一步都这么标准,但大体差不多:
- 产品提出需求;
- 后端理解需求;
- 设计数据库表;
- 写 Entity、Mapper;
- 写 Service 业务逻辑;
- 写 Controller 接口;
- 前后端联调;
- 测试、上线。
这个流程看起来非常自然。
比如现在要做一个“创建订单”的接口,我们很容易先想到表:
order order_item product user然后根据表写 Entity:
publicclassOrder{privateLongid;privateStringorderNo;privateLonguserId;privateBigDecimaltotalAmount;privateIntegerstatus;}再写 Mapper:
publicinterfaceOrderMapper{intinsert(Orderorder);OrderselectById(Longid);}最后把逻辑写到 Service:
@Transactional(rollbackFor=Exception.class)publicLongcreateOrder(CreateOrderDTOrequest){// 查询商品Productproduct=productMapper.selectById(request.getProductId());if(product==null){thrownewIllegalArgumentException("商品不存在");}// 校验库存if(product.getStock()<request.getQuantity()){thrownewIllegalStateException("库存不足");}// 计算金额BigDecimaltotalAmount=product.getPrice().multiply(BigDecimal.valueOf(request.getQuantity()));// 保存订单Orderorder=newOrder();order.setOrderNo(orderNoGenerator.next());order.setUserId(request.getUserId());order.setTotalAmount(totalAmount);order.setStatus(1);orderMapper.insert(order);// 扣减库存productMapper.deductStock(request.getProductId(),request.getQuantity());returnorder.getId();}这段代码在早期版本里其实挺清楚的。
它从上到下完成了下单流程:
- 查商品;
- 校验库存;
- 算金额;
- 保存订单;
- 扣减库存。
如果需求就到这里结束,那问题不大。
但是业务往往不会停在这里。
四、MVC 的问题通常不是一开始就出现的
MVC 项目的问题,一般不是第一天就出现。
刚开始的时候,大家都写得挺规矩。
Controller 很薄,Service 方法也不长,Mapper 只写 SQL,代码看起来清清爽爽。
真正的问题通常出现在项目持续迭代之后。
还是下单接口,后面业务可能会继续加:
- 优惠券;
- 积分抵扣;
- 会员折扣;
- 满减活动;
- 预售商品;
- 虚拟商品;
- 风控校验;
- 渠道差异;
- 下单后发送消息;
- 下单后同步第三方系统。
这时候我们很自然会继续往 Service 里面加逻辑:
@Transactional(rollbackFor=Exception.class)publicLongcreateOrder(CreateOrderDTOrequest){// 查询用户Useruser=userMapper.selectById(request.getUserId());if(user==null){thrownewIllegalArgumentException("用户不存在");}// 查询商品Productproduct=productMapper.selectById(request.getProductId());if(product==null){thrownewIllegalArgumentException("商品不存在");}// 校验库存if(product.getStock()<request.getQuantity()){thrownewIllegalStateException("库存不足");}// 计算基础金额BigDecimaltotalAmount=product.getPrice().multiply(BigDecimal.valueOf(request.getQuantity()));// 处理优惠券if(request.getCouponId()!=null){Couponcoupon=couponMapper.selectById(request.getCouponId());if(coupon==null||!coupon.canUse()){thrownewIllegalStateException("优惠券不可用");}totalAmount=totalAmount.subtract(coupon.getDiscountAmount());}// 处理会员折扣if(user.getMemberLevel()!=null){totalAmount=calculateMemberDiscount(totalAmount,user.getMemberLevel());}// 风控校验riskClient.checkOrder(request.getUserId(),totalAmount);// 保存订单Orderorder=newOrder();order.setOrderNo(orderNoGenerator.next());order.setUserId(user.getId());order.setTotalAmount(totalAmount);order.setStatus(1);orderMapper.insert(order);// 扣减库存productMapper.deductStock(product.getId(),request.getQuantity());// 发送消息orderMessageProducer.sendOrderCreatedMessage(order.getId());returnorder.getId();}这段代码也不是不能跑。
甚至很多线上系统就是这么跑的。
问题是,它已经开始把很多不同性质的逻辑揉在一起了:
- 用户校验;
- 商品校验;
- 库存规则;
- 金额计算;
- 优惠券规则;
- 会员折扣规则;
- 风控调用;
- 订单保存;
- 库存扣减;
- MQ 消息发送。
这里面有业务规则,也有技术动作。
有当前系统内部逻辑,也有外部系统调用。
有核心下单逻辑,也有下单后的附加动作。
短期看,这样写最快。
长期看,这个 Service 方法会越来越不好改。
五、Service 变胖之后会带来什么问题
Service 变胖以后,最明显的问题不是代码行数多,而是职责开始混在一起。
1. 改一个规则,要看一堆上下文
比如现在要改优惠券规则。
理论上我们只想改“优惠券怎么使用”。
但如果优惠券逻辑直接写在下单 Service 里面,那我们就必须顺着整个下单流程看:
- 优惠券是在会员折扣前使用,还是后使用;
- 优惠券金额会不会影响风控校验;
- 优惠券使用失败时,库存有没有被扣;
- 优惠券状态什么时候更新;
- 事务回滚会不会影响优惠券。
也就是说,一个局部规则被写进了大流程里,后面每次修改都要理解整段流程。
这就是维护成本。
2. 类似逻辑容易复制粘贴
再比如创建订单、预创建订单、再次购买、购物车结算,这几个功能可能都需要计算金额。
如果一开始没有抽出清晰的金额计算逻辑,后面很容易出现这种情况:
- A 方法复制了一份金额计算;
- B 方法复制后改了一个 if;
- C 方法又补了一个特殊渠道判断;
- D 方法漏了某个优惠规则。
时间一长,同一个业务含义,在不同地方有不同实现。
这时候修 Bug 就会很难受。
你以为修了一处,结果另一处还有旧逻辑。
3. 外部系统变动会影响内部业务
Service 里直接调外部系统也很常见。
比如:
UserInfoDTOuserInfo=userClient.getUserInfo(request.getUserId());如果用户服务返回字段改了,本系统的下单逻辑就要改。
如果类似调用散落在多个 Service 里,那改动范围会更大。
这也是为什么后面 DDD 会讲防腐层。
不过这一篇先不展开,先记住一个问题就行:外部系统的变化,不应该大范围污染内部业务逻辑。
4. 业务语言越来越弱
还有一个问题比较隐蔽,就是代码里的业务语言会越来越弱。
比如我们经常会看到这些方法:
handleOrder processOrder updateStatus saveData doCreate这些名字不是不能用,但业务含义比较模糊。
方法里面可能什么都干。
一旦代码里到处都是handle、process、updateStatus,后面接手的人就只能点进去看实现。
如果代码能直接表达业务动作,比如:
createOrder cancelOrder payOrder confirmReceipt applyRefund理解成本就会低很多。
DDD 后面强调的“通用语言”,其实也和这个有关。
不过通用语言、限界上下文这些内容会放到战略设计部分讲,这里先不提前展开。
六、MVC 本身没有告诉我们业务边界在哪里
说到底,MVC 主要解决的是工程分层问题。
它告诉我们:
- 请求入口放 Controller;
- 业务流程放 Service;
- 数据访问放 Mapper。
但它没有明确告诉我们:
- 哪些业务规则应该放在一起;
- 哪些逻辑应该隔离;
- 哪个对象应该负责哪个行为;
- 外部系统变化应该怎么隔离;
- 一个状态流转规则应该由谁保证。
这些问题在简单业务里不明显。
因为简单业务就算都写在 Service 里,也不会太乱。
但是业务复杂之后,如果所有规则都默认放进 Service,Service 就会变成一个万能容器。
这也是上一篇文章说的:真正麻烦的不是技术框架,而是业务逻辑没有被管理好。
七、那 MVC 项目是不是就不能维护了
当然不是。
很多 MVC 项目只要做好基本规范,也可以维护得很好。
比如:
- Controller 不写业务逻辑;
- Service 方法不要过长;
- 复杂逻辑及时抽方法;
- 相同业务规则不要复制粘贴;
- 外部系统调用做一层封装;
- 状态值用枚举表达;
- 查询和命令逻辑适当分开;
- 关键业务补单元测试。
这些做法不一定非要上 DDD 才能做。
比如我们可以先把金额计算抽出来:
privateBigDecimalcalculateOrderAmount(Productproduct,Useruser,CreateOrderDTOrequest){BigDecimaltotalAmount=product.getPrice().multiply(BigDecimal.valueOf(request.getQuantity()));if(request.getCouponId()!=null){Couponcoupon=couponMapper.selectById(request.getCouponId());if(coupon==null||!coupon.canUse()){thrownewIllegalStateException("优惠券不可用");}totalAmount=totalAmount.subtract(coupon.getDiscountAmount());}if(user.getMemberLevel()!=null){totalAmount=calculateMemberDiscount(totalAmount,user.getMemberLevel());}returntotalAmount;}这样至少主流程会清楚一点:
publicLongcreateOrder(CreateOrderDTOrequest){Useruser=getUser(request.getUserId());Productproduct=getProduct(request.getProductId());checkStock(product,request.getQuantity());BigDecimaltotalAmount=calculateOrderAmount(product,user,request);Orderorder=saveOrder(user,product,request,totalAmount);deductStock(product,request.getQuantity());sendOrderCreatedMessage(order.getId());returnorder.getId();}这依然是 MVC 写法,但可读性已经比一大坨代码好很多。
所以我的理解是:DDD 不是 MVC 项目失控之后唯一的救命药。
在业务还没有复杂到一定程度之前,先把 MVC 写规范,本身就能解决很多问题。
八、什么时候开始考虑 DDD 思想
那什么时候 MVC 开始不太够用呢?
我觉得可以看几个信号。
1. Service 方法越来越像流程脚本
一个方法里面又查库、又校验、又算金额、又调外部系统、又发消息,而且每一步都有各种 if/else。
这时候 Service 已经不只是“业务服务”了,更像一个流程脚本。
2. 同一条业务规则散落在很多地方
比如订单取消规则。
创建订单里判断一次,支付回调里判断一次,后台关闭订单里判断一次,定时任务里又判断一次。
后面规则一变,所有地方都要改。
这种就说明业务规则缺少一个稳定的归属位置。
3. 状态流转越来越复杂
比如订单状态、审批状态、支付状态、售后状态。
状态一多,规则就会变复杂。
哪些状态可以流转到哪些状态,哪些操作只能在特定状态下执行,如果全靠 Service 里面写 if/else,很容易乱。
4. 外部系统变化频繁影响内部逻辑
如果外部接口一改,本系统多个 Service 都要跟着改,这说明外部依赖没有被隔离好。
这时候就可以考虑适配器、防腐层这类做法。
5. 大家对同一个业务词理解不一致
比如“订单关闭”“订单取消”“订单作废”“退款关闭”,不同人理解不一样,代码里也写得不一样。
这时候问题已经不只是代码问题,而是业务语言没有统一。
后面讲限界上下文和通用语言时,会专门说这个问题。