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 的 DataSource 和 EntityManager 注册成了全局 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() 把 imports、inject、useFactory 这套样板全部打包好,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.ts,OrderModule 放 order.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}')], // 兜底扫描
}),
})
__dirname在ts-node和编译后的dist/目录层级不同,需要按环境调整路径,或统一用字符串 globsrc/**/*.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 自定义类 |
高频陷阱清单
synchronize: true上了生产 → 字段改名 = 数据丢失,必须用 MigrationforRootAsync的useFactory里漏写imports→ 配置未就绪就建连接,参数全是undefined- 跨模块复用 Repository 时忘了
exports→ 运行时报找不到 provider - 关联 Entity 只被引用从未
forFeature()→ 查询时报找不到 metadata,用 glob 兜底或 SharedModule 规避 - 用两个独立
repo.save()模拟事务 → 不是事务,中间失败会留下脏数据,改用DataSource.transaction QueryRunner忘记release()→ 连接池连接被持续占用,最终耗尽- cascade 范围设置过宽(
cascade: true) → 无意触发级联删除,改为按需声明如cascade: ['insert', 'update']