Why TypeORM Needs Pattern-Consistency Rules
TypeORM supports two fundamentally different patterns: Active Record (entity.save(), Entity.find()) and Data Mapper (repository.save(entity), repository.find()). AI mixes them randomly — calling User.findOne() in one service and getRepository(User).findOne() in another. The codebase becomes an inconsistent mess where developers don't know which pattern to use.
Beyond pattern mixing, AI generates TypeORM with: synchronize: true in production (drops and recreates tables on every restart), eager relations on everything (loads the entire database per query), no QueryBuilder for complex queries (falls back to raw SQL), and decorator-heavy entities without proper migration files.
These rules target TypeORM 0.3+ with TypeScript. They enforce one pattern (Data Mapper is recommended), proper migration workflow, and efficient relation loading.
Rule 1: Data Mapper Pattern — One Pattern Consistently
The rule: 'Use the Data Mapper pattern with repositories. Never use Active Record (entity.save(), Entity.find()). Inject repositories: constructor(@InjectRepository(User) private usersRepo: Repository<User>). Use repository methods: this.usersRepo.findOneBy({ id }), this.usersRepo.save(user), this.usersRepo.remove(user). Entities are plain classes with decorators — they don't have save/remove methods.'
For custom repositories: 'Extend Repository<Entity> for custom query methods: @Injectable() class UserRepository extends Repository<User> { findByEmail(email: string) { return this.findOneBy({ email }); } findActive() { return this.find({ where: { isActive: true } }); } }. Register with TypeOrmModule.forFeature([User]) — NestJS injects the custom repository automatically.'
Why Data Mapper over Active Record: 'Data Mapper separates data access from the entity class — entities are testable without a database connection. Active Record couples the entity to TypeORM — you can't construct a User without ORM context. Data Mapper is standard in NestJS and recommended for any DI-based architecture.'
- Data Mapper: repository.find(), repository.save() — entities are plain objects
- Never Active Record: no entity.save(), no Entity.find() — pick one pattern
- Custom repositories for reusable queries — extend Repository<Entity>
- Inject with @InjectRepository(Entity) — TypeOrmModule.forFeature for registration
- Entities are testable without DB — Data Mapper enables proper unit testing
Active Record couples entities to the ORM — you can't construct a User without database context. Data Mapper keeps entities as plain objects. repository.save(user) is testable, entity.save() is not.
Rule 2: QueryBuilder for Complex Queries
The rule: 'Use QueryBuilder for queries that go beyond simple find options: joins, subqueries, aggregations, conditional where clauses, and pagination. Build with: this.usersRepo.createQueryBuilder("user").leftJoinAndSelect("user.posts", "post").where("user.isActive = :active", { active: true }).orderBy("user.createdAt", "DESC").skip(offset).take(limit).getManyAndCount().'
For parameters: 'Always use parameterized queries in QueryBuilder: .where("user.email = :email", { email }) — never string interpolation. QueryBuilder parameterizes with :name syntax. Use setParameter for complex scenarios. This is your SQL injection prevention in TypeORM.'
For when to use which: 'Simple CRUD: repository methods (find, findOneBy, save, remove). Filtered lists with relations: find with FindOptionsWhere and FindOptionsRelations. Complex queries (joins, subqueries, aggregations, dynamic conditions): QueryBuilder. Raw SQL: only when QueryBuilder can't express it (very rare).'
Rule 3: Relation Loading Strategies
The rule: 'Never set eager: true on @ManyToOne, @OneToMany, or @ManyToMany — it loads related data on every query, even when you don't need it. Use explicit relation loading: find({ relations: { posts: true } }) for simple cases, QueryBuilder leftJoinAndSelect for complex cases. Load only what you need for each query.'
For N+1 prevention: 'TypeORM's find({ relations }) generates a single JOIN query — no N+1. But accessing a lazy relation in a loop DOES cause N+1: for (const user of users) { await user.posts } — each access triggers a query. Always use find({ relations }) or QueryBuilder joins — never loop over lazy relations.'
For lazy relations: 'Lazy relations (posts: Promise<Post[]>) load on access — but they cause N+1 in loops. Use them only for single-entity detail views where you know exactly one relation will be accessed. For lists and batch operations, always eager-join in the query.'
- Never eager: true on entity relations — explicit loading per query
- find({ relations: { posts: true } }) for simple eager loading
- QueryBuilder leftJoinAndSelect for complex eager loading
- Never loop over lazy relations — causes N+1 queries
- Lazy (Promise<T[]>) only for single-entity detail views
eager: true on a relation loads it on EVERY query — even when you don't need it. For a User with 10 eager relations, every findOneBy({ id }) loads the entire object graph. Use find({ relations }) explicitly per query.
Rule 4: Migration Workflow
The rule: 'Never use synchronize: true in production — it drops columns, tables, and data without warning. Use TypeORM migrations: npx typeorm migration:generate -n CreateUsers generates a migration from entity changes. npx typeorm migration:run applies migrations. Migration files in src/migrations/ — committed to git. Review generated SQL before applying.'
For migration commands: 'migration:generate compares entities to the database and generates SQL. migration:create creates an empty migration for manual SQL. migration:run applies pending migrations. migration:revert rolls back the last migration. Use generate for schema changes, create for data migrations.'
For production: 'Run migrations before the app starts in production — not as a side effect of app boot. Configure in the deployment pipeline: apply migrations → verify → start app. If migrations fail, the deployment stops. Never auto-run migrations on app startup — a failed migration with auto-restart creates an infinite crash loop.'
- Never synchronize: true in production — drops data silently
- migration:generate for schema changes — migration:create for data changes
- migration:run in deployment pipeline — before app start, not on boot
- Review generated SQL — it's in the migration file, readable
- migration:revert for rollback — test migrations on staging first
synchronize: true compares entities to DB and applies changes — including dropping columns and tables. It has no rollback, no review step, no audit trail. In production, this silently destroys data. Migrations only.
Rule 5: Entity Design Patterns
The rule: 'Use TypeORM decorators for entity definition: @Entity(), @PrimaryGeneratedColumn("uuid"), @Column(), @CreateDateColumn(), @UpdateDateColumn(). Use @Index for frequently queried columns. Use @Unique for unique constraints. Use enum columns with @Column({ type: "enum", enum: UserRole }). Keep entities focused — one table per entity, no business logic in entity classes.'
For inheritance: 'Use @TableInheritance for STI (Single Table Inheritance) when subtypes share most fields. Use separate tables (no inheritance) when subtypes differ significantly. Avoid deep inheritance hierarchies — they complicate queries and migrations. Prefer composition (embedding a shared columns class) over inheritance.'
For subscribers: 'Use @EventSubscriber for entity lifecycle events: beforeInsert, afterUpdate, afterRemove. Use sparingly — only for data integrity (timestamps, slugs, audit fields). Never use subscribers for business logic (sending emails, creating related records). Business logic belongs in the service layer.'
Complete TypeORM Rules Template
Consolidated rules for TypeORM projects.
- Data Mapper pattern: repository methods — never Active Record entity.save()
- Custom repositories for reusable queries — extend Repository<Entity>
- QueryBuilder for complex queries — parameterized :name syntax — never string interpolation
- Explicit relation loading: find({ relations }) — never eager: true on entities
- Never lazy relation loops — always join in the query for lists/batches
- Never synchronize: true in production — migration:generate for schema, migration:run for apply
- Migrations in deployment pipeline — before app start — review generated SQL
- @Entity decorators — @Index for queries — @EventSubscriber only for data integrity