Clean Architecture 实战:用 TypeScript 构建可维护的后端应用

深入解析 Clean Architecture 在 TypeScript 后端项目中的落地实践,涵盖分层设计、依赖倒置、用例编排、数据映射,附完整可运行代码与常见架构方案对比,帮你告别意大利面条式代码。

前端开发 2026-05-31 18 分钟

你是否接手过这样的项目:路由处理器里直接写 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                    # 组装入口

💡 提示: 这个结构不是唯一的「正确答案」。小项目可以把 infrastructurepresentation 合并。关键不是文件夹数量,而是import 依赖方向

1.3 依赖倒置的 TypeScript 实现

Clean Architecture 的核心武器是依赖倒置原则(Dependency Inversion Principle)——高层模块定义接口,低层模块实现接口。TypeScript 的 interfaceabstract 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_hashpwd_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 的入参校验

📚 相关文章