This is Part 1 of a 5-part series on building efficient systems with TypeORM.
The Big Idea
The ORM is allowed to make common database work pleasant.
It should not hide:
- ownership boundaries
- migration risk
- authorization scope
- transaction scope
- query shape
- locking and race behavior
- index needs
- destructive data changes
- operational backfills
- observability
If an ORM pattern hides one of those, production will eventually make the hidden thing visible at the worst possible time.
The best production ORM systems do something that sounds boring but feels magical in practice: they make the dangerous path harder than the safe path.
Some rules I like:
synchronize: falseeverywhere that matters.- Migrations are release artifacts: Treat migration files with the same discipline as your source code. Once a migration is deployed, it is permanent. If you need to make a change, "fix forward" with a new migration rather than editing an old one.
- New
up()migrations are additive and forward-safe: Design changes to be "backward compatible" with the code currently running in production. Prefer adding new columns over renaming or deleting them, ensuring the app stays online during the deployment transition. - Enforcement: Use automated scripts to block dangerous commands like
DROP TABLEorTRUNCATEin production. This acts as a "safety lock" to prevent accidental destructive code. - Explicit Join Columns: Always name your relationship columns explicitly (e.g.,
author_idinstead of the defaultauthorId). This keeps your database schema readable and predictable. - Soft Deletes by Default: Prefer "Soft Deletes" (using a
deleted_attimestamp) over hard-deleting records. This ensures data is preserved for audits or recovery, and prevents accidental permanent loss of related information. - Idempotent Backfills: Ensure scripts that update existing data can be rerun safely. If a script fails halfway through, you should be able to restart it without creating duplicate or corrupted data.
- Consistency for Critical Writes: For high-stakes data, use a
QueryRunnerto perform both reads and writes on the primary database. This avoids "read-after-write" inconsistencies caused by replication lag, ensuring the system state is always accurate and valid. - Unified Contract: Treat your entities, migrations, API definitions (DTOs), and tests as parts of the same promise. If you change a field in one place, update it everywhere to keep the entire system consistent.
That is the heart of production ORM, to facilitate safe and reliable data related works.
What TypeORM Is Good At
TypeORM is strong when you use it for:
- entity mapping in decorator-heavy NestJS apps
- repository-based CRUD with clear service ownership
- query builders for intentional SQL shape
- migration classes with TypeScript
- PostgreSQL features through raw SQL escape hatches
- transactions through
DataSource,EntityManager, andQueryRunner - schema metadata that stays close to the domain model
TypeORM is weaker when teams expect it to:
- design migrations for them
- prevent N+1 queries automatically
- infer every relation safely
- make production schema changes risk-free
- hide database-specific performance work
- replace human review
That is fine. Tools do not need to do everything. They need to have clear boundaries.
Start With the Production Contract
Your TypeORM configuration is not just setup code. It is the first expression of your data-layer policy.
In production, optimize for safety and reliability over schema evolution speed:
// src/database/typeorm.config.ts import { TypeOrmModuleOptions } from '@nestjs/typeorm'; export function getTypeOrmModuleOptions( databaseUrls: string[], nodeEnv: string, ): TypeOrmModuleOptions { const [mainDb, replicationDBs] = parse(databaseUrls); const isProduction = nodeEnv === 'production'; const requiresSsl = isProduction || databaseUrl.includes('sslmode=require'); return { type: 'postgres', // In production, use replication to split reads and writes replication: isProduction ? { master: mainDb, slaves: ...replicationDBs, } : undefined, // If not using replication, use standard connection options ...(!isProduction && mainDb), ssl: requiresSsl ? { rejectUnauthorized: false } : undefined, // Connection pooling is essential for managing database resources extra: { max: isProduction ? 30 : 10, // Max number of concurrent connections min: isProduction ? 5 : 2, // Minimum number of idle connections idleTimeoutMillis: 30000, // Close idle connections after 30 seconds }, autoLoadEntities: true, synchronize: false, migrations: [join(__dirname, 'migrations', '!(*.spec).{ts,js}')], migrationsRun: !isProduction, retryAttempts: 5, retryDelay: 1000, maxQueryExecutionTime: 1000, // Logs any query taking > 1 second logging: isProduction ? ['error', 'warn'] : ['query', 'error', 'warn'], }; }
The important production posture:
replication: split traffic between a primary (master) for writes and one or more replicas (slaves) for reads to scale database load.extra(Pooling): manage database connections efficiently by defining pool sizes and timeouts, preventing connection exhaustion.synchronize: false: the application does not mutate production schema from entity metadata.migrationsRun: !isProduction: local environments can move quickly; production runs migrations explicitly in the deploy pipeline.maxQueryExecutionTime: slow queries become visible early.
Treat Entities as Storage Contracts
An entity is not just a class. It is a public interface to your database table. Controllers, services, jobs, queues, migrations, seeds, tests, reports, and analytics will eventually depend on its field names and relations.
For example, imagine three central models:
- a
Userwho signs in - a
Profilethat stores user-facing identity details - a
Businessowned by a user
A production-ready entity should be explicit about table names, column names, column types, indexes, uniqueness, nullability, defaults, and relation behavior.
import { Check, Column, CreateDateColumn, DeleteDateColumn, Entity, Index, JoinColumn, OneToOne, PrimaryGeneratedColumn, UpdateDateColumn, VersionColumn, } from 'typeorm'; import { UserEntity } from './user.entity'; @Entity({ name: 'profiles' }) @Index('profiles_user_id_unique_idx', ['userId'], { unique: true }) @Index('profiles_display_name_idx', ['displayName']) @Check(`"displayName" IS NULL OR length("displayName") >= 2`) export class ProfileEntity { @PrimaryGeneratedColumn('uuid') id!: string; @Column({ type: 'uuid' }) userId!: string; @OneToOne(() => UserEntity, (user) => user.profile, { onDelete: 'NO ACTION', }) @JoinColumn({ name: 'userId' }) user!: UserEntity; @Column({ type: 'varchar', length: 80, nullable: true, comment: 'Public display name shown in user-facing surfaces.', }) displayName!: string | null; @Column({ type: 'varchar', length: 160, nullable: true, select: false, comment: 'Private user biography draft, hidden from default selects.', }) privateBioDraft!: string | null; @Column({ type: 'text', nullable: true }) avatarUrl!: string | null; @Column({ type: 'jsonb', default: () => "'{}'::jsonb" }) preferences!: { locale?: string; timezone?: string; emailNotifications?: boolean; }; @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; @DeleteDateColumn({ type: 'timestamptz', nullable: true }) deletedAt!: Date | null; }
There are a few quiet choices here that matter:
- The database table is named explicitly.
- The foreign key scalar
userIdis visible. - The relation is pinned to
userIdwith@JoinColumn. - Text length is part of the database contract, not only DTO validation.
- Sensitive or noisy fields can be excluded with
select: false. - JSONB is used intentionally, with a database-level default.
- soft delete is supported with
@DeleteDateColumn.
The best entity design question is not "what fields do I need today?"
It is:
What future mistake would this entity make easy?
Then design against that mistake.
Put Shared Fields in a Base Entity
If every table needs the same operational fields, define them once.
export abstract class AppBaseEntity { @PrimaryGeneratedColumn('uuid') id!: string; @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; @DeleteDateColumn({ type: 'timestamptz', nullable: true }) deletedAt!: Date | null; }
Then your entities can focus on their own domain:
@Entity({ name: 'users' }) @Index('users_email_unique_idx', ['email'], { unique: true }) export class UserEntity extends AppBaseEntity { @Column({ type: 'citext' }) email!: string; @Column({ type: 'text', select: false }) passwordHash!: string; }
Only put truly universal fields here. Do not force business-specific concepts like businessId, status, or version into a base class unless they are genuinely part of every entity contract.
Default to Restrictive Deletes
Database cascades are not evil. Silent cascades in application-owned business data usually are.
For business data, I default to NO ACTION or intentional SET NULL instead of ON DELETE CASCADE.
Why?
A business, audit trail, invoice, invite, verification record, payment reference, support note, and analytics event are not disposable implementation details. If a user row disappears, you probably do not want meaningful operational history to vanish because one relation decorator said cascade.
Safer default:
@ManyToOne(() => BusinessEntity, { onDelete: 'NO ACTION' }) @JoinColumn({ name: 'businessId' }) business!: BusinessEntity;
Use SET NULL only when the relationship is optional and the child row remains meaningful without the parent pointer:
@Column({ type: 'uuid', nullable: true }) archivedByUserId!: string | null; @ManyToOne(() => UserEntity, { onDelete: 'SET NULL', nullable: true }) @JoinColumn({ name: 'archivedByUserId' }) archivedBy!: UserEntity | null;
Use cascade delete only for data that is:
- private to the parent
- not auditable
- not legally or financially meaningful
- not referenced by reports or integrations
- safe to erase as one unit
Good cascade candidates: draft-only nested preferences, unshared temporary uploads, and private UI layout rows.
Bad cascade candidates: users, businesses, payments, audit logs, invoices, verification state, support records.
A Repository Pattern That Actually Helps
Generic repositories are risky. Exposing naked methods like findAll(), find(), or findBy() directly to services often leaves massive loopholes. Without strict standards, you end up fetching thousands of records unintentionally because someone forgot to add a limit. Even worse, you're forced to duplicate RBAC (Role-Based Access Control) rules across every single place data is read, which is a recipe for security vulnerabilities.
Small query objects or "Smart Repositories" solve this by centralizing three things: pagination standards, principal-aware filtering, and query shape.
Instead of a generic repository, define a query class that sets default properties for limit and pageSize, retrieves the current Principal, and enforces row-level security—ensuring, for example, that a user with ID 4 cannot query a profile belonging to user ID 5.
type Principal = { userId: string; businessIds: string[]; roles: string[]; }; export class BusinessQueries { private readonly DEFAULT_LIMIT = 20; private readonly MAX_LIMIT = 100; constructor(private readonly repo: Repository<BusinessEntity>) {} /** * Enforces RBAC and Row-Level Security. * Ensures the Principal can only see what they are allowed to. */ private secure(principal: Principal) { return this.repo .createQueryBuilder('business') .where('business.ownerUserId = :userId', { userId: principal.userId }) .orWhere('business.id IN (:...businessIds)', { businessIds: principal.businessIds.length ? principal.businessIds : ['00000000-0000-0000-0000-000000000000'], }); } getBusinesses(params: { principal: Principal; search?: string; verified?: boolean; limit?: number; page?: number; }) { // Standardized pagination const limit = Math.min(params.limit ?? this.DEFAULT_LIMIT, this.MAX_LIMIT); const page = params.page ?? 1; const offset = (page - 1) * limit; const qb = this.secure(params.principal) .select([ 'business.id', 'business.name', 'business.slug', 'business.isVerified', 'business.createdAt', ]) .orderBy('business.createdAt', 'DESC') .addOrderBy('business.id', 'DESC') .take(limit) .skip(offset); if (params.search) { qb.andWhere('business.name ILIKE :search', { search: `%${params.search}%`, }); } if (params.verified !== undefined) { qb.andWhere('business.isVerified = :verified', { verified: params.verified, }); } return qb; } }
The service still owns state transitions and side effects. The query object owns reusable SQL shape.
This separation ensures we don't allow unstructured or unsafe reads across the application, moving away from the dangerous assumption that every entity should expose the same generic CRUD surface.
In this chapter, we covered:
- The philosophy behind a production-grade ORM.
- What TypeORM excels at and where its boundaries lie.
- Establishing a production-ready database contract.
- Designing entities as robust storage contracts.
- Implementing base entities and restrictive delete policies.
- Building "Smart Repositories" to enforce security and standards.
In the next chapter: We'll dive into advanced query techniques, including avoiding N+1 problems, pagination, and mastering transactions.
