TypeORM + PostgreSQL: Patterns for Real Apps
Migrations, soft deletes, optimistic locking, and the entity patterns we use across production codebases.
TypeORM is great until you need to do something non-trivial - then it gets opinionated in ways the docs do not always explain. After shipping it across a dozen production codebases, we have settled on a tight set of patterns that work at scale. Below is the playbook: how we structure entities, manage migrations, implement soft deletes, handle concurrency, and avoid the footguns that show up in month three of a project.
Migrations: review every diff
Migrations are where TypeORM teams burn the most time. Auto-generation compares your entities to the database schema and emits a migration - useful, but it generates everything including changes you may not want. Two rules:
- 1Review every generated migration before committing; never blindly accept the diff
- 2Never run synchronize: true in any environment beyond local development
Production drift is bad enough; auto-sync makes it catastrophic.
Migration workflow we standardize on
Generate the migration locally, name it descriptively (AddIsActiveToUsers not 1234567890123-Migration), commit it alongside the entity change in the same PR. CI runs migrations against a fresh database and verifies the schema matches the entities. Production deploys run migrations as a separate step before app deployment, with explicit rollback scripts for every migration. We never assume down migrations work - test them in staging first.
Soft deletes leak silently
TypeORM supports soft deletes via @DeleteDateColumn and the softDelete() repository method. The catch: queries do not automatically filter out soft-deleted rows unless you opt in via withDeleted: false (which is the default for most query builder paths but not all). Standardize on a base repository class that explicitly filters deleted records, and audit every raw SQL query and query builder call to ensure consistency. Otherwise, soft-deleted users and orders leak into application logic in surprising ways.
Optimistic locking for concurrent writes
Optimistic locking is the right concurrency model for most CRUD apps. Add @VersionColumn to entities that may be updated concurrently (orders, inventory, user settings). TypeORM auto-increments the version on every save; if two transactions read version 5 and both try to write, the second fails with OptimisticLockVersionMismatchError. Catch the error, fetch the latest version, and decide: retry, merge, or surface a conflict to the user. Pessimistic locking with setLock("pessimistic_write") is the heavier alternative; reserve it for true contention scenarios.
Custom repositories beat anemic ones
TypeORM 0.3+ uses DataSource and dataSource.getRepository(Entity) rather than the older @EntityRepository decorator. Wrap repositories with custom methods that encapsulate domain logic: userRepo.findActiveByEmail(email), orderRepo.findOpenOrdersOlderThan(days). The application layer should not be writing query builders directly - it should be calling intent-revealing methods. This pays off in testability and refactor safety.
Connection pooling and indexes
Default Postgres pool size in TypeORM is 10 connections per process. With Node clustering or autoscaling, you can quickly exhaust max_connections (typically 100-200). Use extra: { max: N } to set per-process pool size based on your topology, and use a pooler like PgBouncer in transaction mode for very high-concurrency workloads. Monitor pg_stat_activity to see actual connection usage.
Index strategy is something TypeORM does not do for you. The @Index decorator declares an index but the application is still responsible for using queries that hit it. Common pattern: every findBy query and every WHERE clause column should have an index unless the table is small and read-only. Use EXPLAIN ANALYZE in staging against production-shaped data to verify the planner uses your indexes.
Transactions: explicit, scoped, with timeouts
Transactions in TypeORM 0.3 use the DataSource transaction API: dataSource.transaction(async (manager) => { ... }). Never mix raw repository operations with transaction-scoped operations - they hit different sessions and lose atomicity. Wrap multi-entity writes in explicit transactions; do not rely on implicit transactional boundaries. For long-running operations, set explicit timeouts and statement timeouts on the database side to prevent runaway transactions blocking other work.
We deploy these patterns across production engagements in fintech, healthcare, and eCommerce. Talk to a senior engineer about how they apply to your platform.
