NestJS 集成 TypeORM 的最优解

NestJS 对 TypeORM 的集成不是简单的"装个包、连个库",它在 DI 容器和模块系统之上做了一层完整的封装。搞清楚这层封装的设计意图、三种数据访问模式的取舍边界、以及关联关系的处理机制,才能写出真正可维护的代码。本文按照实际项目落地的顺序展开,每个决策点都会说清楚"为什么这样做"。


一、安装与基础连接(这里用mysql,其它大差不差)

bash 复制代码
pnpm add @nestjs/typeorm typeorm mysql2

装完之后在根模块注册 TypeOrmModule.forRoot(),这是整个应用唯一的数据库连接入口:

typescript 复制代码
// app.module.ts(伪代码)
@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: 'root',
      database: 'mydb',
      autoLoadEntities: true,
      synchronize: false,     // 生产环境绝对不能开 true
    }),
  ],
})
export class AppModule {}

forRoot() 底层把 TypeORM 的 DataSourceEntityManager 注册成了全局 provider,任何模块都可以直接注入这两个对象,不需要额外 import。

关于 synchronize:它会在每次应用启动时对比 Entity 定义和数据库结构并自动执行 DDL。开发阶段图方便可以开,一旦上了生产必须关掉,用 Migration 来管理 schema 变更,否则一次不小心的字段改名就是数据丢失。


二、异步配置:生产环境的正确姿势

静态的 forRoot() 写死了数据库连接信息,这在生产上行不通。正确的做法是结合 @nestjs/config 的命名空间(registerAs)来管理数据库配置,再通过 forRootAsync() 消费。关于 @nestjs/config 的完整用法,可参考 《手摸手,教你如何优雅的书写 NestJS 服务配置》,这里只说和 TypeORM 对接的部分。

2.1 用 registerAs 定义数据库配置

typescript 复制代码
// config/database.config.ts(伪代码)
import { registerAs } from '@nestjs/config';

export default registerAs('database', () => ({
  type: 'mysql' as const,
  host: process.env.DB_HOST || 'localhost',
  port: parseInt(process.env.DB_PORT, 10) || 3306,
  username: process.env.DB_USER,
  password: process.env.DB_PASS,
  database: process.env.DB_NAME,
}));

2.2 用 .asProvider() 对接 TypeOrmModule

.asProvider()importsinjectuseFactory 这套样板全部打包好,NestJS 会据此建立正确的初始化顺序:

typescript 复制代码
// app.module.ts(伪代码)
@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true, load: [databaseConfig] }),
    TypeOrmModule.forRootAsync({
      ...databaseConfig.asProvider(),
      useFactory: (config: ConfigType<typeof databaseConfig>) => ({
        ...config,
        autoLoadEntities: true,
        synchronize: false,
      }),
    }),
  ],
})
export class AppModule {}

2.3 为什么必须写 imports?

这背后有一个容易被忽视的初始化顺序问题:

bash 复制代码
ConfigModule 解析 .env / 工厂函数
      ↓
databaseConfig 生成配置对象
      ↓
TypeOrmModule 拿到配置,建立连接池
      ↓
各业务模块的 Service / Repository 可以正常注入

如果不声明 imports: [ConfigModule],框架不知道 TypeOrmModule 依赖 ConfigModule,两者可能并发初始化,导致 useFactory 执行时配置还没就绪,连接参数全是 undefined.asProvider() 之所以可靠,正是因为它在展开后隐式声明了这层依赖。

手写 useFactory 时一定要带上 imports

typescript 复制代码
// 手写等价形式(伪代码)
TypeOrmModule.forRootAsync({
  imports: [ConfigModule],      // ← 不能省,否则初始化顺序无法保证
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    type: 'mysql',
    host: config.get<string>('database.host'),
    port: config.get<number>('database.port'),
    autoLoadEntities: true,
    synchronize: false,
  }),
})

三、Entity 定义与关联关系处理

3.1 Entity 基本结构

TypeORM 的 Entity 就是一个被 @Entity() 标记的 TypeScript 类,每个字段对应一个数据库列:

typescript 复制代码
// user.entity.ts(伪代码)
@Entity('users')  // 显式指定表名,避免依赖类名推断
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 100 })
  name: string;

  @Column({ unique: true })
  email: string;

  @Column({ default: true })
  isActive: boolean;

  @OneToMany(() => Order, (order) => order.user, { cascade: true })
  orders: Order[];
}

Entity 文件应该和它所属的业务模块放在一起,UserModule 就放 user.entity.tsOrderModuleorder.entity.ts,模块边界才能保持清晰(我习惯这样操作,不喜勿喷)。

3.2 TypeORM 关联表的处理机制(与 Sequelize 的本质区别)

这是很多已经习惯了 Sequelize 的大佬们容易踩坑的地方,必须单独说清楚。

Sequelize 的方式 :声明关联后,大量操作是自动处理的------belongsTo/hasMany 会自动维护外键写入,belongsToMany 的中间表记录由框架自动插删,行为"隐式"。

TypeORM 的方式 :TypeORM 默认不会自动维护关联数据 ,所有写操作都必须显式控制,只有配置了 cascade 选项才会有级联行为。这是一个设计上的选择------更显式、更可预测,但也要求开发者主动配置。

cascade 配置说明:

typescript 复制代码
// user.entity.ts(伪代码)
@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  // cascade: true 等价于 ['insert', 'update', 'remove', 'soft-remove', 'recover']
  // 保存 User 时会同步保存/更新 orders
  @OneToMany(() => Order, (order) => order.user, { cascade: true })
  orders: Order[];
}

// order.entity.ts(伪代码)
@Entity('orders')
export class Order {
  @PrimaryGeneratedColumn()
  id: number;

  // 多对一端不需要 cascade,外键由这一侧维护
  @ManyToOne(() => User, (user) => user.orders)
  user: User;

  @Column()
  userId: number;
}

ManyToMany 中间表:

TypeORM 中 @ManyToMany 的中间表由框架自动创建和维护,但记录的插入/删除仍需通过 cascade 或手动操作来触发:

typescript 复制代码
// post.entity.ts(伪代码)
@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  // TypeORM 自动创建 post_tags_tag 中间表
  // cascade: true 意味着保存 Post 时会同步处理中间表记录
  @ManyToMany(() => Tag, { cascade: true })
  @JoinTable()  // 拥有方必须加 @JoinTable
  tags: Tag[];
}

实际写操作的三种姿势对比:

typescript 复制代码
// 姿势一:直接 save,依赖 cascade(有 cascade: true 时可用)
const user = await userRepo.findOne({ where: { id: 1 }, relations: ['orders'] });
user.orders.push(newOrder);
await userRepo.save(user); // 级联保存 orders

// 姿势二:分别 save,手动维护外键(不依赖 cascade,控制更精确)
const order = orderRepo.create({ ...dto, userId: user.id });
await orderRepo.save(order);

// 姿势三:事务保证原子性(多表写操作的推荐做法)
await dataSource.transaction(async (manager) => {
  const order = manager.create(Order, { ...dto, userId });
  await manager.save(order);
  await manager.decrement(Inventory, { productId: dto.productId }, 'stock', dto.qty);
});

实践建议cascade: true 虽然方便,但范围太广,生产代码里推荐按需声明 cascade: ['insert']cascade: ['insert', 'update'],避免无意中触发级联删除。多表写操作涉及原子性时,一律用事务(见第五节),不要依赖 cascade 来"顺便"写关联表,那会让数据流向变得难以追踪。

3.3 autoLoadEntities 的工作机制与边界

开启 autoLoadEntities: true 后,所有通过 forFeature() 注册过的 Entity 会自动收集到 DataSource 里,不再需要手动维护 entities 数组:

typescript 复制代码
TypeOrmModule.forRootAsync({
  useFactory: (config) => ({
    ...config,
    autoLoadEntities: true, // forFeature() 注册的 Entity 自动收集
  }),
})

边界只通过关联关系引用、但从未在任何模块 forFeature() 里出现过的 Entity,不会被自动加载 。比如 User 关联了 UserProfile,但没有任何模块 forFeature([UserProfile]),TypeORM 就不知道这张表存在,查询时会报找不到 metadata 的错误。

规避方案(推荐组合:方案一 + 方案三):

方案一:glob 路径兜底 ,和 autoLoadEntities 并不冲突,两个来源会合并:

typescript 复制代码
TypeOrmModule.forRootAsync({
  useFactory: (config) => ({
    ...config,
    autoLoadEntities: true,
    entities: [join(__dirname, '..', '**', '*.entity.{ts,js}')], // 兜底扫描
  }),
})

__dirnamets-node 和编译后的 dist/ 目录层级不同,需要按环境调整路径,或统一用字符串 glob src/**/*.entity.ts 让 TypeORM 自己解析。

方案二:SharedModule 收容"无主" Entity,适合被多个模块引用但不归属任何单一业务模块的 Entity:

typescript 复制代码
// shared/shared.module.ts(伪代码)
@Module({
  imports: [TypeOrmModule.forFeature([UserProfile, Tag, Attachment])],
  exports: [TypeOrmModule],
})
export class SharedModule {}

方案三:团队 Convention ,最根本的手段------建立规范:新增 Entity 的 PR 必须同时有对应 Module 的 forFeature() 变更,缺了就打回。


四、三种数据访问模式

为什么会有三种模式?

TypeORM 本身提供了三个层次的抽象:

  • Repository<T>:单 Entity 维度的操作封装,每个 Entity 有一个独立实例
  • EntityManager:全局的 Entity 管理器,可操作任意 Entity,是 Repository 的底层
  • DataSource:数据库连接本身,是 EntityManager 和 Repository 的来源,也是事务的入口

NestJS 的 @nestjs/typeorm 把这三层都接入了 DI 系统,自然就形成了三种使用姿势。它们不是非此即彼的关系,而是面向不同颗粒度场景的工具------简单 CRUD 用 Repository 够了,复杂领域查询需要把逻辑封装进自定义类,跨 Entity 写操作则只有直接操作 DataSource 才能保证原子性。


模式一:Repository 模式(NestJS 官方推荐,标准 CRUD)

通过 forFeature() 注册 Entity,再用 @InjectRepository() 把 TypeORM 内置的 Repository<T> 注入 Service,这是 NestJS 文档里的标准写法。

typescript 复制代码
// users.module.ts(伪代码)
@Module({
  imports: [TypeOrmModule.forFeature([User])],
  providers: [UsersService],
  controllers: [UsersController],
  exports: [TypeOrmModule], // 需要跨模块复用 UserRepository 时才加这行
})
export class UsersModule {}
typescript 复制代码
// users.service.ts(伪代码)
@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly userRepo: Repository<User>,
  ) {}

  findAll(): Promise<User[]> {
    return this.userRepo.find();
  }

  findActiveByEmail(email: string): Promise<User | null> {
    return this.userRepo.findOne({ where: { email, isActive: true } });
  }

  async create(dto: CreateUserDto): Promise<User> {
    const user = this.userRepo.create(dto);
    return this.userRepo.save(user);
  }
}

适用:单表的标准 CRUD,查询条件不复杂,业务刚起步时的默认选择。

局限 :随着业务增长,createQueryBuilder 会开始堆积在 Service 里;跨模块复用需要 exports: [TypeOrmModule],暴露的粒度比较粗。


模式二:Custom Repository 模式(复杂查询与领域封装)

当某个 Entity 的查询逻辑复杂到一定程度,或者同一段查询在多个 Service 里重复出现,把查询逻辑堆在 Service 里就开始显得臃肿。这时候用自定义 Repository 来封装领域查询------本质上是把"怎么查"这个关注点从 Service 里分离出来。

TypeORM 0.3.x 以后,推荐写法是创建一个普通的 @Injectable() 类,在构造函数里注入 DataSource,通过 dataSource.getRepository(Entity) 获取内置 Repository,再在这个类上封装业务查询方法:

typescript 复制代码
// user.repository.ts(伪代码)
@Injectable()
export class UserRepository {
  private readonly repo: Repository<User>;

  constructor(private readonly dataSource: DataSource) {
    this.repo = dataSource.getRepository(User);
  }

  findActiveUsers(): Promise<User[]> {
    return this.repo
      .createQueryBuilder('user')
      .where('user.isActive = :active', { active: true })
      .orderBy('user.createdAt', 'DESC')
      .getMany();
  }

  findWithOrderCount(): Promise<User[]> {
    return this.repo
      .createQueryBuilder('user')
      .leftJoinAndSelect('user.orders', 'order')
      .loadRelationCountAndMap('user.orderCount', 'user.orders')
      .getMany();
  }

  async findByEmailOrThrow(email: string): Promise<User> {
    const user = await this.repo.findOneBy({ email });
    if (!user) throw new NotFoundException(`User not found: ${email}`);
    return user;
  }
}
typescript 复制代码
// users.module.ts(伪代码)
@Module({
  // DataSource 是全局 provider,不需要 forFeature()
  providers: [UsersService, UserRepository],
  controllers: [UsersController],
  exports: [UserRepository], // 只导出这个类本身,粒度更精确
})
export class UsersModule {}
typescript 复制代码
// users.service.ts(伪代码)
@Injectable()
export class UsersService {
  constructor(private readonly userRepo: UserRepository) {}

  getActiveUsers() {
    return this.userRepo.findActiveUsers();
  }
}

适用:查询逻辑复杂、需要多处复用、或者强调领域隔离的场景。

优势 :不需要 forFeature();跨模块只导出这个类本身;单元测试直接 mock 类,不需要 getRepositoryToken


模式三:DataSource / EntityManager 模式(事务与跨 Entity 操作)

直接注入 DataSource,适合两类场景:跨多个 Entity 的写操作(需要事务保证原子性),以及 ORM 表达能力不足时的原生 SQL。

typescript 复制代码
// order.service.ts(伪代码)
@Injectable()
export class OrderService {
  constructor(private readonly dataSource: DataSource) {}

  // 场景一:回调风格事务(逻辑线性,无需中途分支)
  async placeOrder(dto: PlaceOrderDto): Promise<Order> {
    return this.dataSource.transaction(async (manager) => {
      const order = manager.create(Order, dto);
      await manager.save(order);
      await manager.decrement(Inventory, { productId: dto.productId }, 'stock', dto.qty);
      const ledger = manager.create(Ledger, { orderId: order.id, amount: dto.amount });
      await manager.save(ledger);
      return order;
    });
  }

  // 场景二:QueryRunner 手动控制(需要中途做判断,或指定隔离级别)
  async createManyWithCheck(users: CreateUserDto[]): Promise<void> {
    const runner = this.dataSource.createQueryRunner();
    await runner.connect();
    await runner.startTransaction('READ COMMITTED');

    try {
      for (const dto of users) {
        const exists = await runner.manager.existsBy(User, { email: dto.email });
        if (exists) throw new ConflictException(`${dto.email} 已存在`);
        await runner.manager.save(User, runner.manager.create(User, dto));
      }
      await runner.commitTransaction();
    } catch (err) {
      await runner.rollbackTransaction();
      throw err;
    } finally {
      await runner.release(); // 必须释放,否则连接池会被耗尽
    }
  }

  // 场景三:原生 SQL(报表/复杂聚合,ORM 表达困难时)
  async getSalesSummary(startDate: Date, endDate: Date) {
    return this.dataSource.query(
      `SELECT DATE(created_at) AS date, SUM(amount) AS total
       FROM orders
       WHERE created_at BETWEEN ? AND ?
       GROUP BY DATE(created_at)`,
      [startDate, endDate],
    );
  }
}

适用:跨 Entity 写操作、需要原子性保证、原生 SQL 报表查询。

注意 :单元测试需要 mock 整个 DataSource,成本最高;QueryRunner 用完必须 release(),不然会持续占用连接池连接。


三种模式的对比与选型

维度 Repository 模式 Custom Repository 模式 DataSource/EntityManager 模式
适用场景 标准 CRUD,查询简单 复杂查询,多处复用查询逻辑 跨 Entity 事务,原生 SQL
注入方式 @InjectRepository(Entity) 普通 @Injectable() 直接注入 DataSource
需要 forFeature
跨模块复用 exports: [TypeOrmModule](粒度粗) exports: [XxxRepository](精确) DataSource 全局直接注入
查询能力 Repository<T> 全部 API 任意复杂度,可自由封装 可写原生 SQL,QueryBuilder 无限制
事务支持 单 Entity save,不跨表 借助 DataSource.transaction 原生支持,推荐用这个
单元测试难度 低(getRepositoryToken + mock) 最低(直接 mock 自定义类) 较高(需要 mock DataSource
代码组织 查询逻辑在 Service 里 查询逻辑下沉到 Repository 事务/底层逻辑在 Service 里

实战选型原则:三种模式混用,不是二选一。

  • 新模块默认用 Repository 模式,简单 CRUD 够用,上手快。
  • 当 Service 里的 createQueryBuilder 超过三处,或同一查询被多个 Service 引用,把查询逻辑抽到 Custom Repository,Service 只调方法名。
  • 只要涉及多张表的写操作 ,不管上面用哪种模式,都要切到 DataSource + 事务来保证原子性。不要用两个独立的 repo.save() 来模拟事务------那不是事务,中间任何一步失败,数据就不一致了。

分层原则:Controller 不碰数据库 → Service 负责业务逻辑 → Repository 只管数据访问。Repository 层越纯粹,Service 越好测。


五、事务处理:两种写法的取舍

上一节展示了两种事务写法,这里总结一下选择依据:

dataSource.transaction(callback)语法糖,适合逻辑线性、不需要中途分支的场景:

typescript 复制代码
// 抛错自动回滚,无需手写 try/catch/finally(伪代码)
await this.dataSource.transaction(async (manager) => {
  await manager.save(Order, orderData);
  await manager.save(Payment, paymentData);
});

QueryRunner完整控制 ,适合需要在事务中途做判断、指定隔离级别、或分阶段提交的场景。两者底层机制相同,dataSource.transaction() 内部就是对 QueryRunner 的封装。


六、多数据库连接

当业务需要同时连多个库时,给每个连接取唯一名字:

typescript 复制代码
// app.module.ts(伪代码)
@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      name: 'userDb',
      ...userDbConfig.asProvider(),
      useFactory: (config) => ({ ...config, autoLoadEntities: true }),
    }),
    TypeOrmModule.forRootAsync({
      name: 'orderDb',
      ...orderDbConfig.asProvider(),
      useFactory: (config) => ({ ...config, autoLoadEntities: true }),
    }),
  ],
})
export class AppModule {}

子模块和注入时都要带上连接名:

typescript 复制代码
// 注册(伪代码)
TypeOrmModule.forFeature([Order], 'orderDb')

// 注入(伪代码)
@InjectRepository(Order, 'orderDb')
private readonly orderRepo: Repository<Order>

@InjectDataSource('orderDb')
private readonly dataSource: DataSource

不传名字默认用 default 连接。多连接场景下一定要养成显式传名字的习惯,否则某个模块无意中连错库是很难排查的 bug。


七、单元测试:Mock 策略

测试不应该依赖真实数据库,针对不同的访问模式,mock 策略不同:

Repository 模式 :用 getRepositoryToken() 替换注入令牌

typescript 复制代码
// users.service.spec.ts(伪代码)
const mockUserRepo = {
  find: jest.fn(),
  findOneBy: jest.fn(),
  save: jest.fn(),
  delete: jest.fn(),
};

const module = await Test.createTestingModule({
  providers: [
    UsersService,
    { provide: getRepositoryToken(User), useValue: mockUserRepo },
  ],
}).compile();

Custom Repository 模式:直接 mock 自定义类,最简洁------mock 的接口就是你自己定义的方法名,不需要了解 TypeORM 内部结构:

typescript 复制代码
// users.service.spec.ts(伪代码)
const mockUserRepository = {
  findActiveUsers: jest.fn(),
  findByEmailOrThrow: jest.fn(),
};

const module = await Test.createTestingModule({
  providers: [
    UsersService,
    { provide: UserRepository, useValue: mockUserRepository },
  ],
}).compile();

八、Migration:schema 变更的正确方式

synchronize: true 只适合本地开发,生产环境的 schema 变更必须走 Migration。Migration 文件独立于 NestJS 应用的 DI 体系之外,由 TypeORM CLI 管理:

bash 复制代码
# 生成 migration(对比 Entity 定义与当前数据库 schema 的差异,自动生成 DDL)
npx typeorm migration:generate src/migrations/AddUserPhone -d src/data-source.ts

# 执行所有未运行的 migration
npx typeorm migration:run -d src/data-source.ts

# 回滚最近一次 migration
npx typeorm migration:revert -d src/data-source.ts

需要单独维护一个 data-source.ts 给 CLI 使用:

typescript 复制代码
// src/data-source.ts(伪代码)
import 'dotenv/config'; // CLI 执行时没有 NestJS 的 ConfigModule,需要手动加载 .env
import { DataSource } from 'typeorm';

export default new DataSource({
  type: 'mysql',
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT, 10),
  username: process.env.DB_USER,
  password: process.env.DB_PASS,
  database: process.env.DB_NAME,
  entities: ['src/**/*.entity.ts'],
  migrations: ['src/migrations/*.ts'],
});

Migration 文件里不能用 @InjectRepository() 这类 Nest 特性,只能直接操作 QueryRunner。这是设计上的隔离:migration 是纯粹的 schema 操作脚本,不带任何业务逻辑。


小结

整体结构一览

层次 职责 关键 API
全局连接 建立 DataSource,全局可注入 forRootAsync + registerAs + .asProvider()
模块作用域 注册 Repository,限定使用范围 forFeature
标准 CRUD 简单增删改查 @InjectRepository + Repository<T>
复杂查询 封装领域查询逻辑 Custom Repository(@Injectable 类)
关联写操作 配置 cascade 或手动维护外键 cascade 选项 + 显式 save
事务操作 多表原子写操作 DataSource.transaction / QueryRunner
Schema 变更 生产环境结构演进 TypeORM CLI + Migration + data-source.ts
单元测试 隔离数据库依赖 getRepositoryToken 或直接 mock 自定义类

高频陷阱清单

  1. synchronize: true 上了生产 → 字段改名 = 数据丢失,必须用 Migration
  2. forRootAsyncuseFactory 里漏写 imports → 配置未就绪就建连接,参数全是 undefined
  3. 跨模块复用 Repository 时忘了 exports → 运行时报找不到 provider
  4. 关联 Entity 只被引用从未 forFeature() → 查询时报找不到 metadata,用 glob 兜底或 SharedModule 规避
  5. 用两个独立 repo.save() 模拟事务 → 不是事务,中间失败会留下脏数据,改用 DataSource.transaction
  6. QueryRunner 忘记 release() → 连接池连接被持续占用,最终耗尽
  7. cascade 范围设置过宽(cascade: true → 无意触发级联删除,改为按需声明如 cascade: ['insert', 'update']
相关推荐
UIUV4 小时前
node:child_process spawn 模块学习笔记
javascript·后端·node.js
前端付豪1 天前
Nest 项目小实践之注册登陆
前端·node.js·nestjs
天蓝色的鱼鱼1 天前
Node.js 中间层退潮:从“前端救星”到“成本噩梦”
前端·架构·node.js
codingWhat1 天前
uniapp 多地区、多平台、多环境打包方案
前端·架构·node.js
小p1 天前
nodejs学习: 服务器资源CPU、内存、硬盘
node.js
Mr_li1 天前
手摸手,教你如何优雅的书写 NestJS 服务配置
node.js·nestjs
QQ5110082852 天前
python+springboot+django/flask的校园资料分享系统
spring boot·python·django·flask·node.js·php
q***09802 天前
最新最详细的配置Node.js环境教程
node.js
WeiXin_DZbishe2 天前
基于django在线音乐数据采集的设计与实现-计算机毕设 附源码 22647
javascript·spring boot·mysql·django·node.js·php·html5