整洁架构管依赖方向

整洁架构(Clean Architecture)回答的是:依赖往哪指。 业务对象不能依赖 Activity、Room、Retrofit;外层通过内层定义的接口靠过来。

它不回答「已支付能不能取消」该写在哪个方法里。那是 领域驱动设计:用订单案例讲清领域建模与分层 的事。可以只做分层、对象仍然贫血;也可以先把规则放进 Order,目录还挤在一个包里。两者独立,能叠用,不是同一套名词。

Robert C. Martin 在 2012 年的博客 The Clean Architecture 里把它画成同心圆,2017 年出书展开。圆的数量可以增减,依赖规则不能破:源码依赖只能指向圆心。内层代码不得提到外层的函数、类、变量或数据格式。

整洁架构的同心圆与依赖方向

图 1:外层是会换的细节,内层是更稳定的规则。越往里越不该出现页面、数据库、HTTP 的类型。

层放什么不放什么
EntitiesUser、Order、企业规则Context、Room、Retrofit
Use Cases一次业务动作的编排:查重、调用实体、保存解析 Intent、拼 SQL、刷新 RecyclerView
Interface AdaptersController、Presenter / ViewModel、仓储实现外层表行、HTTP Response 直接传入用例
Frameworks & DriversActivity、Compose、Room、SQLite、Web 框架被内层 import 的类型

同心圆是示意图,不是必须建四个 Gradle 模块。Android 常把四层收成 domain / presentation / data,目录树写在「一次注册穿过各层」。检查标准不变:domain 不出现 Android SDK 和数据库类型。

为什么分层还是会腐

第一版详情页把规则写在点击回调里也能跑:

public void onCancelClicked() {
    if (order.status == OrderStatus.PAID) {   // 规则的第一份副本
        toast("已支付不能取消");
        return;
    }
    orderDao.update(order);                   // 界面直接认识数据库
    refresh();
}

入口一增加,规则就多一份。列表左滑、支付回调、本地缓存同步,各自 if 一遍。更糟的是 Order 开始认识外层:

public class Order {
    public void cancel(Context context, OrderDao dao) { // 实体已经依赖框架
        if (status == PAID) {
            Toast.makeText(context, "已支付不能取消", Toast.LENGTH_SHORT).show();
            return;
        }
        dao.update(this);
    }
}

规则复制是 DDD 要收的问题。Order 持有 Context 和 OrderDao,才是整洁架构要拦的依赖方向:圆心已经认识外层,换 Room 或换页面都要改实体,规则也无法在内存里单独测。少一个 UseCase 类名解决不了这件事。

MVC、MVP、MVVM 管界面怎么刷新,可以停在适配层:ViewModel 把点击转成对用例的调用。它们不是整洁架构。View 直接读写 Model、Controller 里堆业务 if,只是界面层内部的平面耦合;再叠加 Order import Context,才同时破了依赖规则。

依赖规则与依赖倒置

运行时调用可以进出边界:Controller 调用用例,用例调用仓储,仓储再写库。源码依赖必须始终向内。做到这一点靠依赖倒置:接口写在内层,实现写在外层。

控制流可以穿过边界,源码依赖必须向内

图 2:实线是运行时调用,可以穿过边界。虚线才是源码依赖:InMemoryUserRepository 提到 UserRepository,内层不提到内存实现。

跨边界时,外层格式不能进内层。RegisterUserInput 这种简单结构可以进用例;Room @Entity、Cursor、Retrofit Response 不能进。仓储端口说的是内层的 User,这是内层类型,不是外层表行。内层一旦认识外层的数据格式,换库或换接口时圆心也会跟着改。

接线发生在最外层。Main、Hilt、手工组装都可以,职责是:用具体仓储构造用例,再用用例构造 Controller。内层自己不 new 外层实现。

输出也可以是端口。用例可以依赖 RegisterUserOutput,由 Presenter 或 ViewModel 实现,这样用例也不认识界面。下面的注册例子用返回字符串简化,没有单独画出输出端口。

一次注册穿过各层

下面用用户注册把四层落到目录和代码。例子用 Java、不依赖框架,只为了看清依赖方向。订单提交走同一条骨架,差别在实体里有没有真正的行为。

整洁架构分层与目录落地

图 3:层名对应目录。Controller 调用用例,Interactor 调用 User 与仓储端口;InMemoryUserRepository 实现内层接口,源码依赖向内。

Java 注册 Demo 目录树

目录按层排,不按功能名打散。树的阅读顺序与图 3 相同:上外下内。

src/
├── Main.java                                // Frameworks & Drivers · 组合根接线
├── web/                                     // Interface Adapters · UI / HTTP 侧
│   └── RegisterUserController.java          // 请求 → Input → 调用用例
├── data/                                    // Interface Adapters · 数据侧
│   └── InMemoryUserRepository.java          // implements UserRepository
├── usecase/                                 // Use Cases · 本应用一次注册
│   ├── RegisterUser.java                    // 输入端口,Controller 只依赖它
│   ├── RegisterUserInput.java               // 跨边界的简单结构
│   └── RegisterUserInteractor.java          // 查重、加密、创建 User、交给仓储
├── repository/                              // Entities 边界上的端口,仍属内层
│   └── UserRepository.java                  // save / findByUsername
└── entity/                                  // Entities · 最内层
    └── User.java                            // 企业对象,不 import 框架
目录同心圆职责允许依赖
entity/、repository/Entities对象与端口标准库
usecase/Use Cases一次注册的编排entity、repository
web/、data/Interface AdaptersController、仓储实现内层接口;实现里才出现存储细节
Main.javaFrameworks & Drivers用具体仓储构造用例,再用用例构造 Controller所有外层实现

UserRepository 必须和 User 同在内层。放到 data/ 后再让 usecase 去 import,依赖就已经向外了。

Android 三层目录树

不必先建四个 Gradle 模块。常见三层只是把圆心收进 domain,把适配器拆开:

app/
├── presentation/                            // Adapters 的 UI 侧 + 界面框架
│   ├── RegisterActivity.kt
│   ├── RegisterScreen.kt                    // Compose
│   └── RegisterUserViewModel.kt
├── data/                                    // Adapters 的数据侧 + 存储 / 网络框架
│   ├── InMemoryUserRepository.kt
│   ├── RoomUserRepository.kt                // 可选,implements UserRepository
│   └── UserDto.kt                           // 外层表行 / JSON,不得传入 usecase
├── di/                                      // Frameworks · Hilt 模块,最外层接线
│   └── UserModule.kt
└── domain/                                  // Entities + Use Cases · 圆心
    ├── User.kt
    ├── UserRepository.kt                    // 端口仍在 domain
    ├── RegisterUser.kt
    ├── RegisterUserInput.kt
    └── RegisterUserInteractor.kt
Android 目录对应同心圆
domain/Entities + Use Cases
presentation/Interface Adapters 的 UI 侧 + Frameworks 的界面
data/Interface Adapters 的数据侧 + Frameworks 的存储和网络
di/Frameworks & Drivers 的组合根

检查标准不变:domain/ 不出现 Android SDK、Room、Retrofit。UserDto 停在 data/,仓储返回的是 User。

实体只表达规则,不认识保存方式:

public class User {
    private final String id;
    private final String username;
    private final String password;        // 已加密
 
    public User(String id, String username, String password) {
        this.id = id;
        this.username = username;
        this.password = password;
    }
 
    public static String encryptPassword(String rawPassword) {
        return "encrypted_" + rawPassword; // 演示用;真实系统用 BCrypt 一类算法
    }
 
    public String getId() { return id; }
    public String getUsername() { return username; }
    public String getPassword() { return password; }
}

输入端口也写在用例层。Controller 依赖这个接口,不依赖具体 Interactor:

public interface RegisterUser {
    void execute(RegisterUserInput input) throws Exception;
}

输入是简单结构,不是 HTTP Request,也不是数据库行:

public class RegisterUserInput {
    private final String username;
    private final String password;
 
    public RegisterUserInput(String username, String password) {
        this.username = username;
        this.password = password;
    }
 
    public String getUsername() { return username; }
    public String getPassword() { return password; }
}

仓储接口由内层定义,可以和 entity、usecase 放在一起,不能放进 data 实现包后再让内层去 import。用例说「我要按用户名查找、保存 User」,不说 HashMap 还是 Room:

public interface UserRepository {
    void save(User user);
    Optional<User> findByUsername(String username);
}

用例编排一次动作。它依赖接口,不依赖实现:

public class RegisterUserInteractor implements RegisterUser {
    private final UserRepository userRepository;
 
    public RegisterUserInteractor(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
 
    @Override
    public void execute(RegisterUserInput input) throws Exception {
        if (userRepository.findByUsername(input.getUsername()).isPresent()) {
            throw new Exception("Username already exists");
        }
 
        String encrypted = User.encryptPassword(input.getPassword());
        User user = new User(
                String.valueOf(System.currentTimeMillis()),
                input.getUsername(),
                encrypted);
        userRepository.save(user);
    }
}

内存实现属于外层。换成 Room 或 HTTP 时,只换这个类:

public class InMemoryUserRepository implements UserRepository {
    private final Map<String, User> store = new ConcurrentHashMap<>();
 
    @Override
    public void save(User user) {
        store.put(user.getUsername(), user);
    }
 
    @Override
    public Optional<User> findByUsername(String username) {
        return Optional.ofNullable(store.get(username));
    }
}

Controller 停在适配层。它把请求收成 RegisterUserInput,不查重、不加密:

public class RegisterUserController {
    private final RegisterUser registerUser;
 
    public RegisterUserController(RegisterUser registerUser) {
        this.registerUser = registerUser;
    }
 
    public String handleRequest(String username, String password) {
        try {
            registerUser.execute(new RegisterUserInput(username, password));
            return "User registered successfully!";
        } catch (Exception e) {
            return "Error: " + e.getMessage();
        }
    }
}

Main 在最外层把箭头接上:

public class Main {
    public static void main(String[] args) {
        UserRepository repo = new InMemoryUserRepository();
        RegisterUser useCase = new RegisterUserInteractor(repo);
        RegisterUserController controller = new RegisterUserController(useCase);
 
        System.out.println(controller.handleRequest("alice", "secret123"));
        System.out.println(controller.handleRequest("alice", "another"));
    }
}

运行结果:

User registered successfully!
Error: Username already exists

这一层结构已经满足依赖规则:换 UI 或换存储,User 和 RegisterUserInteractor 不用改。它还不是 DDD。Uncle Bob 把内层规则分成两级:Entities 是跨应用仍成立的企业规则,Use Cases 是「本应用如何完成一次注册」这种应用规则。查重放在 Interactor 里,依赖方向是对的,只是规则还没收到对象上。订单那篇要把 submit() / pay() / cancel() 放进 Order,用例就只负责加载、调用、保存。

验收标准与常见走偏

看 import 和数据形状,不看目录名。

  • 实体和用例不 import 数据库驱动、UI 框架、网络库(标准库除外)。
  • 外层实现内层接口;内层不 new 外层类。
  • 换 Web、命令行、Activity,不改用例。
  • 换 SQL、内存、文件,不改用例。
  • 第三方支付、消息队列停在外层,经接口进入。
  • 业务用例能在纯单元测试里跑,不必起数据库或页面。
  • 跨层传递简单结构或端口,不把 Cursor、Response Body 漏进圆心。

常见走偏:

  • 先建 :domain :data :presentation,Order 仍只有 setter。目录是地图,不是目标。
  • Interactor 直接 new InMemoryUserRepository(),或 User 持有 Context。依赖规则已经破了。
  • 把 Room Entity 当成领域实体传进用例。仓储返回的应是内层对象。
  • 每个按钮都建一个空 UseCase,规则仍散落在 ViewModel。多一层名字,没有收回依赖。

客户端上,MVC / MVI 继续管屏幕。对象开始 import Context 或 Retrofit 时,按整洁架构把目录和依赖方向拉开。同一条规则已经出现在详情、列表、回调三处时,那是把行为放进对象的时机,见订单那篇。不必先上整套术语。

与 DDD、Android 三层的对应

整洁架构DDD
要解决的问题换 UI 或换数据库时,核心代码不用跟着改业务规则变复杂时,入口不再各自复制 if
主要产物同心圆、依赖只能向内实体、值对象、聚合、仓储、领域事件
在订单里对应什么adapter → application → domain,仓储接口由 domain 定义Order.submit()、OrderItem、Money、OrderRepository
没有它会怎样Order 里出现 Context 或 Retrofit,规则无法单独测分层很干净,但 Order 仍是只有 setter 的贫血对象

Android 里常见对应:

Clean Architecture常见 Android / DDD 叫法
EntitiesDomain 模型、聚合根
Use Cases应用服务、Interactor
Interface AdaptersViewModel、Controller、RepositoryImpl
Frameworks & DriversActivity / Compose、Room、Retrofit

Hilt / Koin 只负责最外层接线,见 Hilt、SpringDI以及厂内DI的异同。Wrapper 包装层:用稳定入口隔开复杂世界 是局部把脏细节挡住;整洁架构是把挡住这件事写成全项目的依赖方向。

资料来源


相关笔记