你是否接手过这样的项目:路由处理器里直接写 SQL、业务逻辑和 HTTP 状态码纠缠在一起、换个数据库要改 30 多个文件?据 Stack Overflow 2025 开发者调查,72% 的后端开发者表示「代码耦合严重、难以测试」是他们最大的维护痛点。Clean Architecture(整洁架构)不是什么新概念——Robert C. Martin 在 2012 年就提出了——但大多数 TypeScript/Node.js 项目仍然没有真正落地。本文不讲理论,而是用完整的 TypeScript 代码,演示如何把 Clean Architecture 从 PPT 变成可运行的后端服务。
📌 记住: Clean Architecture 的核心不是文件夹结构,而是依赖规则——内层永远不知道外层的存在。理解这一点,比记住任何模板都重要。
🏗️ 一、Clean Architecture 核心模型与 TypeScript 落地
1.1 同心圆模型速览
Clean Architecture 的架构图是一个同心圆,从内到外分为四层:
| 层级 | 职责 | TypeScript 典型实现 | 依赖方向 |
|---|---|---|---|
| Entities(实体) | 核心业务规则、领域模型 | 类、值对象、领域事件 | 只依赖自身 |
| Use Cases(用例) | 应用级业务编排 | 函数/类,调用实体和接口 | 依赖实体层 |
| Interface Adapters(适配器) | 数据格式转换、控制器 | Controller、Repository 实现、Presenter | 依赖用例层 |
| Frameworks & Drivers(框架) | 外部工具和框架 | Express/Fastify、数据库驱动、消息队列 | 依赖适配器层 |
⚠️ 警告: 依赖方向是由外向内的——外层依赖内层,内层绝不依赖外层。这是 Clean Architecture 的铁律,违反它就等于白做。
1.2 项目结构实战
很多文章纠结文件夹命名,其实结构只需遵循一个原则:内层文件不应该 import 外层模块。以下是一个经过生产验证的结构:
src/
├── domain/ # Entities 层(最内层)
│ ├── entities/
│ │ ├── User.ts # 实体:包含业务规则
│ │ └── Order.ts
│ ├── value-objects/
│ │ ├── Email.ts # 值对象:不可变、自验证
│ │ └── Money.ts
│ ├── events/
│ │ └── UserCreated.ts # 领域事件
│ └── repositories/
│ └── IUserRepository.ts # 仓库接口(抽象)
│
├── application/ # Use Cases 层
│ ├── use-cases/
│ │ ├── CreateUser.ts # 用例:编排业务流程
│ │ └── GetUserOrders.ts
│ ├── dtos/
│ │ ├── CreateUserDTO.ts # 输入/输出数据传输对象
│ │ └── UserResponseDTO.ts
│ └── interfaces/
│ └── IPasswordHasher.ts # 端口接口
│
├── infrastructure/ # Interface Adapters 层
│ ├── repositories/
│ │ ├── PostgresUserRepo.ts # 具体仓库实现
│ │ └── InMemoryUserRepo.ts # 测试用实现
│ ├── services/
│ │ └── BcryptPasswordHasher.ts
│ └── mappers/
│ └── UserMapper.ts # 数据库模型 ↔ 领域实体 映射
│
├── presentation/ # Frameworks & Drivers 层(最外层)
│ ├── http/
│ │ ├── routes.ts
│ │ ├── controllers/
│ │ │ └── UserController.ts
│ │ └── middleware/
│ │ └── auth.ts
│ └── di/
│ └── container.ts # 依赖注入容器
│
└── main.ts # 组装入口
💡 提示: 这个结构不是唯一的「正确答案」。小项目可以把
infrastructure和presentation合并。关键不是文件夹数量,而是import 依赖方向。
1.3 依赖倒置的 TypeScript 实现
Clean Architecture 的核心武器是依赖倒置原则(Dependency Inversion Principle)——高层模块定义接口,低层模块实现接口。TypeScript 的 interface 和 abstract class 天然支持这一模式:
// domain/repositories/IUserRepository.ts
// ✅ 内层定义接口,不关心谁来实现
export interface IUserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
delete(id: string): Promise<void>;
}
// infrastructure/repositories/PostgresUserRepository.ts
// ✅ 外层实现接口,依赖内层的抽象
import { IUserRepository } from '../../domain/repositories/IUserRepository';
import { User } from '../../domain/entities/User';
import { UserMapper } from '../mappers/UserMapper';
import { db } from '../database/connection';
export class PostgresUserRepository implements IUserRepository {
async findById(id: string): Promise<User | null> {
const row = await db.query('SELECT * FROM users WHERE id = $1', [id]);
if (!row) return null;
return UserMapper.toDomain(row); // 数据库行 → 领域实体
}
async findByEmail(email: string): Promise<User | null> {
const row = await db.query('SELECT * FROM users WHERE email = $1', [email]);
if (!row) return null;
return UserMapper.toDomain(row);
}
async save(user: User): Promise<void> {
const data = UserMapper.toPersistence(user); // 领域实体 → 数据库行
await db.query(
`INSERT INTO users (id, name, email, password_hash, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET name = $2, email = $3`,
[data.id, data.name, data.email, data.password_hash, data.created_at]
);
}
async delete(id: string): Promise<void> {
await db.query('DELETE FROM users WHERE id = $1', [id]);
}
}
⚡ 关键结论: IUserRepository 接口定义在 domain/ 层,但实现在 infrastructure/ 层。这意味着你可以随时把 PostgreSQL 换成 MongoDB,只需要提供一个新的 MongoUserRepository implements IUserRepository,业务逻辑代码零修改。
🧪 二、从实体到用例:完整业务流程实现
2.1 实体:把业务规则封装在领域模型中
实体不是贫血的数据容器——它应该包含业务规则和不变量约束:
// domain/entities/User.ts
import { Email } from '../value-objects/Email';
import { UserCreated } from '../events/UserCreated';
export class User {
private _domainEvents: Array<{ type: string; payload: unknown }> = [];
private constructor(
public readonly id: string,
private _name: string,
private _email: Email,
private _passwordHash: string,
public readonly createdAt: Date,
) {}
// ✅ 工厂方法:封装创建逻辑,保证实体始终处于有效状态
static create(params: {
id: string;
name: string;
email: string;
passwordHash: string;
}): User {
if (params.name.trim().length < 2) {
throw new Error('用户名至少 2 个字符');
}
const email = Email.create(params.email); // 值对象自验证
const user = new User(
params.id,
params.name.trim(),
email,
params.passwordHash,
new Date(),
);
// 记录领域事件,由外层负责发布
user._domainEvents.push({
type: 'UserCreated',
payload: { userId: params.id, email: params.email },
});
return user;
}
// ✅ 业务规则在实体内部,而非散落在 Service 层
changeName(newName: string): void {
if (newName.trim().length < 2) {
throw new Error('用户名至少 2 个字符');
}
this._name = newName.trim();
}
get name(): string { return this._name; }
get email(): string { return this._email.value; }
get passwordHash(): string { return this._passwordHash; }
get domainEvents() { return [...this._domainEvents]; }
clearEvents(): void { this._domainEvents = []; }
}
// domain/value-objects/Email.ts
// ✅ 值对象:不可变、自验证、通过值相等
export class Email {
private constructor(public readonly value: string) {}
static create(email: string): Email {
const trimmed = email.trim().toLowerCase();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmed)) {
throw new Error(`无效的邮箱格式: ${email}`);
}
return new Email(trimmed);
}
equals(other: Email): boolean {
return this.value === other.value;
}
}
💡 提示: 很多开发者把实体做成只有 getter/setter 的「贫血模型」,把所有业务逻辑放在 Service 类中。这是 Clean Architecture 的反模式——业务规则应该住在实体里,Service(用例)只负责编排。
2.2 用例:编排业务流程
用例是应用层的核心,它协调实体和外部服务完成一个完整的业务动作:
// application/use-cases/CreateUser.ts
import { randomUUID } from 'crypto';
import { User } from '../../domain/entities/User';
import { IUserRepository } from '../../domain/repositories/IUserRepository';
import { IPasswordHasher } from '../interfaces/IPasswordHasher';
import { CreateUserDTO, UserResponseDTO } from '../dtos/CreateUserDTO';
export class CreateUserUseCase {
// ✅ 依赖注入:通过构造函数注入抽象接口,而非具体实现
constructor(
private readonly userRepo: IUserRepository,
private readonly passwordHasher: IPasswordHasher,
) {}
async execute(dto: CreateUserDTO): Promise<UserResponseDTO> {
// 1. 检查邮箱是否已存在
const existing = await this.userRepo.findByEmail(dto.email);
if (existing) {
throw new ConflictError('该邮箱已被注册');
}
// 2. 哈希密码(通过端口接口,不直接依赖 bcrypt)
const passwordHash = await this.passwordHasher.hash(dto.password);
// 3. 创建实体(业务规则在实体内部验证)
const user = User.create({
id: randomUUID(),
name: dto.name,
email: dto.email,
passwordHash,
});
// 4. 持久化
await this.userRepo.save(user);
// 5. 返回 DTO(不直接返回领域实体)
return {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString(),
};
}
}
// 自定义错误类型,便于上层统一处理
export class ConflictError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConflictError';
}
}
⚡ 关键结论: 用例不知道密码用的是 bcrypt 还是 argon2,不知道数据存的是 PostgreSQL 还是 MongoDB,甚至不知道请求来自 HTTP 还是 GraphQL。它只依赖抽象接口——这就是依赖倒置的威力。
2.3 Mapper:数据库模型与领域实体的桥梁
很多项目跳过 Mapper 直接把数据库行当作领域实体用,这会导致数据库 schema 泄漏到业务逻辑中:
// infrastructure/mappers/UserMapper.ts
import { User } from '../../domain/entities/User';
// 数据库行的原始类型(与数据库 schema 对齐)
interface UserRow {
id: string;
name: string;
email: string;
password_hash: string;
created_at: Date;
}
export class UserMapper {
// ✅ 数据库行 → 领域实体
static toDomain(row: UserRow): User {
return User.create({
id: row.id,
name: row.name,
email: row.email,
passwordHash: row.password_hash,
});
}
// ✅ 领域实体 → 数据库行
static toPersistence(user: User): UserRow {
return {
id: user.id,
name: user.name,
email: user.email,
password_hash: user.passwordHash,
created_at: user.createdAt,
};
}
}
⚠️ 警告: 跳过 Mapper 看似省事,但一旦数据库字段名变更(比如
password_hash→pwd_hash),你的业务代码就要跟着改。Mapper 层隔离了这种变化。
🚀 三、组装与测试:依赖注入的正确姿势
3.1 手写依赖注入容器
Clean Architecture 不需要重型 DI 框架(如 InversifyJS)。一个简单的工厂函数就够了:
// presentation/di/container.ts
import { PostgresUserRepository } from '../../infrastructure/repositories/PostgresUserRepository';
import { BcryptPasswordHasher } from '../../infrastructure/services/BcryptPasswordHasher';
import { CreateUserUseCase } from '../../application/use-cases/CreateUser';
import { GetUserOrdersUseCase } from '../../application/use-cases/GetUserOrders';
import { UserController } from '../http/controllers/UserController';
import { db } from '../../infrastructure/database/connection';
export function createContainer() {
// 基础设施层:具体实现
const userRepo = new PostgresUserRepository(db);
const passwordHasher = new BcryptPasswordHasher();
// 用例层:注入依赖
const createUser = new CreateUserUseCase(userRepo, passwordHasher);
const getUserOrders = new GetUserOrdersUseCase(userRepo);
// 控制器层:注入用例
const userController = new UserController(createUser, getUserOrders);
return { userController };
}
// main.ts
import express from 'express';
import { createContainer } from './presentation/di/container';
const app = express();
app.use(express.json());
const { userController } = createContainer();
// 路由只做 HTTP 适配,不含业务逻辑
app.post('/api/users', (req, res) => userController.create(req, res));
app.get('/api/users/:id/orders', (req, res) => userController.getOrders(req, res));
app.listen(3000, () => console.log('服务已启动: http://localhost:3000'));
3.2 可测试性:Clean Architecture 的最大回报
Clean Architecture 的真正价值在测试时才完全显现。由于依赖都是接口,你可以轻松替换为 Mock:
// tests/use-cases/CreateUser.test.ts
import { CreateUserUseCase, ConflictError } from '../../application/use-cases/CreateUser';
import { User } from '../../domain/entities/User';
// ✅ InMemory 实现:不依赖任何数据库,测试飞快
class InMemoryUserRepo implements IUserRepository {
private users = new Map<string, User>();
async findByEmail(email: string): Promise<User | null> {
for (const user of this.users.values()) {
if (user.email === email) return user;
}
return null;
}
async save(user: User): Promise<void> {
this.users.set(user.id, user);
}
// ...其他方法
}
class FakePasswordHasher implements IPasswordHasher {
async hash(password: string): Promise<string> {
return `hashed_${password}`;
}
async compare(password: string, hash: string): Promise<boolean> {
return hash === `hashed_${password}`;
}
}
describe('CreateUserUseCase', () => {
it('应成功创建用户', async () => {
const repo = new InMemoryUserRepo();
const hasher = new FakePasswordHasher();
const useCase = new CreateUserUseCase(repo, hasher);
const result = await useCase.execute({
name: '张三',
email: 'zhangsan@example.com',
password: 'securePass123',
});
expect(result.name).toBe('张三');
expect(result.email).toBe('zhangsan@example.com');
});
it('重复邮箱应抛出 ConflictError', async () => {
const repo = new InMemoryUserRepo();
const hasher = new FakePasswordHasher();
const useCase = new CreateUserUseCase(repo, hasher);
await useCase.execute({
name: '张三',
email: 'zhangsan@example.com',
password: 'securePass123',
});
await expect(
useCase.execute({
name: '李四',
email: 'zhangsan@example.com',
password: 'anotherPass',
})
).rejects.toThrow(ConflictError);
});
});
⚡ 关键结论: 这个测试不需要启动数据库、不需要 Docker、不需要 Mock 库——整个用例的测试在 5ms 内完成。这就是 Clean Architecture 为可测试性买单的回报。
3.3 常见误区与避坑指南
| ❌ 常见误区 | ✅ 正确做法 |
|---|---|
| 实体只有 getter/setter,业务逻辑全在 Service | 业务规则封装在实体和值对象中 |
| 控制器直接调用仓库 | 控制器 → 用例 → 仓库,严格分层 |
| 用例返回数据库 ORM 模型 | 用例返回 DTO,通过 Mapper 转换 |
| 一开始就引入 DI 框架 | 小项目用构造函数注入,大项目再引入框架 |
| 每个实体都建完整的四层结构 | 只有核心业务实体需要完整分层,CRUD 操作可以简化 |
| 过度抽象,为未来可能的需求预留接口 | 遵循 YAGNI 原则,只在需要时才抽象 |
⚠️ 警告: Clean Architecture 不是银弹。如果你的项目只是简单的 CRUD 应用(如内部管理后台),完整的四层架构可能是过度工程化。对于 CRUD 项目,Controller → Service → Repository 三层就够了。Clean Architecture 适用于业务规则复杂、需要长期维护的项目。
3.4 架构方案对比:何时选择 Clean Architecture
后端架构不止一种选择。下表对比了 TypeScript/Node.js 生态中四种常见的架构方案,帮你根据项目实际情况做出判断:
| 维度 | Controller-Service-Repository | Clean Architecture | Hexagonal Architecture | CQRS + Event Sourcing |
|---|---|---|---|---|
| 分层复杂度 | 低(3 层) | 中(4 层) | 中(端口+适配器) | 高(读写分离+事件) |
| 适合项目规模 | 小型 CRUD 应用 | 中大型业务系统 | 中大型系统 | 高并发、复杂领域 |
| 可测试性 | ⭐⭐⭐ 一般 | ⭐⭐⭐⭐⭐ 优秀 | ⭐⭐⭐⭐⭐ 优秀 | ⭐⭐⭐⭐ 良好 |
| 学习曲线 | 低 | 中 | 中 | 高 |
| 数据库切换难度 | 需改 Service 层 | 只改 Adapter 层 | 只改 Adapter 层 | 独立读写存储 |
| 业务规则位置 | 散落在 Service 中 | 集中在 Entity 中 | 集中在 Domain 中 | 集中在 Aggregate 中 |
| 推荐场景 | 管理后台、内部工具 | SaaS 产品、电商系统 | 微服务、多端适配 | 金融、电商核心交易 |
// ❌ 典型的 Controller-Service-Repository 贫血模型
// 业务逻辑散落在 Service 中,实体只是数据容器
class UserService {
async createUser(data: CreateUserInput) {
if (data.name.length < 2) throw new Error('名称太短'); // 业务规则在 Service 里
if (await this.repo.exists(data.email)) throw new Error('邮箱已存在');
const hashed = await bcrypt.hash(data.password, 10);
return this.repo.save({ ...data, password: hashed });
}
}
// ✅ Clean Architecture:业务规则封装在实体中
// 用例只做编排,不做业务判断
class CreateUserUseCase {
async execute(dto: CreateUserDTO) {
const existing = await this.repo.findByEmail(dto.email);
if (existing) throw new ConflictError('邮箱已存在');
const user = User.create(dto); // 业务验证在实体内部
await this.repo.save(user);
return UserResponseDTO.from(user);
}
}
💡 提示: 如果你的团队刚接触 Clean Architecture,建议从一个核心子域(如订单处理、支付流程)开始试点,而非整个项目一次性重构。先在一个模块中体验依赖倒置带来的可测试性收益,再逐步推广。
📋 总结与行动建议
Clean Architecture 的核心价值在于隔离变化:数据库可以换、框架可以换、外部 API 可以换,但业务规则始终稳定。对于 TypeScript/Node.js 后端项目,我的建议是:
- ✅ 新项目:从简单的三层架构开始,当业务复杂度上升时再拆分为完整的四层
- ✅ 核心领域实体:投入时间设计值对象和业务规则,这是最值得的投资
- ✅ 依赖注入:小项目用构造函数注入就够了,不要一上来就引入 InversifyJS 或 TSyringe
- ❅ 过度抽象:不要为了「架构整洁」而创建大量空接口和无用的抽象层
- ❅ 忽视 Mapper:数据库 schema 和领域模型的隔离,是可维护性的关键保障
⚡ 关键结论: Clean Architecture 的精髓不在文件夹结构,而在依赖方向。只要你确保内层不知道外层的存在,用什么目录结构都行。先从依赖倒置做起,比照搬模板有价值得多。
相关工具推荐:
- 🔧 Fastify — 高性能 Node.js 框架,天然支持依赖注入插件
- 🔧 Drizzle ORM — 类型安全的 TypeScript ORM,与 Clean Architecture 的 Mapper 层完美配合
- 🔧 Vitest — 极速测试框架,配合 InMemory 仓库实现秒级测试
- 🔧 ts-pattern — 模式匹配库,让领域规则代码更清晰
- 🔧 Zod — 运行时验证库,适合 DTO 的入参校验