Givepo
博客

整洁架构与 SOLID 原则:编写长期易维护的软件

为什么代码库越大越难改,以及整洁架构、依赖规则和 SOLID 原则如何让业务逻辑易于测试和修改,附完整的 TypeScript 示例。

Givepo 团队
阅读约 7 分钟
Illustration of concentric architecture layers with arrows pointing inward to the core, beside a SOLID principles checklist软件工程

大多数软件的失败,并不是因为它无法被构建出来,而是因为一两年后,每次修改都比上一次更费时,小小的修复会弄坏不相关的功能,没人敢再碰核心代码。整洁架构和 SOLID 原则正是为防止这种情况而生的一组理念:把系统中因不同原因而变化的部分彼此隔离开。

代码库为何会腐化

腐化几乎总是源于耦合:业务规则知道 HTTP 请求、数据库行和 UI 组件的存在。当定价逻辑写在一个既解析 JSON 又拼写 SQL 的控制器里时,不启动 Web 服务器和数据库就无法测试定价;更换数据库就必须改动定价逻辑;而想在后台任务中复用定价逻辑更是无从谈起。

好的架构就是划清边界,让最有价值的代码,也就是业务规则,依赖尽可能少的东西。

依赖规则

按照 Robert C. Martin 的描述,整洁架构把代码组织为若干同心圆层:

  1. 实体(Entities)位于中心:核心业务对象和规则,例如订单及其总价的计算方式。
  2. 用例(Use cases)环绕其外:应用特有的业务流程,例如下单或退款。
  3. 接口适配器:控制器、展示器和仓储,负责在用例与外部世界之间转换数据。
  4. 框架与驱动位于最外层:Web 框架、数据库、UI、第三方服务。

让这一切成立的只有一条规则:源代码的依赖只能指向内层。内层对外层一无所知。用例从不导入数据库客户端,而是声明一个描述自身需求的接口,即端口(port),由外层提供具体实现,即适配器(adapter)。这也是这种风格又被称为端口与适配器或六边形架构的原因。

一个能清楚体现分层的目录结构可能如下所示:

Text
src/
  domain/            entities and value objects; no framework imports
    order.ts
  application/       use cases and the ports they need
    ports.ts
    place-order.ts
  infrastructure/    adapters: database, email, payment provider
    postgres-order-repository.ts
    email-notifier.ts
  interface/         HTTP handlers, CLI commands, UI
    http/orders-controller.ts
  main.ts            composition root: wires adapters into use cases

一个完整的 TypeScript 示例

从实体开始。它只包含纯粹的业务规则,不导入任何框架代码:

TypeScript
// domain/order.ts
export type OrderLine = { sku: string; quantity: number; unitPriceCents: number }

export class Order {
  private constructor(
    readonly id: string,
    readonly customerId: string,
    readonly lines: readonly OrderLine[]
  ) {}

  static create(id: string, customerId: string, lines: OrderLine[]): Order {
    if (lines.length === 0) throw new Error('An order needs at least one line')
    if (lines.some((line) => line.quantity <= 0)) throw new Error('Quantities must be positive')
    return new Order(id, customerId, lines)
  }

  get totalCents(): number {
    return this.lines.reduce((sum, line) => sum + line.quantity * line.unitPriceCents, 0)
  }
}

用例描述业务流程,并声明它所依赖的端口。注意,这些接口归应用层所有,而不是归数据库代码所有:

TypeScript
// application/ports.ts
import type { Order } from '../domain/order'

export interface OrderRepository {
  save(order: Order): Promise<void>
}

export interface Notifier {
  orderPlaced(order: Order): Promise<void>
}

// application/place-order.ts
import { Order, type OrderLine } from '../domain/order'
import type { Notifier, OrderRepository } from './ports'

export class PlaceOrder {
  constructor(
    private readonly orders: OrderRepository,
    private readonly notifier: Notifier
  ) {}

  async execute(input: { customerId: string; lines: OrderLine[] }) {
    const order = Order.create(crypto.randomUUID(), input.customerId, input.lines)
    await this.orders.save(order)
    await this.notifier.orderPlaced(order)
    return { orderId: order.id, totalCents: order.totalCents }
  }
}

外层的适配器用真实技术实现端口,组合根(composition root)在启动时把一切连接起来:

TypeScript
// infrastructure/postgres-order-repository.ts
import type { Pool } from 'pg'
import type { OrderRepository } from '../application/ports'
import type { Order } from '../domain/order'

export class PostgresOrderRepository implements OrderRepository {
  constructor(private readonly db: Pool) {}

  async save(order: Order) {
    await this.db.query('INSERT INTO orders (id, customer_id, total_cents) VALUES ($1, $2, $3)', [order.id, order.customerId, order.totalCents])
  }
}

// main.ts
const placeOrder = new PlaceOrder(new PostgresOrderRepository(pool), new EmailNotifier(mailer))

回报体现在测试上。用例只需简单的内存假对象就能在几毫秒内完成测试,不需要数据库、不需要邮件服务器,也不需要 mock 库:

TypeScript
import { expect, test } from 'vitest'
import { PlaceOrder } from './place-order'
import type { Order } from '../domain/order'

test('places an order and notifies the customer', async () => {
  const saved: Order[] = []
  const notified: Order[] = []
  const placeOrder = new PlaceOrder({ save: async (order) => void saved.push(order) }, { orderPlaced: async (order) => void notified.push(order) })

  const result = await placeOrder.execute({
    customerId: 'customer-1',
    lines: [{ sku: 'TS-1042', quantity: 2, unitPriceCents: 1500 }],
  })

  expect(result.totalCents).toBe(3000)
  expect(saved).toHaveLength(1)
  expect(notified).toHaveLength(1)
})

SOLID 原则逐条解读

SOLID 是五条面向对象设计原则,它们在单个类和模块的层面上服务于同一个目标。

S:单一职责原则

一个模块应该只有一个变化的理由,也就是说它只对一类人或一个关注点负责。一个既计算税费、又生成 PDF、还发送邮件的发票类,每当财务、设计或 IT 部门改变想法时都要修改。应把它拆分为计算器、渲染器和发送器。

O:开闭原则

代码应当对扩展开放,对修改关闭:通过新增代码来增加行为,而不是修改已经测试过的代码。常用的技巧是策略模式:

TypeScript
interface DiscountRule {
  appliesTo(order: Order): boolean
  amountCents(order: Order): number
}

const rules: DiscountRule[] = [new LoyaltyDiscount(), new BulkOrderDiscount()]

export const discountCents = (order: Order) => rules.filter((rule) => rule.appliesTo(order)).reduce((sum, rule) => sum + rule.amountCents(order), 0)

// A new promotion is a new class in the list, not another branch in a growing if/else

L:里氏替换原则

接口的任何实现都必须能在任何需要该接口的地方使用,而不会让调用方感到意外。一个实现了 OrderRepository 却在调用 save() 时抛出异常的 ReadOnlyOrderRepository 就违反了这一原则,依赖该契约的调用方会在运行时出错。如果某个实现无法履行完整的契约,它就需要一个不同的、更小的接口。

I:接口隔离原则

客户端不应依赖它用不到的方法。与其使用一个包含二十个方法的大接口,不如使用多个小而专注的接口,例如 OrderReader 和 OrderWriter。小接口更容易实现、更容易在测试中伪造,也更容易修改。

D:依赖倒置原则

高层策略应当依赖抽象,而不是依赖具体细节。上面的 PlaceOrder 用例依赖的是 OrderRepository 接口,而不是 PostgreSQL,因此更换数据库时业务逻辑完全不受影响。这条原则正是依赖规则的驱动力。

值得了解的设计模式

  • 仓储(Repository):把实体的存储方式隐藏在一个类似集合的接口后面。
  • 策略(Strategy):在同一个接口后面替换可互换的算法,例如定价、运费或折扣规则。
  • 适配器(Adapter):用你的应用定义的接口包装第三方 API。
  • 工厂(Factory):集中处理复杂对象的创建和校验,就像 Order.create 那样。
  • 观察者与领域事件:让系统的其他部分响应“订单已创建”,而订单本身无需知道谁在监听。
  • 装饰器(Decorator):在现有实现外层添加缓存、日志或重试,而不修改它本身。

何时不该过度设计

整洁架构是一种工具,而不是一种信仰。一个业务逻辑很少的小型 CRUD 应用并不需要四层结构和一堆接口,这些繁文缛节的成本会超过它带来的收益。请根据代码的复杂度和预期生命周期,适度地运用这些理念:

  • 无论项目大小,始终把业务规则放在控制器和 UI 组件之外。这几乎没有成本。
  • 在有充分理由的地方引入端口和适配器:可能被替换的外部服务、需要快速测试的逻辑,或者 API、后台任务、CLI 等多个入口。
  • 不要创建只有一个实现、也没有测试替身的接口。等到第二个需求出现时再加(you aren’t gonna need it,即 YAGNI)。
  • 以修改真正令人痛苦的地方为指引,一次一个模块,逐步向这种架构重构。

要点回顾

  • 可维护性来自对依赖的管理:让业务规则独立于框架和基础设施。
  • 依赖指向内层。用例声明端口,外层提供适配器。
  • SOLID 把同样的思路应用到类和模块上:单一职责、扩展而非修改、诚实的契约、小接口,以及依赖抽象。
  • 让架构的复杂程度与问题本身的复杂度相匹配。

总结

归根结底,整洁架构是为了保护你最有价值的代码,也就是让业务运转的那些规则,使其免受框架、数据库和交付方式频繁变化的冲击。让依赖指向内层,让模块保持专注,并在不依赖基础设施的情况下测试业务逻辑。这样,你的代码库在第五年依然能像第一年一样快速响应变化。

延伸阅读

  • #Architecture
  • #Clean Architecture
  • #Design Patterns
  • #SOLID
  • #TypeScript
作者
Givepo 团队

Givepo 是一家 IT 公司,专注于开发网站、移动应用和定制软件,助力您的业务持续发展。

分享本文

免费 30 分钟咨询

与我们的团队沟通您的想法、需求范围和预算。

预约咨询

更多文章

博客
后端与 API
·阅读约 7 分钟

后端架构详解:REST API、微服务与可扩展数据库

如何设计开发者乐于使用的 REST API,微服务何时值得付出代价,如何设计能承受高负载的数据库,以及让后端系统保持快速和可靠的模式。

查看详情
网站开发
·阅读约 6 分钟

使用 Next.js、React 和 TypeScript 进行现代 Web 开发:最佳实践

Server Components、渲染策略、类型安全的数据与 Core Web Vitals。一份实用指南,教你用当今大多数团队选择的技术栈构建快速、易维护的 Web 应用。

查看详情
人工智能与机器学习
·阅读约 7 分钟

人工智能与机器学习实战:它们在哪里创造真正的商业价值

拨开炒作的迷雾。了解机器学习和生成式 AI 的真实工作原理、它们当下在哪些场景创造可衡量的价值,以及如何让第一个 AI 项目真正上线,而不是止步于演示。

查看详情