ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

MVCD 4实战:从MVC到领域驱动,理清依赖方向与事务边界

2026/9/7 22:27:53 拓冰建站 浏览量
MVCD 4实战:从MVC到领域驱动,理清依赖方向与事务边界 1. MVCD 4 是什么我为什么要做这个版本先说结论MVCD 4 不是某个开源框架也不是网上流传的标准名词。它是我最近在团队里做的一个分层架构原型项目的代号。MVCD 拆开看是 Model-View-Controller-Domain也就是在经典 MVC 三层之上把 Domain领域层单独抽出来变成最核心的一层。后面的 4 代表这是我自己迭代的第四个版本前三个版本踩了不少坑第四版才把依赖方向、事务边界和目录结构真正理顺。如果你正在写业务系统尤其是订单、库存、会员这类核心链路项目一定会遇到这样的问题Controller 越来越厚Service 里塞满了 if/else一个订单状态变更的逻辑散落在五六个方法里。改一个需求要同时动实体类、Mapper、Service 和 DTO改完自己都不确定有没有漏掉别的地方。MVCD 4 想解决的就是把“业务规则”从“技术实现”里彻底解放出来。它特别适合中小团队维护中大型单体应用也适合想尝试领域驱动设计DDD但不想一上来就全盘引入大量概念的人。再提醒一句这套东西不是我拍脑袋发明的。它的核心思想来自 DDD 的分层架构和经验老到的企业应用开发实践我只是把其中最关键的部分抽出来做成一个能直接落地的骨架。换句话说你可以把它当成一个低门槛的 DDD 入门版也可以当成普通 MVC 工程的“强化版”。下面我会从设计思路、目录结构、代码实现、常见坑位几个角度把它完整拆开。2. 设计思路为什么普通 MVC 撑不住了2.1 经典 MVC 的问题不在分层在“归属感”MVC 本身没有错错的是很多项目把 MVC 写成了 V-C-M 三件事全堆在一起。Controller 既要做参数校验又要调用外部接口还要组装返回对象Model 里的实体类既承担数据库映射又承担业务状态流转脑子里同时塞着两张表的关系和一段审批流逻辑。时间一长谁都不敢改实体类因为不知道哪段代码偷偷依赖了某个字段的某个值。MVCD 4 的核心思路是给每行代码找到一个明确的“家”业务规则只存在于 Domain 层应用服务只负责流程编排基础设施负责和数据库、消息队列、外部接口打交道Controller 只做协议转换。这样改业务的时候大部分时间只需要打开领域对象和应用服务不会牵连到数据库映射和 HTTP 协议层。2.2 版本 4 的三个关键调整我之所以叫它 4 而不是 1是因为前面几个版本走过弯路。第一版只是在 MVC 后面加了一个 Domain 目录但实际上 Controller 还是绕过 Domain 直接查了数据库等于换汤不换药。第二版把领域层建得很重聚合根、值对象、领域事件全用上了结果团队成员理解成本极高一个小需求要写七八个类。第三版开始收敛但事务注解乱放出现了“应用服务调领域服务调仓储再自己开事务”的混乱局面。第四版做了三个硬性调整。第一个调整是依赖方向必须单向。Controller 只依赖应用服务应用服务只依赖领域层接口和基础设施接口基础设施实现领域层定义的接口谁都不允许反向依赖。这样做的好处是核心业务代码里看不到任何 Spring、MyBatis、HTTP 相关的注解换框架不会伤筋动骨。第二个调整是事务全部放在应用服务层。领域对象内部不出现事务注解仓储接口也不出现事务注解。理由很简单一个用例常常要聚合多个领域操作只有最外层才清楚这次操作应该是一个事务还是多个事务。把事务下沉到领域层非常容易把一批本该同时成功或同时失败的操作拆成两半。第三个调整是区分“领域模型”和“数据模型”。Order 类是领域模型OrderDO或者 OrderEntity是数据模型。领域模型表达业务数据模型表达存储。两者之间由仓储实现去做转换而不是让一个类既当爹又当妈。这是 MVCD 4 和普通 MVC 最直观的区别后面我会详细演示。2.3 这个架构适合什么规模如果你的项目只有十几个接口CRUD 为主几乎没有复杂的业务状态流转那 MVCD 4 确实有点“杀鸡用牛刀”。但如果你的项目里有类似“下单后 30 分钟未支付自动取消”“退款金额超过订单总额不允许原路退回”“库存冻结/释放/扣减”这种规则那这套结构会显著降低你的精神负担。我建议的判断标准是核心领域规则如果在多个用例里被重复使用就值得为它建 Domain 层。3. 目录结构与依赖规则先搭出一张“地图”3.1 一个可落地的工程目录MVCD 4 的目录结构并不复杂我用 Java Spring Boot 项目举例其他语言也类似。关键在于模块边界不在于包名长成什么样。推荐按“接口层 / 应用层 / 领域层 / 基础设施层”四块组织代码。com.company.project ├── interfaces // 对应 MVCD 里的 View Controller负责协议转换 │ ├── controller │ ├── dto │ └── assembler ├── application // 应用服务负责用例编排、事务控制 │ ├── service │ ├── command │ └── query ├── domain // 领域层项目中最核心的部分 │ ├── model │ ├── repository │ ├── service // 这里指领域服务不是 Spring Service │ └── event └── infrastructure // 基础设施实现 ├── repository ├── client └── config有些项目会把 interfaces 改成 controller、application 改成 service、infrastructure 改成 dal名字无所谓边界对齐就行。真正重要的是理解每个包允许放什么、不允许放什么。比如domain.model里不要出现 Spring 的注解不要出现 MyBatis 的TableField不要出现 JSON 序列化注解。这些技术标签应该全部集中在基础设施层。3.2 依赖规则画成箭头只有三层MVCD 4 的依赖规则可以用三句话讲清楚。第一句外层可以依赖内层内层绝不允许依赖外层。接口层依赖应用层应用层依赖领域层领域层谁也不依赖。有人会问那 Controller 想查数据库怎么办答案是 Controller 把请求参数交给应用服务应用服务调用仓储接口仓储接口定义在领域层由基础设施层实现。这样链路看起来长了但每一环都清晰可控。第二句领域层只面向接口编程。仓储Repository在领域层只放接口不放实现。实现类全部放在基础设施层用 MyBatis、JPA、Redis 或者内存列表都行。这样做让领域层完全不知道“数据库长什么样”单测时替身随便换。第三句基础设施层可以依赖所有层但它只去实现领域层声明的接口不要反向把业务方法写在基础设施里。很多团队的 Mapper 里出现了“计算订单状态并更新”这种业务逻辑这在 MVCD 4 里是被明确禁止的。Mapper 只负责持久化一个已确定的状态状态怎么变是领域层的事。3.3 依赖方向带来的直接收益依赖方向理顺之后最大的变化是写单元测试变得非常顺畅。因为领域层不依赖任何技术框架你可以很轻松地构造一个 Order 对象然后断言 confirm 之后状态是否正确。应用层的测试只需要 mock 掉仓储接口和外部客户端不需要启动整个 Spring 容器。就算以后从 Spring Boot 切到 Quarkus甚至把核心领域抽出来变成一个独立的 jar 给多个应用复用领域层的代码都几乎不用动。我在版本 4 里还加了一条约定接口层 DTO 不允许直接传给领域层。Controller 收到的请求先转成 Command 对象再传给应用服务。领域方法只接收领域对象或基本类型。这个约定一开始看着啰嗦但对防止接口层参数污染业务逻辑非常有效。4. 用 MVCD 4 实现一个订单确认用例核心环节拆解4.1 领域层让订单自己管理状态光讲理论没有感觉我用订单确认这个业务场景来做完整演示。这个用例的核心规则是只有处于“已创建”状态的订单才能被确认确认后状态变为“已确认”同时要记录确认时间。传统写法的 Service 里会随手写三个if和两次数据库更新MVCD 4 要求这些规则全部内聚在 Order 领域对象里。先定义订单状态枚举和订单对象。public enum OrderStatus { CREATED, CONFIRMED, CANCELLED }public class Order { private String orderId; private Money totalAmount; private OrderStatus status; private LocalDateTime confirmedAt; public Order(String orderId, Money totalAmount) { this.orderId orderId; this.totalAmount totalAmount; this.status OrderStatus.CREATED; } public void confirm() { if (this.status ! OrderStatus.CREATED) { throw new IllegalStateException(只有已创建的订单才能确认当前状态 this.status); } this.status OrderStatus.CONFIRMED; this.confirmedAt LocalDateTime.now(); } public boolean isConfirmed() { return this.status OrderStatus.CONFIRMED; } }注意这里的confirm()方法没有任何数据库操作没有Transactional没有 Spring 痕迹。它只做一件事根据当前状态判断是否允许流转然后改变自己的状态。这就是领域模型的全部意义。当后续需求变成“已确认但未支付的订单不能再次确认”时你也只需要改这一个方法不需要翻遍整个 Service。Money 这个值对象我也简单定义一下。因为金额这个字段太容易踩精度坑用 double 做订单金额在线上迟早出事。这里用 BigDecimal 包装一层顺便把“金额不能为负”的业务规则收进来。public class Money { private final BigDecimal amount; public Money(BigDecimal amount) { if (amount null || amount.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(金额不能为负); } this.amount amount; } public BigDecimal getAmount() { return amount; } }4.2 领域服务与仓储接口把“接口”和“实现”拆开如果某个业务规则横跨多个聚合或者必须查询多个实体才能判断就应该放到领域服务里。比如“取消订单并释放库存”可能同时涉及订单和库存两个对象放在任何一个对象里都不合适领域服务是更合适的容器。public interface OrderRepository { OptionalOrder findById(String orderId); void save(Order order); }这个接口出现在 domain.repository 包下不带任何注解。它描述了“订单要有能力被查出来、被保存”但没说从哪里查、怎么存。基础设施层的实现可以基于 MyBatis也可以基于 JPA甚至用发消息到别的服务然后等回调。领域层完全不关心。4.3 应用服务编排用例并控制事务应用服务是 MVCD 4 里最容易被写脏的一层因为所有流程都要在这里跑一遍。如果把握不好度它会退化成以前的 Service 类。我的经验是应用服务只做三件事——找对象、调用领域方法、保存结果。业务判断不要写在应用服务里哪怕是一行 if 也不要写除非这个 if 与领域对象生命周期无关比如“参数为空就直接抛参数异常”。Service public class OrderApplicationService { private final OrderRepository orderRepository; public OrderApplicationService(OrderRepository orderRepository) { this.orderRepository orderRepository; } Transactional public ConfirmOrderResult confirmOrder(String orderId) { Order order orderRepository.findById(orderId) .orElseThrow(() - new IllegalArgumentException(订单不存在 orderId)); order.confirm(); orderRepository.save(order); return ConfirmOrderResult.from(order); } }代码很短但它把一条规则执行得很彻底整个用例在一个事务里先查出订单再让订单自己完成状态流转最后保存。后面的需求如果要求“确认订单后给用户发通知”也只是在order.confirm()之后加一行消息发送调用不会破坏领域逻辑。4.4 基础设施层用 MyBatis 实现仓储接下来是最容易让人迷惑的地方。订单领域对象有orderId、totalAmount、status、confirmedAt这些字段但数据库表可能叫t_order字段叫order_no、total_fee、order_state。领域模型和数据模型不一致怎么办MVCD 4 的做法是写一个数据访问对象DO仓储实现负责转换。TableName(t_order) public class OrderDO { private Long id; private String orderNo; private BigDecimal totalFee; private String orderState; private LocalDateTime confirmedAt; }Repository public class OrderRepositoryImpl implements OrderRepository { private final OrderMapper orderMapper; public OrderRepositoryImpl(OrderMapper orderMapper) { this.orderMapper orderMapper; } Override public OptionalOrder findById(String orderId) { OrderDO orderDO orderMapper.selectByOrderNo(orderId); if (orderDO null) { return Optional.empty(); } Order order new Order(orderDO.getOrderNo(), new Money(orderDO.getTotalFee())); // 这里需要把 DO 里的 orderState 还原为 OrderStatus // 实际工程里推荐写一个 Assembler/Converter 来做转换 return Optional.of(order); } Override public void save(Order order) { // 将领域对象转成 DO 后 upsert } }这个转换看着繁琐但它是防御未来变更最有效的一步。比如订单状态从字符串改成数字字典或者从单表拆成主表和扩展表都只需要修改仓储实现领域层的OrderStatus枚举和Order.confirm()纹丝不动。很多团队做数据库字段调整的时候胆战心惊就是因为领域模型和数据库模型混在了一起。4.5 接口层Controller 只做协议转换最后是接口层。Controller 接收 HTTP 请求把 JSON 转成 Command调用应用服务然后把结果转成 DTO 返回。RestController RequestMapping(/v1/orders) public class OrderController { private final OrderApplicationService orderApplicationService; public OrderController(OrderApplicationService orderApplicationService) { this.orderApplicationService orderApplicationService; } PostMapping(/{orderId}/confirm) public ApiResponseConfirmOrderDTO confirm(PathVariable String orderId) { ConfirmOrderResult result orderApplicationService.confirmOrder(orderId); return ApiResponse.success(ConfirmOrderDTO.from(result)); } }Controller 里看不到任何“应该把状态改成什么”的逻辑也看不到任何直接访问 Mapper 的代码。它的职责只有一个把 HTTP 协议变成应用服务能理解的方法调用再把应用服务的结果变回 HTTP 响应。如果有人把ApiResponse换成 GraphQL或者把 Spring MVC 换成 JAX-RSController 层整个重写但应用服务和领域层都不受影响。4.6 一套简单的单元测试示例代码写完了介绍一下测试方式。领域层测试最常见因为不需要启动容器。class OrderTest { Test void should_confirm_created_order() { Order order new Order(A001, new Money(new BigDecimal(99.00))); order.confirm(); assertTrue(order.isConfirmed()); } Test void should_reject_confirm_when_status_not_created() { Order order new Order(A001, new Money(new BigDecimal(1.00))); order.confirm(); assertThrows(IllegalStateException.class, order::confirm); } }这种测试写起来快跑起来也快一个字段映射出了问题仓储单测能拦住状态流转逻辑出了问题领域单测能拦住。分层架构让测试的边界和业务边界保持一致这是我认为 MVCD 4 最值得投入的理由。5. 关键细节解析四个最折磨人的问题5.1 事务边界为什么一定放在应用服务很多初学者在领域方法上加Transactional或者在仓储方法上加结果出现嵌套事务、连接被占用、回滚不完整等莫名其妙的问题。我建议你记住一句话事务属于用例不属于方法。一个用例是一个完整的事务边界。比如“确认订单发送消息”里如果只需要保证订单状态保存成功消息发送失败不影响主流程那就不能把它们放在同一个事务里。这个判断只有应用服务层看到整体情况后能做领域对象没有这个视角。MVCD 4 里还有一个配套要求事务注解不要用在 Controller 方法上不要用在仓储的单个查询方法上。查询方法通常不在事务内执行如果非要查也要用只读事务并明确标注。5.2 领域模型与数据库模型分离复杂度值不值如果你只做一个纯 CRUD 的表比如字典表、配置表领域模型和数据模型确实长得一模一样强行分离有点形式主义。但订单、用户、库存这种核心对象业务状态往往比数据库字段更丰富。比如订单上有“是否允许部分发货”这种动态规则数据库里根本没有对应字段它可能是根据商品类型和用户等级算出来的。如果你把订单实体类直接当数据库映射用这种计算逻辑就只能散落在 Service 里。最终我采取的策略是“按需分离”。核心领域对象使用独立的领域模型简单配置表直接用 DO 一把梭。这样既不会过度设计也不会在规则变复杂时无从下手。5.3 命令对象和 DTO 是不是多余的MVCD 4 里Controller 接收的参数叫 DTO应用服务接收的参数叫 Command。有的项目还有 Query 对象专门用于查询接口。为什么要分开因为 DTO 往往带有接口层痕迹比如分页参数、签名参数、序列化字段名Command 则是应用层的输入应该语义清晰不含协议细节。举个例子前端传pageNum和pageSizeDTO 直接接收没问题但应用服务的分页查询方法更希望收到一个PageQuery对象内部校验好页码最小值为 1。如果你觉得两个对象太麻烦可以先用一个 Command 类同时应对 Controller 入参和应用服务入参等接口数量多了再拆。版本 4 开头的几周我就是这么干的后面才逐步完善。5.4 领域事件要不要急着引入MVCD 4 没有强制要求使用领域事件但我保留了一个 event 包。它的作用是处理“同一个领域行为引发的多个副作用”。比如订单确认后要更新统计报表、发送短信、通知仓储系统这些都属于副作用不应该直接写在订单领域对象内部。让领域对象在状态变化时记录事件应用服务发布事件其他模块监听事件完成后续动作。不过我要泼一盆冷水如果你的团队没有事件总线、没有可靠投递机制一上来就用领域事件很容易变成“逻辑到处飞”。新手可以先把事件发布逻辑直接写在应用服务里至少保证了显式调用等副作用多了再往事件化迁移。我见过好几个项目求大求全地把所有操作异步化结果排查问题时完全看不清调用链。6. 常见问题与排查技巧实录6.1 问题速查表现象典型原因排查方向Controller 直接注入 Mapper分层规则没执行到位加 Code Review 规则禁止 interfaces 层依赖 infrastructure 层领域对象里出现 Spring 注解领域模型与数据模型混用拆出 DO把持久化注解全部挪到基础设施层事务回滚不生效事务方法被同类内部调用AOP 代理没生效或被 try/catch 吞掉异常把被调方法拆到另一个 bean确保回滚异常没有在边界内被吞掉数据状态错乱该确认的订单没确认状态流转逻辑写在 Service 里不同地方各有各的写法把状态判断收敛到领域方法全链路只调用同一个confirm()查询接口复杂且性能差应用层没用专门查询模型硬生生遍历领域对象引入查询模型/DO 直查绕过复杂的聚合转换6.2 我踩过最狠的三个坑第一个坑是领域层“过度充血”。早期我觉得把所有规则都放进实体很酷于是连“用户是否有权限查看这个订单”这种和订单对象关系不大的逻辑也塞了进去。结果实体类越来越大改一个方法要跑八百个测试。后来我才想明白领域对象只装与自己强相关的规则跨对象的规则放领域服务跨系统的规则放应用服务。第二个坑是仓储接口设计得过细。第一版我在OrderRepository里放了findByUserIdAndStatus、findByStatusAndCreateTimeBefore等十几个方法领域层和应用层谁都能调。后来发现大部分方法只在某个具体用例里用一次接口膨胀又难维护。现在的做法是仓储只提供最通用的findById、save需要复杂查询时直接新增一个专门的查询接口不管它叫OrderQueryMapper还是OrderReadRepository反正不要混进领域接口里。第三个坑是低估了编译器带来的规矩。分层再好如果没人检查过一个月就会有人图方便写一个穿透调用。版本 4 我在工程里做了两件事一是用 ArchUnit 之类的架构测试工具把“interfaces 不允许依赖 infrastructure”写成自动化用例跑 CI 时强制卡住二是把所有跨层调用的代码评审模板固定下来凡是越过应用服务而发生的依赖一律打回。这两件事缺一不可代码规范只有变成测试和评审规则才能长期有效。6.3 一些可以少走弯路的建议如果你准备把现有项目改成 MVCD 4我建议不要立刻全量重构。先挑一个业务核心模块比如订单或支付把它的 Controller-Service-Mapper 三层拆成新的四层结构其他模块先保持原样。跑通一个模块摸清楚团队配合的节奏再逐步推广。千万不能一边上线新需求一边做大规模架构改造那种状态下出问题你根本分不清是业务改错了还是架构迁移的锅。对于从零开始的新项目骨架的搭建时间其实很短。先把接口层、应用层、领域层、基础设施层四个包建好再把 Controller、ApplicationService、Repository、RepositoryImpl 这四个类各写一个示例剩下的事情就是在写每个新功能时问自己这段代码属于第几层如果回答不出来说明这个方法的职责可能是混的先拆清楚再写。靠这种基本自问你的项目大概率不会烂到哪里去。我在实际动手过程中最深的体会是MVCD 4 的精髓不是那四个字母而是“依赖方向”和“责任归属”。把代码放对位置比用什么框架重要得多。哪怕你一个领域驱动概念都不会只要做到 Controller 只做协议转换、应用服务只做流程编排、领域对象负责业务规则、基础设施负责技术实现项目的长期可维护性都会甩开一大截。这套结构我用了一年多越到后期越能感受到它的好处需求变更不再恐慌测试不再依赖整个环境新同事也能很快找到该改的地方。希望你也能基于自己的项目情况把它改造成一套适合团队的 MVCD 4。