整洁架构管依赖方向
整洁架构(Clean Architecture)回答的是:依赖往哪指。 业务对象不能依赖 Activity、Room、Retrofit;外层通过内层定义的接口靠过来。
它不回答「已支付能不能取消」该写在哪个方法里。那是 领域驱动设计:用订单案例讲清领域建模与分层 的事。可以只做分层、对象仍然贫血;也可以先把规则放进 Order,目录还挤在一个包里。两者独立,能叠用,不是同一套名词。
Robert C. Martin 在 2012 年的博客 The Clean Architecture 里把它画成同心圆,2017 年出书展开。圆的数量可以增减,依赖规则不能破:源码依赖只能指向圆心。内层代码不得提到外层的函数、类、变量或数据格式。
图 1:外层是会换的细节,内层是更稳定的规则。越往里越不该出现页面、数据库、HTTP 的类型。
| 层 | 放什么 | 不放什么 |
|---|---|---|
| Entities | User、Order、企业规则 | Context、Room、Retrofit |
| Use Cases | 一次业务动作的编排:查重、调用实体、保存 | 解析 Intent、拼 SQL、刷新 RecyclerView |
| Interface Adapters | Controller、Presenter / ViewModel、仓储实现 | 外层表行、HTTP Response 直接传入用例 |
| Frameworks & Drivers | Activity、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 Adapters | Controller、仓储实现 | 内层接口;实现里才出现存储细节 |
Main.java | Frameworks & 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 叫法 |
|---|---|
| Entities | Domain 模型、聚合根 |
| Use Cases | 应用服务、Interactor |
| Interface Adapters | ViewModel、Controller、RepositoryImpl |
| Frameworks & Drivers | Activity / Compose、Room、Retrofit |
Hilt / Koin 只负责最外层接线,见 Hilt、SpringDI以及厂内DI的异同。Wrapper 包装层:用稳定入口隔开复杂世界 是局部把脏细节挡住;整洁架构是把挡住这件事写成全项目的依赖方向。
资料来源
- Robert C. Martin, The Clean Architecture(2012-08-13)
- Robert C. Martin, Clean Architecture: A Craftsman’s Guide to Software Structure and Design(2017)
- 原文摘记:https://www.yuque.com/dongpozhouzi-mshe3/zhm85g/hql3g409nunpn0ql
相关笔记
- 领域驱动设计:用订单案例讲清领域建模与分层
- 继承体系中的差异治理:Template Method、Strategy 与组合
- Wrapper 包装层:用稳定入口隔开复杂世界
- Hilt、SpringDI以及厂内DI的异同
- Clean Architecture
- 观察者模式
- Event Sourcing:事件、投影与读模型
评论