整洁架构实战:用依赖倒置解耦业务与框架

当你的核心业务逻辑里写满了 Spring 的 @Service、MyBatis 的 Mapper,或者某个 Web 框架的注解时,业务其实已经被框架”绑架”了。整洁架构(Clean Architecture)用一组同心圆把代码分成「实体—用例—接口适配—框架与驱动」四层,并通过依赖倒置原则(DIP)让外层依赖内层,从而把业务规则与数据库、Web、第三方 SDK 彻底解耦。本文用一个订单下单场景,带你把这套架构真正跑起来。

一、为什么业务总被框架绑架

传统三层架构(Controller / Service / Dao)在初创项目里很顺手:写一个 Service,里面直接注入 MyBatis 的 Mapper,再调一下 Redis 缓存,一个接口就完成了。问题在于,这种写法让”业务规则”和”技术细节”长在了同一层。当有一天你想把 MySQL 换成 PostgreSQL,或者想脱离 Spring 单独对下单逻辑做单元测试时,会发现业务代码里到处都是框架的引用,根本拆不干净。

更隐蔽的代价是”可测试性”的丧失。一段真正的核心用例,本应只关心”什么条件下允许下单、库存如何扣减”,却被迫依赖一个真实的数据库连接。于是你的单元测试要么起一整套 Spring 容器,要么用一堆 Mock 把依赖层层打桩,测试越写越重,最后没人愿意写。

二、整洁架构的同心圆四层

整洁架构把所有代码画成一组同心圆,依赖方向永远指向圆心。越靠近圆心越抽象、越稳定;越靠外圈越具体、越易变。最外层是框架和驱动(Web、数据库、消息队列),最内层是业务实体。

        ┌─────────────────────────────────┐
        │   框架与驱动 (Web/DB/SDK)        │   ← 最易变,可替换
        ├─────────────────────────────────┤
        │   接口适配层 (Controller/RepoImpl)│
        ├─────────────────────────────────┤
        │   用例层 (Use Cases / 应用服务)    │
        ├─────────────────────────────────┤
        │   实体层 (Entities / 领域模型)     │   ← 最稳定,不依赖任何外层
        └─────────────────────────────────┘
           依赖方向:外圈 ──► 内圈(永远向内)

四层的职责可以这样对照:

层级依赖方向主要职责能否引用外层
实体层无(最内)业务规则、领域对象、不变量绝不
用例层只依赖实体层单个业务用例的编排与输入输出绝不
接口适配层依赖用例层与实体层把外层数据转换成内层能懂的格式—
框架驱动层依赖适配层HTTP、ORM、缓存、消息等具体技术 outermost

关键约束只有一条:源代码依赖只能由外向内,内圈绝不知道外圈的存在。实体层里不该出现 @Entity、@Service 这类框架注解,用例层也不该直接 new 一个具体的数据库实现。

三、依赖倒置是解耦的核心

怎么做到”内层不知道外层”?答案是依赖倒置原则(DIP):高层模块和低层模块都依赖于抽象,而不是彼此依赖具体实现。在整洁架构里,这意味着”数据怎么存”的接口由内层定义,由外层去实现。

先看反面例子——用例直接依赖具体的仓储实现:

// ❌ 反面:业务层直接依赖具体的 MyBatis 实现
public class PlaceOrderService {
    private final OrderMapper orderMapper;   // 来自 MyBatis 框架
    private final RedisTemplate redis;        // 来自 Spring Data

    public OrderResult place(OrderCommand cmd) {
        Order order = cmd.toOrder();
        orderMapper.insert(order);            // 业务被迫认识数据库
        redis.opsForValue().set("last:" + order.getId(), order);
        return OrderResult.ok(order.getId());
    }
}

再看正面例子——在领域层定义接口,让外层去实现它:

// ✅ 正面:接口定义在内层,实现留在外层
// 实体/用例层(不依赖任何框架)
public interface OrderRepository {
    OrderId nextId();
    void save(Order order);
    Optional<Order> findById(OrderId id);
}

public class PlaceOrderUseCase {
    private final OrderRepository repository;   // 只依赖抽象
    public PlaceOrderUseCase(OrderRepository repository) {
        this.repository = repository;
    }
    public OrderResult execute(OrderCommand cmd) {
        Order order = Order.create(cmd);
        repository.save(order);                 // 不知道背后是 MySQL 还是内存
        return OrderResult.ok(order.getId());
    }
}

注意这段用例代码里没有任何 import org.springframework 或 import com.baomidou。它只认 OrderRepository 这个抽象。至于这个接口背后是 JDBC、JPA 还是内存 Map,用例层完全不关心——这正是可测试性的来源:单元测试里传一个内存版实现即可。

四、最小可运行示例:订单下单用例

把上面的思路落到一个完整的小例子。先是实体层,领域对象自己守住不变量:

// 实体层:只关心业务规则,不依赖框架
public class Order {
    private final OrderId id;
    private final List<OrderItem> items;
    private OrderStatus status;

    public static Order create(OrderCommand cmd) {
        if (cmd.getItems().isEmpty())
            throw new IllegalArgumentException("订单不能为空");
        return new Order(new OrderId(cmd.getOrderNo()), cmd.getItems(), OrderStatus.CREATED);
    }
    public void pay() { this.status = OrderStatus.PAID; }
    // getter ...
}

接着是接口适配层,把具体技术接到内层定义的接口上:

// 接口适配层:实现内层定义的抽象(这里可以是任意 ORM)
@Repository
public class JdbcOrderRepository implements OrderRepository {
    private final JdbcTemplate jdbc;
    public JdbcOrderRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; }
    @Override public void save(Order order) {
        jdbc.update("insert into orders(id,status) values(?,?)",
                    order.getId().value(), order.getStatus().name());
    }
    @Override public Optional<Order> findById(OrderId id) { /* ... */ return Optional.empty(); }
}

最外层的 Web 适配器只负责收参数、调用例、转响应,不含任何业务判断:

// 框架驱动层:HTTP 入口,薄薄一层
@RestController
@RequestMapping("/orders")
public class OrderController {
    private final PlaceOrderUseCase useCase;
    public OrderController(PlaceOrderUseCase useCase) { this.useCase = useCase; }
    @PostMapping
    public OrderResult create(@RequestBody OrderCommand cmd) {
        return useCase.execute(cmd);   // 业务全在用例里,这里只是"传声筒"
    }
}

// 装配:在应用入口把实现注入用例(Spring 或纯 Java 均可)
@Configuration
class AppConfig {
    @Bean PlaceOrderUseCase placeOrderUseCase(OrderRepository repo) {
        return new PlaceOrderUseCase(repo);
    }
}

这里有个值得强调的细节:PlaceOrderUseCase 这个类本身没有任何 Spring 注解。它只是个普通的 Java 对象,容器只是”顺便”帮它注入了依赖。换句话说,即使明天把 Spring 换成 Guice,甚至完全不用 DI 框架、手写 new,业务代码一行都不用改。

五、它与 DDD、CQRS 如何配合

整洁架构不是孤立的,它和领域驱动设计(DDD)天然契合:最内圈放的就是 DDD 里的聚合根与领域服务,用例层则对应应用服务。当系统读写压力差异大时,还可以在用例层之上叠加CQRS(读写分离架构)——命令侧走用例修改实体,查询侧直接拼 DTO,两者共享实体层,互不污染。

换句话说,DDD 告诉你”业务长什么样”,CQRS 告诉你”读写怎么拆”,而整洁架构告诉你”这些代码该怎么摆、依赖该往哪指”。三者是互补关系,不是三选一。

六、落地时最容易踩的三个误区

误区正确做法为什么
给每一个类都加接口只在”依赖会跨越层边界且实现可能变化”处抽象盲目接口化会让代码膨胀、阅读负担翻倍
用例层写成贫血转发器把业务判断、校验、状态流转放进用例或实体否则业务又悄悄回流到 Controller,架构形同虚设
把 JPA 注解写进实体层持久化注解留在适配层的”数据模型”上实体层一旦依赖 ORM,就又被框架绑架回去了

其中第三点最容易被忽略。很多人图省事,直接用 @Entity 注解的类和领域实体混为一谈,结果持久化框架的字段约束、懒加载语义悄悄渗透进业务。干净的做法是:内层有纯业务的 Order,适配层有只负责映射的 OrderPo,两者在仓储实现里转换。多写一点转换代码,换来的是内层十年的稳定。

七、什么时候不该用整洁架构

架构不是越”干净”越好,而是越”合适”越好。对于纯 CRUD 后台、生命周期只有几周的原型、或者团队尚不熟悉分层思想的项目,强行上四层同心圆,只会把简单问题复杂化:一个接口要建命令类、结果类、用例类、仓储接口、仓储实现、控制器六七个文件,开发效率反而下降。

可以参考微服务熔断降级里的取舍思路:引入任何机制都要先问”它解决的核心痛点我有没有”。当你的系统确实面临”框架频繁更替””业务逻辑复杂且需高覆盖单测””多终端共用同一套业务”时,整洁架构的回报才真正显现。它和单例模式一样,是工具而非教条。

八、小结

整洁架构的精髓可以浓缩成三句话:用同心圆把代码按”稳定程度”分层;用依赖倒置让外层依赖内层、内层定义抽象;让框架永远待在最外圈,随时可被替换。它带来的最大红利不是”看起来高级”,而是业务规则的可测试性与可移植性——这也是代码可读性与长期可维护性的根基。下次当你又想在 Service 里直接 new 一个 Mapper 时,不妨停下来想一想:这段逻辑,五年后换掉数据库时,还动得动吗?

上一篇 tcpdump 实战:用命令行抓包排查网络故障
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]