整洁架构实践:依赖倒置与用例设计让业务规则免于技术绑架
发布时间:2026/10/8 15:37:01 作者:尧图编辑部 阅读量:1,286

1. 重新审视整洁架构从一次重构说起1.1 我从 MVC 胖服务里看到的困境我第一次认真思考整洁架构是在接手一个“历史遗留系统”的时候。那个项目采用了标准的 Spring MVC 分层Controller、Service、DAO初看很清晰但真正改起来却让人头皮发麻。业务规则散落在 Service 里一个方法动不动两三百行订单创建逻辑里混着库存扣减、优惠券校验、金额计算、日志埋点甚至还有短信发送。最要命的是Service 直接依赖 MyBatis 的 mapper 接口业务方法里到处是OrderDO、QueryWrapper一旦数据库字段调整上游十几个 Service 方法跟着改。那时我意识到传统的分层只是“代码文件的分类法”并没有真正解决依赖关系的问题。控制层依赖服务层服务层依赖数据层整个系统像叠罗汉一样从 UI 一路压到数据库。表面上看依赖是单向的实际上业务规则被技术细节绑架了你想复用一段优惠计算逻辑却发现它必须传入一个OrderDO你想写个纯单元测试却必须先启动 Spring 容器、准备数据库连接。这就是我认为整洁架构值得实践的起点。它不只是一套包名规范而是通过控制依赖方向把业务规则放到系统中心让数据库、框架、UI 都变成可以替换的细节。听起来有点抽象但只要你经历过“换个 ORM 等于重写业务逻辑”的痛就会明白这套思想的真正价值。1.2 整洁架构到底是什么整洁架构Clean Architecture是 Robert C. Martin 在 2012 年提出的一套架构思想它延续了 Hexagonal Architecture六边形架构、Onion Architecture洋葱架构的核心主张核心就一句话让业务规则不依赖技术实现。我理解它主要有三条原则。第一条是分层通常从外到内分成四层框架与驱动层Web、数据库、消息队列、适配器层Controller、Presenter、Repository 实现、用例层Application Service / Use Case、实体层Enterprise Business Rules。第二条是依赖方向源码依赖必须指向内部外圈可以依赖内圈内圈绝对不依赖外圈。第三条是边界用接口把内外圈隔开外部的东西通过接口反向实现内部定义的契约。听起来和 DDD 很像但两者关注点不同。DDD 侧重领域建模和业务语言的统一解决“怎么把复杂业务拆清楚”的问题整洁架构侧重依赖管理和模块边界解决“拆完之后怎么不互相污染”的问题。两者完全可以结合实际项目中我一般用 DDD 做战略设计用整洁架构的依赖规则来约束代码结构效果比单用任何一个都好。2. 依赖规则整洁架构的“宪法”2.1 四层模型和依赖方向整洁架构的四层模型很多人把它画成同心圆最里面是实体Entities往外是用例Use Cases再往外是接口适配器Interface Adapters最外面是框架和驱动Frameworks Drivers。我更喜欢把它理解成一部机器的四个区域核心引擎在最里面操作面板在中层外壳和电源在最外面。实体层承载的是企业级业务规则它不关心用户从网页来还是从 App 来也不关心数据存在 MySQL 还是 PostgreSQL 里。比如“订单总额等于商品单价乘以数量再减去优惠金额”这条规则就应该写在实体里。用例层承载的是应用级业务规则它描述一个具体的用户场景比如“创建订单”“取消订单”“确认收货”。用例负责编排实体协调各种输入输出但不直接操作数据库、不组装 HTTP 响应。适配器层负责把外部世界的请求转换成用例能理解的输入再把用例的输出转换回外部需要的格式。Controller 属于这一层Repository 接口的实现也属于这一层。最外面的框架层就是 Spring、MyBatis、Thymeleaf 这些具体技术组件。依赖方向的关键在于源码依赖只能从外指向内。也就是说内圈不能出现外圈类的名字。实体层不能 import 任何 Spring 注解用例层不应该出现HttpServletRequest更不能直接调用OrderMapper。如果你做到了这一点就达到了“框架无关”的目标今天用 Spring明天换成 Guice业务内核一个字都不用改。2.2 为什么必须坚持依赖倒置依赖规则听上去很美但落地时最反直觉的一点是数据库访问接口要定义在用例层而不是数据层。我刚开始也很困惑Repository 接口不放在数据访问模块难道放在业务模块吗后来想通了这就是依赖倒置的核心——高层模块不应该依赖低层模块两者都应该依赖抽象。举个具体例子。用例需要一个“保存订单”的能力它不需要知道订单被保存到了哪个数据库、用的是 MyBatis 还是 JPA它只需要一个OrderRepository接口。这个接口定义在用例层属于内部契约。数据层里写一个MyBatisOrderRepository实现这个接口这个实现类依赖了用例层的接口方向正好指向内侧。这样做的好处很明显。第一数据库实现可以随时替换只要实现同一个接口就够了。第二测试时可以轻松注入一个内存版 Repository。第三最重要的业务用例不再被数据库细节污染阅读用例代码就能看懂业务流程。我见过太多项目业务代码里直接orderMapper.insert(order)美其名曰“封装”实际上每个业务方法都漏出了持久化细节这样的代码没法脱离 Spring 环境运行。依赖倒置需要靠控制反转容器来支撑。Spring 就是干这个的接口定义在内部实现类注册为 Bean运行时容器自动注入。但这不代表我们可以乱用Autowired正确的姿势是构造函数注入并且只注入本层依赖的接口。我曾经见过有人为了省事在用例里直接注入 Controller 层的Result类结果整个依赖方向瞬间倒置这个用例再也无法复用。3. 代码落地的工程实践3.1 项目结构怎么拆理论讲完关键要看怎么落地。我惯用的工程结构是按模块和层双维度拆而不是简单地建一堆controller、service、dao包名。Maven 或 Gradle 多模块工程下我习惯拆成这样order-service/ order-application/ # 用例层 接口定义 usecase/ CreateOrderUseCase.java CreateOrderInput.java port/ OrderRepository.java StockService.java domain/ # 实体层 Order.java OrderItem.java order-adapter/ # 适配器层 web/ OrderController.java OrderCreateRequest.java OrderCreateResponse.java persistence/ MyBatisOrderRepository.java OrderDO.java OrderMapper.java order-bootstrap/ # 框架层 Application.java config/这里order-application依赖关系最干净它只包含纯 Java 代码没有任何 Spring 注解。有人会质疑没有Service、TransactionalSpring 怎么管理事务答案是事务注解放在 adapter 层或单独的事务切面里。用例层的CreateOrderUseCase是一个普通类只要OrderRepository.save()是事务性操作由外部配置事务边界就可以。这样设计让用例层既能在 Spring 环境跑也能拆出来单独做单元测试。order-adapter依赖order-application实现接口并做输入输出转换。order-bootstrap是启动入口装配所有 Bean配置数据源、消息队列等。如果你用 Spring BootApplication.java放在最外层即可。这套结构初期成本不低每个模块都要手动维护接口和实现但对中大型项目非常值。3.2 用接口稳定边界边界靠接口来定义。我总结了一套接口设计的经验能减少很多返工。第一接口的语义要面向业务不面向技术。OrderRepository提供save(Order order)、findById(OrderId id)不要提供insert(OrderDO order)。前者表达“保存一个订单”后者暴露了数据库表结构。第二参数和返回值尽量用领域对象或简单的值对象不要用 DTO。用例内部处理的是Order不是OrderCreateRequest更不是MapString, Object。适配器负责把请求转成CreateOrderInput把结果转成响应 DTO。第三每个用例对应一个输入输出对象。CreateOrderInput里放用户 ID、商品列表、收货地址等CreateOrderResult里放订单 ID、总金额、预计发货时间。这样做的好处是用例的签名非常稳定不会因为 Controller 加了一个 header 参数就得改接口。我在项目中还习惯把跨系统的调用也收敛成端口接口。比如库存扣减不要直接在用例里调用HttpClient或FeignClient而是定义StockService接口放在用例层适配层用RemoteStockServiceImpl去实现。这样如果后来想改成本地库存模块或者加一层缓存完全不影响用例逻辑。3.3 一个下单场景的完整例子口说无凭用一个最经典的“创建订单”场景把整个流程走一遍。实体层定义Orderpublic class Order { private OrderId id; private UserId userId; private ListOrderItem items; private Money totalAmount; public static Order create(UserId userId, ListOrderItem items) { Order order new Order(); order.id new OrderId(UUID.randomUUID().toString()); order.userId userId; order.items items; order.totalAmount items.stream() .map(OrderItem::getSubtotal) .reduce(Money.ZERO, Money::add); return order; } public Money getTotalAmount() { return totalAmount; } }用例层定义CreateOrderUseCasepublic class CreateOrderUseCase { private final OrderRepository orderRepository; private final StockService stockService; public CreateOrderUseCase(OrderRepository orderRepository, StockService stockService) { this.orderRepository orderRepository; this.stockService stockService; } public CreateOrderResult execute(CreateOrderInput input) { ListOrderItem items input.getItems(); stockService.deductStock(items); Order order Order.create(input.getUserId(), items); orderRepository.save(order); return new CreateOrderResult(order.getId().getValue(), order.getTotalAmount().getAmount()); } }适配层的 Controller 做转换RestController public class OrderController { private final CreateOrderUseCase createOrderUseCase; PostMapping(/orders) public OrderCreateResponse create(RequestBody OrderCreateRequest request) { CreateOrderInput input new CreateOrderInput( request.getUserId(), request.getItems()); CreateOrderResult result createOrderUseCase.execute(input); return new OrderCreateResponse(result.getOrderId(), result.getTotalAmount()); } }适配层的持久化实现Repository public class MyBatisOrderRepository implements OrderRepository { private final OrderMapper orderMapper; Override Transactional public void save(Order order) { OrderDO doObj OrderDO.from(order); orderMapper.insert(doObj); } }看到没有用例层完全不知道 Spring、不知道 MyBatis、不知道 HTTP。事务放在了持久化实现上或者更优雅的做法是利用 Spring 环绕通知给用例方法统一加事务。这样测试用例层时我可以直接 new 一个CreateOrderUseCase传入 mock 的 Repository 和 StockService几毫秒跑完不需要启动容器。4. 避坑指南我用整洁架构踩过的雷4.1 别把“层”变成“包名游戏”整洁架构最大的坑就是有人把代码塞进domain、application、infrastructure三个包就算是“整洁”了。包名改得勤但依赖照样乱domain里的实体带着Table注解application里的服务直接 new 了一个RestTemplate。我踩过一次很深刻的雷。当时团队按整洁架构分包但domain层为了省事直接在User实体上加了 JPA 注解。结果后期换数据库从 MySQL 换到 PostgreSQL发现Column(name user_name)这种映射逻辑到处散落真正被影响的是领域实体而不是该独立的持久化映射。所以我对“包名游戏”的定义是接口是否真的隔离了依赖方向而不是表面上看包名规范实际类图上依然是全局网状依赖。检查方法很简单用 IDE 的依赖分析看domain和usecase是否有指向外部框架的依赖箭头。如果有就要立刻调整。我见过更隐蔽的问题Order实体的方法参数里出现了BigDecimal虽然BigDecimal是 JDK 类但它本质上只是一种实现选择更合理的是自定义Money值对象把金额的单位、精度、运算规则封装进去。这个建议越早做越好。4.2 事务边界、ORM 实体和用例的纠缠第二个高频问题事务到底放在哪网上一搜答案五花八门。我的经验是一个用例对应一个事务边界事务不要放在实体方法里也不要把多个用例串在一个大事务里。一开始我把Transactional加在CreateOrderUseCase上因为直观。后来发现一个问题用例层是纯 Java 类不能用 Spring 注解否则就破坏了依赖规则。于是我把事务挪到 adapter 层的OrderController或通过 AOP 配置实现。注意不能简单地给整个 Controller 方法加事务因为 Controller 方法只是一个 HTTP 入口它可能调用多个用例。如果你在 Controller 层开一个大事务两个用例之间的锁会持有很久并发一高立刻出问题。还有一种常见纠缠是 ORM 实体直接在业务层泛滥。很多人用 MyBatis 或 JPA 的实体类当业务领域对象图省事。结果就是“业务规则”跟着表结构调整走字段加一个减一个所有用例的代码都在改。应该做一个显式转换持久化层有自己的OrderDO领域层有Order虽然字段相似但语义不同。转换代码确实写得枯燥但收益很大表结构调整只影响 adapter 层领域对象始终稳定。4.3 测试到底测哪一层整洁架构带来的最大福利就是可测试性提升。但我也见过团队把测试玩砸了用SpringBootTest启动整个应用然后只为了测一个订单金额计算。测试跑了十几秒还依赖 Redis、MySQL、消息队列一堆环境问题导致 CI 频繁红。正确的策略应该按象限分实体和用例层用纯单元测试适配器层用轻量集成测试框架层交给端到端测试。单元测试只怼核心业务逻辑。比如Order.create()计算订单总额CreateOrderUseCase.execute()验证调用顺序和参数这些都完全不需要 Spring。我用 JUnit 5 Mockito跑一个用例测试只需要几十毫秒。集成测试才需要 Spring 上下文但只测 adapter 层到数据库的映射比如MyBatisOrderRepository的 insert 和 select 是否正确。这里要注意如果用例层需要验证“库存不足时扣减失败”可以 mockStockService抛异常然后断言用例抛出的领域异常完全不碰外部系统。测试金字塔在这里特别适用。我建议团队责任分工业务人员负责用例层测试的用例名编写开发人员负责实现。比如“给定库存充足创建订单应该成功”翻译成代码就是Test void shouldCreateOrderWhenStockIsEnough() { CreateOrderUseCase useCase new CreateOrderUseCase( orderRepository, stockService); CreateOrderInput input new CreateOrderInput( userId, items); CreateOrderResult result useCase.execute(input); assertThat(result.getOrderId()).isNotBlank(); }这种测试读起来就像业务需求文档对代码评审和后续维护非常有价值。5. 什么时候该用整洁架构5.1 适合与不适合的场景很多人问整洁架构是不是所有项目都适用我的答案很明确不是。它适合业务复杂度高、生命周期长、需要多人协作维护的系统。比如电商核心交易、支付清结算、订单履约中心这些系统业务规则多、变化频繁而且往往要支撑多个客户端和多种外部渠道整洁架构隔离变化的价值会被充分放大。反过来如果一个项目只是一个简单的 CRUD 后台或者企业内部几十个表单的增删改查业务逻辑基本就是“接收参数、存数据库”强行引入五层架构只会增加无谓的复杂度。我曾经给一个内部工具项目引了一套完整的整洁架构最后发现每个用例都是repository.save(entity)代码量翻了一倍但业务没有任何提升。后来我把那个项目重构回 Controller-Service-DAO反而清爽得多。我的判断标准有三个业务规则是否复杂系统是否要长期演进是否有多端接入三个都满足果断上整洁架构只满足一两个可以折中比如只引入接口隔离和依赖倒置不做完整的四层划分。还有一个务实建议从几个核心业务模块开始试点比如订单模块用整洁架构用户模块先用普通分层跑通之后再扩展。不要一上来就全局翻新否则团队成员抵触情绪会很大。5.2 团队协作中的约定与代码评审整洁架构本质上是一种契约它的可持续性靠团队共识保障。如果只有一个人理解其他人都在包名游戏里乱写架构会迅速腐化。我所在团队在推进时做了三件事成效不错。第一定义架构约束文档一页纸就好。明确写出依赖方向和禁止事项比如“domain 层禁止出现 Spring 注解”“用例层禁止 import 外部框架类”“所有跨层调用必须走接口”。第二把约束固化成自动化检查。用 ArchUnit 写测试用例在 CI 里校验包依赖方向Test void domainShouldNotDependOnSpring() { JavaClasses classes new ClassFileImporter() .importPackages(com.example.order.domain); noClasses() .that().resideInAPackage(..domain..) .should().dependOnClassesThat() .resideInAPackage(org.springframework..) .check(classes); }这一步非常管用。人靠代码评审总会漏自动化检查不会。第三代码评审的重点从“功能是否正确”转向“依赖方向是否正确”。每次评审都会问几个问题这个类放在这层合理吗它依赖了不该依赖的东西吗接口抽象是否泄露了实现细节只要团队能保持这种评审习惯整洁架构就能维持健康状态。从我个人的经验看整洁架构不是银弹它是一套让架构边界可维护的方法论。真正难的不是记住那几条规则而是在每一次写代码时抵抗“图省事”的冲动。每次你想在用例里直接注入一个 Mapper 的时候想一想未来替换数据库的场景想一想测试时启动整站的成本就会觉得多写几行接口转换代码非常值得。这套实践让我对代码有了更强的掌控感也希望你也能在自己的项目里体会到这种“业务规则不被技术绑架”的踏实。