跳到正文

整洁架构图:分层、依赖规则与完整示例

把 Robert C. Martin 的整洁架构画成可动手构建的图:四个分层、依赖规则,以及带代码和目录的 PlaceOrder 示例。

· 创始人兼开发者

发布于

并排的四个 UML 包:frameworks and drivers、interface adapters、use cases 和 entities。Web framework 调用 OrderController,它调用 PlaceOrder,PlaceOrder 创建 Order。OrderPresenter 和 PostgresOrderRepository 实现 use cases 层中的接口。

大多数工程师都见过整洁架构的那张图:四个彩色圆环,箭头指向中心。但很少有人能说清,对于一个真实的功能,每个圆环里该放什么,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 只 import entities:没有 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 请求的路径如下:

  1. Web 框架把它路由到 OrderController。
  2. 控制器把请求体解析成 PlaceOrderRequest(use cases 层拥有的普通对象),创建一个 OrderPresenter,并调用 PlaceOrder.execute()。
  3. PlaceOrder 让 Order.place() 应用业务规则,通过 OrderRepository 保存,并把 PlaceOrderResponse 交给 PlaceOrderOutput。
  4. OrderPresenter 实现该接口并构建视图模型:状态码 201、一个 Location 头和一个 JSON 响应体。
  5. 控制器返回视图模型,框架把它写出去。

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
有真实规则和多个入口的长期产品 完整的四层

该章还介绍了成本更低的部分边界;当第二个实现或某个测试需要时,再把其中一个升级。

整洁架构图

并排的四个 UML 包:frameworks and drivers、interface adapters、use cases 和 entities。Web framework 调用 OrderController,它调用 PlaceOrder,PlaceOrder 创建 Order。OrderPresenter 和 PostgresOrderRepository 实现 use cases 层中的接口。

20 个组件 · 9 个连接

在 Archboard 中打开
图表内容
  • 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
以一个用例 PlaceOrder 为线索,穿过整洁架构的四个分层,最外层在左。没有任何箭头指向外侧:presenter 和 Postgres 仓库实现的是 use cases 层拥有的接口,因此控制流向外,依赖却指向内。

这张图的读法如下:

  • 四个 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 中构建

  1. 用 Google 或 GitHub 登录并创建一个图表。
  2. 从 UML 包中并排添加四个 包 节点:Frameworks and drivers、Interface adapters、Use cases 和 Entities,强调色依次为蓝、绿、红、琥珀。
  3. 在 Frameworks and drivers 中,添加一个来自 UML 包、名为 Web framework 的 组件,以及一个来自 数据 包、名为 PostgreSQL 的 Postgres 节点。
  4. 在 Interface adapters 中,添加两个 组件 节点 OrderPresenter 和 OrderController,以及一个名为 PostgresOrderRepository 的 类。
  5. 在 Use cases 中,在 PlaceOrderOutput 和 OrderRepository 两个 接口 节点之间,添加一个名为 PlaceOrder 的 类。
  6. 在 Entities 中,添加一个名为 Order 的 类,并写上它的字段和方法。
  7. 为调用画六条带标签的实线边,为实现画两条带三角箭头的虚线边,再画一条从 PostgresOrderRepository 到 PostgreSQL 的点线边。
  8. 从 基础 包添加一个写明规则和线型的 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 服务、原型和短命工具,多出来的接口和映射得不偿失。为你预计会变化的依赖添加接口,等真正的业务规则出现后再扩充分层。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。