DDD系列:从 MVC 三层架构说起
2026/7/9 2:22:27 网站建设 项目流程

上一篇文章中,我们先简单聊了一下 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 里是怎么落地的

接下来我们换一个更真实一点的角度。

平时我们接到一个需求,大概会经历下面这个过程:

当然,真实工作里不一定每一步都这么标准,但大体差不多:

  1. 产品提出需求;
  2. 后端理解需求;
  3. 设计数据库表;
  4. 写 Entity、Mapper;
  5. 写 Service 业务逻辑;
  6. 写 Controller 接口;
  7. 前后端联调;
  8. 测试、上线。

这个流程看起来非常自然。

比如现在要做一个“创建订单”的接口,我们很容易先想到表:

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

这些名字不是不能用,但业务含义比较模糊。

方法里面可能什么都干。

一旦代码里到处都是handleprocessupdateStatus,后面接手的人就只能点进去看实现。

如果代码能直接表达业务动作,比如:

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. 大家对同一个业务词理解不一致

比如“订单关闭”“订单取消”“订单作废”“退款关闭”,不同人理解不一样,代码里也写得不一样。

这时候问题已经不只是代码问题,而是业务语言没有统一。

后面讲限界上下文和通用语言时,会专门说这个问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询