大多数工程师都见过整洁架构的那张图:四个彩色圆环,箭头指向中心。但很少有人能说清,对于一个真实的功能,每个圆环里该放什么,import 应该朝哪个方向,或者 SQL 该放在哪里。
这种差距会在代码评审中暴露出来:一个 import 了 ORM 的 domain 目录,一个返回 HTTP 状态码的 use case,表单和数据表之间的五个 mapper。
本文把 Robert C. Martin 的圆环变成一个针对单个用例(下订单)的组件图,内容包括:分层、依赖规则、代码和目录、跨越边界、整洁架构与六边形和洋葱架构的对比、常见错误、何时属于过度设计,以及如何画图。
四个分层
Martin 在 2012 年的博客文章 The Clean Architecture 中描述了整洁架构,并在 2017 年出版的《Clean Architecture》一书中做了扩展。他把它看作此前各种思想的整合,其中包括六边形架构和洋葱架构。从中心向外依次是:
| 分层 | 包含内容 | 示例中的对应 | 何时变化 |
|---|---|---|---|
| Entities | 企业级业务规则,可跨应用复用 | Order 及其规则:一个订单至少需要一行明细 |
业务规则变化时 |
| Use cases | 应用级业务规则,每个对应一个工作流 | PlaceOrder、它的两个接口、请求和响应模型 |
工作流变化时 |
| Interface adapters | 控制器、presenter、网关以及全部 SQL | OrderController、OrderPresenter、PostgresOrderRepository |
API 或表结构变化时 |
| Frameworks and drivers | 工具和胶水代码:Web 框架、数据库、驱动 | HTTP 框架、PostgreSQL、pg、main.ts |
替换某个工具时 |
这些圆环只是示意;Martin 说你可能需要不止四层。把它们连起来的规则则永远不变。
依赖规则
用 Martin 的话说,「源代码依赖只能指向内侧」。内层圆环中的代码从不提及外层声明的函数、类或变量,也不使用外层的数据格式。对本例来说:
entities不从其他分层 import 任何东西。use-cases只 importentities:没有 Web 框架、ORM 或驱动。adapters向内 import,外加它们所适配的库。- 只有
main.ts能看到全部。
这条规则针对的是源代码,而不是运行时调用:运行时,PlaceOrder 会向外调用 presenter 和数据库。Martin 用依赖倒置原则解决这一点:use case 调用它自己拥有的接口,由外层的类来实现。调用向外,依赖向内。
确实有一处依赖指向外侧:adapter import 了数据库驱动。这是有意为之。Martin 把所有 SQL 都放在 adapter 层,比它更靠内的代码都「不应该对数据库有任何了解」。
在 CI 中强制执行这条规则:JVM 上用 ArchUnit 的 onionArchitecture() 检查,TypeScript 用 dependency-cruiser,Python 用 import-linter 的 layers contract。
完整示例:下订单
一个 POST /orders 请求的路径如下:
- Web 框架把它路由到
OrderController。 - 控制器把请求体解析成
PlaceOrderRequest(use cases 层拥有的普通对象),创建一个OrderPresenter,并调用PlaceOrder.execute()。 PlaceOrder让Order.place()应用业务规则,通过OrderRepository保存,并把PlaceOrderResponse交给PlaceOrderOutput。OrderPresenter实现该接口并构建视图模型:状态码 201、一个Location头和一个 JSON 响应体。- 控制器返回视图模型,框架把它写出去。
use cases 层,以及它拥有的两个接口:
// src/orders/use-cases/place-order.ts
import { Order } from '../entities/order';
export interface OrderRepository { // implemented by an adapter
nextId(): Promise<string>;
save(order: Order): Promise<void>;
}
export interface PlaceOrderOutput { // implemented by the presenter
present(response: PlaceOrderResponse): void;
}
export type PlaceOrderRequest = {
customerId: string;
lines: { sku: string; quantity: number }[];
};
export type PlaceOrderResponse = { orderId: string; itemCount: number };
export class PlaceOrder {
constructor(private readonly orders: OrderRepository) {}
async execute(request: PlaceOrderRequest, output: PlaceOrderOutput): Promise<void> {
const order = Order.place(await this.orders.nextId(), request.customerId, request.lines);
await this.orders.save(order);
output.present({ orderId: order.id, itemCount: order.itemCount() });
}
}
它只 import entity。订单没有明细或数量小于一时,Order.place() 会抛出异常,而 entity 文件不 import 任何东西。adapter 向内 import,外加驱动:
// src/orders/adapters/postgres/postgres-order-repository.ts
import { randomUUID } from 'node:crypto';
import type { Pool } from 'pg'; // adapters may import the driver
import type { Order } from '../../entities/order';
import type { OrderRepository } from '../../use-cases/place-order';
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly pool: Pool) {}
async nextId(): Promise<string> { return randomUUID(); }
async save(order: Order): Promise<void> {
await this.pool.query('INSERT INTO orders (id, customer_id, lines) VALUES ($1, $2, $3)',
[order.id, order.customerId, JSON.stringify(order.lines)]);
}
}
只有 composition root 知道每个具体类:
// src/main.ts
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const controller = new OrderController(new PlaceOrder(new PostgresOrderRepository(pool)));
app.post('/orders', async (req, res) => {
const view = await controller.post(req.body); // the presenter's view model
res.status(view.status).set(view.headers).json(view.body);
});
对 PlaceOrder 做单元测试,只需要一个内存仓库和一个记录输入的 presenter:不需要服务器,也不需要数据库。
目录结构
src/
orders/
entities/
order.ts Order and its rules
use-cases/
place-order.ts PlaceOrder, both interfaces, request and response models
adapters/
http/
order-controller.ts
order-presenter.ts
postgres/
postgres-order-repository.ts
main.ts composition root: builds and wires everything
先按功能分组,再按分层分组。Martin 的 screaming architecture 观点是:顶层目录应该说明系统做什么(orders、payments),而不是它用了哪个框架。Simon Brown 在同一本书中的章节把组织和封装区分开:目录负责组织代码,而访问规则或构建检查负责强制执行。
跨越边界
每次跨越边界,传递的都是某一层拥有的类型:
| 跨越点 | 传递的内容 | 类型归属 | 机制 |
|---|---|---|---|
| Framework → controller | HTTP 请求 | Framework | main.ts 中的胶水代码 |
| Controller → use case | PlaceOrderRequest |
Use cases | 直接调用 |
| Use case → presenter | PlaceOrderResponse |
Use cases | PlaceOrderOutput 接口 |
| Use case → 数据库 | Order |
Entities | OrderRepository 接口 |
| Presenter → framework | 视图模型 | Adapters | 由控制器返回 |
三个习惯能让这些跨越保持清晰:
- 普通的请求和响应模型。 Martin 说得很直接:「我们不想偷懒,把 Entity 或数据库行直接传过去。」presenter 拿到的是
PlaceOrderResponse,绝不是Order,所以重命名 entity 字段不会影响 API。仓库是一个常见的例外:许多代码库允许它们接收 entity,这仍然是指向内侧的。更严格的团队会在 adapter 内部映射成持久化记录。 - 每个向外的调用都有一个接口,由内层拥有,并以 use case 的需求命名,而不是以技术命名。
- 只有一个 composition root。 Mark Seemann 把 composition root 定义为靠近入口点的「应用中(最好)唯一的、把各模块组合在一起的位置」。Martin 把
main称为「终极细节」。其他一切都通过构造函数接收依赖。
Martin 的图里,interactor 前面还有一个 Use Case Input Port 接口。只有一个控制器和一个实现时,直接调用这个类是常见的简化做法。
整洁架构、六边形架构与洋葱架构
| 六边形(ports and adapters) | 洋葱 | 整洁 | |
|---|---|---|---|
| 奠基文本 | Alistair Cockburn,2005 年的文章 | Jeffrey Palermo,2008 年的博客系列 | Robert C. Martin,2012 年的博客文章、2017 年的书 |
| 图形 | 应用在六边形内,adapter 在外 | 围绕领域模型的圆环 | 四个同心圆 |
| 内部结构 | 未作规定 | 领域模型、领域服务、应用服务 | Entities、use cases |
| 仓库的实现 | 次要(被驱动)adapter | 基础设施,外环 | Interface adapters |
| 核心规则 | 内部代码不能泄漏到外部;通过 port 通信 | 所有耦合都指向中心 | 源代码依赖指向内侧 |
三者都把业务规则放在中间,让外围依赖它们。区别在于对内部结构规定了多少:Cockburn 的原始文章只区分内部和外部,Palermo 的洋葱架构给领域模型外的圆环命名,整洁架构则增加了 presenter 以及请求和响应模型。六边形架构图用 ports and adapters 的术语讲解了一个类似的订单示例。
常见错误
- Entities 中出现框架类型。 在
Order上加 ORM 装饰器或序列化注解,会把最内层绑到最外层。请在 adapter 中做映射。 - 什么都配一个 mapper。 一个只保存一行的表单,却有六份相同字段的副本。只在形状不同的地方做映射。
- 没有行为的分层。 只有 getter 和 setter 的 entity、Martin Fowler 在 2003 年描述的贫血领域模型,以及只调用
save()的 use case。 - 到处都是接口。 接口属于边界,而不是每个类。
- use case 中出现 HTTP。 状态码属于 presenter。
何时属于过度设计
Palermo 写道,洋葱架构「不适用于小型网站」,而 Martin 关于部分边界的章节以「完整的架构边界代价高昂」开头。
| 情形 | 更合适的做法 |
|---|---|
| 规则很少的 CRUD 服务 | 一个轻量的分层应用;把少数规则放在控制器之外 |
| 原型或短命工具 | 不分层 |
| 一个易变的依赖,例如支付服务商 | 为它设一个接口和一个 adapter |
| 有真实规则和多个入口的长期产品 | 完整的四层 |
该章还介绍了成本更低的部分边界;当第二个实现或某个测试需要时,再把其中一个升级。
整洁架构图
图表内容
- Frameworks and drivers
- Interface adapters
- Use cases
- Entities
- Web framework
- PostgreSQL
- OrderPresenter
- OrderController
- PostgresOrderRepository
- PlaceOrderOutput
- PlaceOrder
- OrderRepository
- Order
- POST /orders
- execute(request)
- Order.place()
- reads view model
- present(response)
- save(order)
- implements
- SQL via pg driver
这张图的读法如下:
- 四个 UML 包,最外层在左。 向右移动就是向内移动。
- 实线箭头是调用和 import。 其中三条跨越边界指向 entities。另外三条留在同一层内:
present(response)和save(order)调用 use cases 层拥有的那两个接口。 - 带空心三角的虚线箭头表示实现。 控制流向外时,presenter 和仓库向内指向这些接口。
- 点线是通过驱动执行的 SQL,它从 adapter 出发。
- 注释和 Markdown 块说明
main.ts的位置、entity 强制的规则,以及每种线型的含义。
把整洁架构图画清楚
- 画一个 use case,而不是画圆环。 用真实的类名追踪一个真实功能。
- 把每个接口放在拥有它的那一层。
OrderRepository放在PlaceOrder旁边,而不是它的实现旁边。 - 使用两种箭头样式: 实线表示调用,带空心三角的虚线表示实现,与 UML 的实现关系一致。UML 类图示例介绍了这套符号。
- 让「向内」在页面上只有一个方向。 反向的箭头要么是违规,要么是需要标注的运行时调用。
- 在箭头上标注方法和跨越边界的模型。
- 省略: DTO 字段、框架内部细节和 DI 容器。
关于这个代码层面视图周围的系统,请参阅 C4 模型图示例和软件架构图绘制指南。
在 Archboard 中构建
- 用 Google 或 GitHub 登录并创建一个图表。
- 从 UML 包中并排添加四个 包 节点:Frameworks and drivers、Interface adapters、Use cases 和 Entities,强调色依次为蓝、绿、红、琥珀。
- 在 Frameworks and drivers 中,添加一个来自 UML 包、名为 Web framework 的 组件,以及一个来自 数据 包、名为 PostgreSQL 的 Postgres 节点。
- 在 Interface adapters 中,添加两个 组件 节点 OrderPresenter 和 OrderController,以及一个名为 PostgresOrderRepository 的 类。
- 在 Use cases 中,在 PlaceOrderOutput 和 OrderRepository 两个 接口 节点之间,添加一个名为 PlaceOrder 的 类。
- 在 Entities 中,添加一个名为 Order 的 类,并写上它的字段和方法。
- 为调用画六条带标签的实线边,为实现画两条带三角箭头的虚线边,再画一条从 PostgresOrderRepository 到 PostgreSQL 的点线边。
- 从 基础 包添加一个写明规则和线型的 Markdown 块,并为每一层从 基础 包添加 文本 注释。
用 Mermaid 转可编辑图表工具,可以把你的分层 Mermaid 流程图变成初稿。Free 套餐每个图表允许 15 个图表节点,所以在那里每个图表只追踪一个 use case。
这个示例共用 14 个图表节点,在 Free 套餐的限制之内。
常见问题
整洁架构的四个分层是什么? Entities(企业级业务规则)、use cases(应用级业务规则)、interface adapters(控制器、presenter 和网关)以及 frameworks and drivers(Web 框架、数据库和其他工具)。Martin 称这些圆环只是示意:你可能需要不止四层。
什么是依赖规则? 源代码依赖只指向内侧。内层中的代码从不提及外层声明的任何内容,所以 use cases 对控制器、SQL 和 Web 框架一无所知。
整洁架构和六边形架构有什么区别? 两者都把业务规则放在中间,让 adapter 依赖它们。六边形架构只通过 port 区分内部和外部。整洁架构还把内部拆分为 entities 和 use cases,并给 presenter 以及请求和响应模型命名。
整洁架构中,仓库接口应该放在哪里? 放在 use cases 层,紧挨着需要它的 use case。包含 SQL 的实现属于 interface adapters 层,由 composition root 在启动时传入。
整洁架构对小项目是不是过度设计? 常常是。对于 CRUD 服务、原型和短命工具,多出来的接口和映射得不偿失。为你预计会变化的依赖添加接口,等真正的业务规则出现后再扩充分层。