โ† Back to DevBytes

State Management in TypeORM: Patterns and Libraries

Introduction to State Management in TypeORM

State management is one of the most critical aspects of building robust applications with TypeORM. When working with databases, your application constantly juggles between in-memory representations of data (entities) and their persisted counterparts in the database. How you manage this state โ€” tracking changes, synchronizing updates, handling transactions, and maintaining consistency โ€” directly impacts the reliability, performance, and maintainability of your application.

TypeORM, a popular Object-Relational Mapper for TypeScript and JavaScript, provides several built-in mechanisms for managing entity state. However, understanding these mechanisms deeply and knowing when to augment them with additional patterns or libraries is what separates a functional codebase from an exceptional one.

In this tutorial, we will explore what state management means in the context of TypeORM, why it matters, the patterns you can adopt, and the libraries that can enhance your workflow. By the end, you will have a comprehensive understanding of how to keep your application's data state consistent, predictable, and efficient.

What Is State Management in TypeORM?

In TypeORM, state management refers to the process of tracking and synchronizing the state of your entity objects with the underlying database. An entity in TypeORM is a TypeScript class decorated with the @Entity() decorator, and each instance of that class represents a row in a database table. The "state" of an entity includes its property values at any given moment in your application's lifecycle.

When you load an entity from the database, modify it in memory, and then save it back, TypeORM needs to know what changed, what didn't, and how to persist only the necessary updates. This is the core of state management. It involves several key concepts:

TypeORM implements these concepts primarily through its EntityManager and QueryRunner classes. Understanding how these work under the hood is essential for effective state management.

Why State Management Matters

Poor state management can lead to a cascade of problems that are often difficult to diagnose. Here are the primary reasons why state management deserves careful attention in any TypeORM-based application:

1. Data Consistency

Without proper state management, you risk writing stale or conflicting data to your database. For example, if two parts of your application hold different in-memory copies of the same database row and both attempt to save changes, you can end up with lost updates or corrupted data. The identity map pattern, which TypeORM employs within a single EntityManager scope, helps prevent this by ensuring only one copy of each entity exists per manager instance.

2. Performance Optimization

Effective state management allows TypeORM to generate optimized SQL queries. When change tracking is working correctly, the ORM can issue UPDATE statements that only modify the columns that actually changed, rather than blindly updating every column. This reduces database load and network traffic.

3. Transaction Integrity

Many business operations require multiple database writes to succeed or fail together. State management is what allows you to group these writes into a transaction and roll back all changes if any single operation fails. Without this, partial updates could leave your database in an inconsistent state.

4. Predictable Application Behavior

When state is managed explicitly and consistently, your application's behavior becomes predictable. You know exactly when entities are loaded, when they are modified, and when changes are flushed to the database. This predictability is crucial for debugging, testing, and reasoning about your code.

5. Concurrency Control

In multi-user applications, concurrent modifications to the same data are inevitable. State management patterns like optimistic locking help detect and handle these conflicts gracefully, preventing silent data loss.

Understanding TypeORM's Built-in State Management

Before exploring external patterns and libraries, it is essential to understand the state management capabilities that TypeORM provides out of the box. These built-in features form the foundation upon which all other patterns are built.

The EntityManager and Unit of Work

The EntityManager is the central hub for state management in TypeORM. Each EntityManager instance maintains its own identity map and unit of work. When you load entities through a manager, it tracks them. When you call save() or remove(), the manager coordinates the persistence of all tracked changes.

import { EntityManager } from "typeorm";
import { User } from "./entities/User";

async function demonstrateEntityManagerState(connection: any) {
  // Each EntityManager instance has its own identity map
  const manager: EntityManager = connection.manager;

  // Loading a user โ€” this entity is now tracked by the manager
  const user = await manager.findOne(User, { where: { id: 1 } });
  console.log(user?.name); // e.g., "Alice"

  // Modifying the entity in memory
  if (user) {
    user.name = "Alice Updated";
  }

  // Saving โ€” TypeORM knows only the 'name' column changed
  // It generates: UPDATE user SET name = 'Alice Updated' WHERE id = 1
  await manager.save(user);

  // The entity remains tracked and its state is now "clean" again
  // Further saves without changes will not produce unnecessary queries
}

It is important to note that the global EntityManager (accessed via connection.manager) shares its identity map across your entire application. This can lead to unexpected behavior in concurrent scenarios. For isolated state management, you should use custom entity managers or query runners.

Custom Entity Managers for Isolated State

To create an isolated state context, you can create a new EntityManager instance. This is particularly useful in request-scoped scenarios where you want each HTTP request to have its own identity map.

import { createConnection, EntityManager } from "typeorm";
import { User } from "./entities/User";

async function isolatedStateExample() {
  const connection = await createConnection({
    type: "postgres",
    host: "localhost",
    port: 5432,
    username: "postgres",
    password: "password",
    database: "test",
    entities: [User],
    synchronize: true,
  });

  // Create an isolated entity manager with its own identity map
  const isolatedManager = new EntityManager(connection);

  // This manager tracks entities independently from the global manager
  const user = await isolatedManager.findOne(User, { where: { id: 1 } });

  if (user) {
    user.name = "Isolated Update";
    await isolatedManager.save(user);
  }

  // The global manager does not know about this change
  // until it re-fetches the entity from the database
  const globalUser = await connection.manager.findOne(User, { where: { id: 1 } });
  console.log(globalUser?.name); // "Isolated Update" (fresh from DB)

  await connection.close();
}

The Identity Map in Action

The identity map ensures that within a single EntityManager scope, loading the same database row multiple times returns the same object reference. This prevents inconsistent state within a single operation.

import { getManager } from "typeorm";
import { User } from "./entities/User";

async function identityMapExample() {
  const manager = getManager();

  // First load
  const user1 = await manager.findOne(User, { where: { id: 1 } });

  // Second load of the same row
  const user2 = await manager.findOne(User, { where: { id: 1 } });

  // These are the same object reference due to the identity map
  console.log(user1 === user2); // true

  // Modifying user1 also modifies user2 since they are the same object
  if (user1) {
    user1.name = "Changed via user1";
  }
  console.log(user2?.name); // "Changed via user1"
}

Common State Management Patterns

Beyond TypeORM's built-in mechanisms, several well-established patterns can help you structure your state management more effectively. Each pattern has its own trade-offs, and the best choice depends on your application's complexity and requirements.

Repository Pattern

The repository pattern is TypeORM's recommended approach. It encapsulates data access logic within repository classes, keeping your business logic separate from persistence concerns. TypeORM provides custom repository support that allows you to extend the base repository with domain-specific methods.

import { EntityRepository, Repository } from "typeorm";
import { User } from "./entities/User";

@EntityRepository(User)
export class UserRepository extends Repository<User> {
  // Custom method that encapsulates a common query pattern
  async findActiveUsers(): Promise<User[]> {
    return this.createQueryBuilder("user")
      .where("user.isActive = :isActive", { isActive: true })
      .orderBy("user.createdAt", "DESC")
      .getMany();
  }

  // State-aware method that updates and returns the updated entity
  async activateUser(userId: number): Promise<User | undefined> {
    await this.update(userId, { isActive: true });
    return this.findOne(userId);
  }

  // Soft delete with state tracking
  async softDeleteUser(userId: number): Promise<void> {
    await this.update(userId, {
      isActive: false,
      deletedAt: new Date(),
    });
  }
}

Using the repository in your services keeps state management centralized:

import { getCustomRepository } from "typeorm";
import { UserRepository } from "../repositories/UserRepository";

export class UserService {
  private userRepo: UserRepository;

  constructor() {
    this.userRepo = getCustomRepository(UserRepository);
  }

  async activateUserAccount(userId: number) {
    const user = await this.userRepo.activateUser(userId);
    if (!user) {
      throw new Error("User not found");
    }
    return user;
  }

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

Active Record Pattern

TypeORM also supports the Active Record pattern, where entities themselves contain the methods for persistence. This pattern is simpler but can lead to fat entity classes that mix data and behavior.

import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from "typeorm";

@Entity()
export class User extends BaseEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column()
  email: string;

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

  // Entity methods that manage their own state
  static async findActiveUsers(): Promise<User[]> {
    return this.createQueryBuilder("user")
      .where("user.isActive = :isActive", { isActive: true })
      .getMany();
  }

  async deactivate(): Promise<void> {
    this.isActive = false;
    await this.save();
  }

  async updateProfile(name: string, email: string): Promise<void> {
    this.name = name;
    this.email = email;
    await this.save();
  }
}

// Usage
async function activeRecordExample() {
  const user = await User.findOne({ where: { id: 1 } });
  if (user) {
    await user.deactivate();
  }

  const activeUsers = await User.findActiveUsers();
}

Data Mapper Pattern

The Data Mapper pattern separates the in-memory entity from the persistence logic entirely. Entities are plain objects with no knowledge of the database, and a separate mapper handles all database interactions. TypeORM's repository pattern is a step toward this, but you can take it further by ensuring your entities have no ORM-specific decorators in your domain layer.


// Domain entity โ€” no ORM decorators, pure business object
export class User {
  constructor(
    public readonly id: number,
    public name: string,
    public email: string,
    public isActive: boolean,
    public createdAt: Date
  ) {}

  deactivate(): void {
    if (!this.isActive) {
      throw new Error("User is already inactive");
    }
    this.isActive = false;
  }

  changeEmail(newEmail: string): void {
    if (!this.isValidEmail(newEmail)) {
      throw new Error("Invalid email format");
    }
    this.email = newEmail;
  }

  private isValidEmail(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }
}

// Data mapper โ€” handles persistence, maps between domain and ORM entities
import { Repository } from "typeorm";
import { User as UserORM } from "../entities/User";

export class UserDataMapper {
  constructor(private ormRepo: Repository<UserORM>) {}

  async findById(id: number): Promise<User | null> {
    const ormUser = await this.ormRepo.findOne({ where: { id } });
    if (!ormUser) return null;
    return this.toDomain(ormUser);
  }

  async save(user: User): Promise<void> {
    const ormUser = this.toORM(user);
    await this.ormRepo.save(ormUser);
  }

  private toDomain(orm: UserORM): User {
    return new User(
      orm.id,
      orm.name,
      orm.email,
      orm.isActive,
      orm.createdAt
    );
  }

  private toORM(domain: User): UserORM {
    const orm = new UserORM();
    orm.id = domain.id;
    orm.name = domain.name;
    orm.email = domain.email;
    orm.isActive = domain.isActive;
    return orm;
  }
}

Transaction State Management

Transactions are a fundamental part of state management. They ensure that a group of database operations either all succeed or all fail, maintaining data consistency. TypeORM provides multiple ways to manage transactions, each with different implications for state management.

Using QueryRunner for Explicit Transaction Control

The QueryRunner provides the most explicit and flexible transaction management. It creates a completely isolated state context, including its own EntityManager, ensuring that all operations within the transaction share the same identity map.

import { createConnection, QueryRunner } from "typeorm";
import { User } from "./entities/User";
import { Account } from "./entities/Account";

async function transferFunds(userId: number, amount: number) {
  const connection = await createConnection(/* config */);
  const queryRunner: QueryRunner = connection.createQueryRunner();

  await queryRunner.connect();
  await queryRunner.startTransaction();

  try {
    // All operations use the same queryRunner, sharing state
    const user = await queryRunner.manager.findOne(User, {
      where: { id: userId },
    });

    if (!user) {
      throw new Error("User not found");
    }

    const account = await queryRunner.manager.findOne(Account, {
      where: { userId: user.id },
    });

    if (!account || account.balance < amount) {
      throw new Error("Insufficient funds");
    }

    // Modify state
    account.balance -= amount;

    // Save within the transaction
    await queryRunner.manager.save(account);

    // Log the transaction
    const logEntry = queryRunner.manager.create(TransactionLog, {
      userId: user.id,
      amount,
      type: "debit",
      timestamp: new Date(),
    });
    await queryRunner.manager.save(logEntry);

    // Commit โ€” all changes are persisted atomically
    await queryRunner.commitTransaction();
    console.log("Transfer completed successfully");
  } catch (error) {
    // Rollback โ€” all changes are discarded, state remains consistent
    await queryRunner.rollbackTransaction();
    console.error("Transfer failed:", error.message);
    throw error;
  } finally {
    // Always release the query runner
    await queryRunner.release();
  }
}

Using the transaction() Decorator

For simpler scenarios, TypeORM provides a @Transaction() decorator and @TransactionManager() parameter decorator that automatically manage transaction boundaries.

import { Transaction, TransactionManager, EntityManager } from "typeorm";
import { User } from "./entities/User";
import { Order } from "./entities/Order";

export class OrderService {
  @Transaction()
  async createOrder(
    userId: number,
    orderData: Partial<Order>,
    @TransactionManager() manager: EntityManager
  ): Promise<Order> {
    // All operations within this method use the same transaction
    const user = await manager.findOne(User, { where: { id: userId } });
    if (!user) {
      throw new Error("User not found");
    }

    const order = manager.create(Order, {
      ...orderData,
      userId: user.id,
      status: "pending",
    });

    await manager.save(order);

    user.lastOrderAt = new Date();
    await manager.save(user);

    return order;
    // Transaction is automatically committed on success
    // or rolled back if an error is thrown
  }
}

Optimistic Locking for Concurrent State Management

When multiple users might modify the same entity concurrently, optimistic locking helps detect conflicts. TypeORM supports this through a version column or an updated timestamp column.

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  VersionColumn,
} from "typeorm";

@Entity()
export class Product {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column("decimal")
  price: number;

  @Column("int")
  stock: number;

  // Version column enables optimistic locking
  @VersionColumn()
  version: number;
}

// Usage with optimistic locking
import { getManager } from "typeorm";

async function purchaseProduct(productId: number, quantity: number) {
  const manager = getManager();

  // Load the product โ€” note the version number
  const product = await manager.findOne(Product, {
    where: { id: productId },
  });

  if (!product) {
    throw new Error("Product not found");
  }

  if (product.stock < quantity) {
    throw new Error("Insufficient stock");
  }

  // Modify state
  product.stock -= quantity;

  try {
    // TypeORM includes the version in the WHERE clause:
    // UPDATE product SET stock = ?, version = version + 1
    // WHERE id = ? AND version = ?
    await manager.save(product);
    console.log("Purchase successful");
  } catch (error) {
    // If the version in the database doesn't match,
    // the update affects 0 rows and TypeORM throws an error
    console.error("Concurrent modification detected โ€” please retry");
    throw error;
  }
}

Libraries for Enhanced State Management

While TypeORM provides robust built-in state management, several libraries can complement it to address specific needs such as validation, dependency injection, event-driven state changes, and more.

class-validator for State Validation

Before persisting state changes, it is critical to validate that the new state is valid. The class-validator library integrates seamlessly with TypeORM through validation decorators.

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
} from "typeorm";
import {
  IsEmail,
  IsString,
  MinLength,
  MaxLength,
  IsBoolean,
  IsOptional,
} from "class-validator";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  @IsString()
  @MinLength(2)
  @MaxLength(50)
  name: string;

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

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

  @Column({ nullable: true })
  @IsOptional()
  @MinLength(8)
  @MaxLength(100)
  bio?: string;
}

// Validation before saving
import { validate } from "class-validator";
import { getManager } from "typeorm";

async function createUser(userData: Partial<User>) {
  const manager = getManager();
  const user = manager.create(User, userData);

  // Validate the entity state before persisting
  const errors = await validate(user);
  if (errors.length > 0) {
    const messages = errors
      .map((e) => Object.values(e.constraints || {}).join(", "))
      .join("; ");
    throw new Error(`Validation failed: ${messages}`);
  }

  await manager.save(user);
  return user;
}

TypeDI for Dependency Injection and State Scoping

TypeDI is a dependency injection container that pairs naturally with TypeORM. It helps manage the lifecycle of services and repositories, ensuring that state is properly scoped and shared where appropriate.

import { Container } from "typedi";
import { createConnection, useContainer } from "typeorm";
import { User } from "./entities/User";
import { UserRepository } from "./repositories/UserRepository";

// Tell TypeORM to use TypeDI's container
useContainer(Container);

// Register services
Container.set("USER_REPOSITORY", UserRepository);

export class UserService {
  constructor(private readonly userRepo: UserRepository) {}

  async updateUserState(id: number, updates: Partial<User>): Promise<User> {
    const user = await this.userRepo.findOne({ where: { id } });
    if (!user) {
      throw new Error("User not found");
    }

    Object.assign(user, updates);
    await this.userRepo.save(user);
    return user;
  }
}

// Initialize connection and resolve services
async function bootstrap() {
  await createConnection({
    type: "postgres",
    host: "localhost",
    port: 5432,
    username: "postgres",
    password: "password",
    database: "test",
    entities: [User],
    synchronize: true,
  });

  const userService = Container.get(UserService);
  const updatedUser = await userService.updateUserState(1, {
    name: "Updated Name",
  });
  console.log(updatedUser);
}

bootstrap();

Entity Subscribers for Event-Driven State Changes

TypeORM's subscriber system allows you to hook into entity lifecycle events. This is useful for automatically managing derived state, audit logging, or triggering side effects when state changes.

import {
  EventSubscriber,
  EntitySubscriberInterface,
  InsertEvent,
  UpdateEvent,
  RemoveEvent,
} from "typeorm";
import { User } from "./entities/User";
import { AuditLog } from "./entities/AuditLog";

@EventSubscriber()
export class UserSubscriber implements EntitySubscriberInterface<User> {
  listenTo() {
    return User;
  }

  // Called before insert โ€” set default state
  beforeInsert(event: InsertEvent<User>) {
    if (!event.entity.createdAt) {
      event.entity.createdAt = new Date();
    }
    if (event.entity.isActive === undefined) {
      event.entity.isActive = true;
    }
  }

  // Called after insert โ€” log the state creation
  async afterInsert(event: InsertEvent<User>) {
    const auditLog = event.manager.create(AuditLog, {
      entityType: "User",
      entityId: event.entity.id,
      action: "CREATE",
      newState: JSON.stringify(event.entity),
      timestamp: new Date(),
    });
    await event.manager.save(auditLog);
  }

  // Called after update โ€” log the state change
  async afterUpdate(event: UpdateEvent<User>) {
    const auditLog = event.manager.create(AuditLog, {
      entityType: "User",
      entityId: event.entityId,
      action: "UPDATE",
      oldState: JSON.stringify(event.databaseEntity),
      newState: JSON.stringify(event.entity),
      timestamp: new Date(),
    });
    await event.manager.save(auditLog);
  }

  // Called after remove โ€” log the state deletion
  async afterRemove(event: RemoveEvent<User>) {
    const auditLog = event.manager.create(AuditLog, {
      entityType: "User",
      entityId: event.entityId,
      action: "DELETE",
      oldState: JSON.stringify(event.databaseEntity),
      timestamp: new Date(),
    });
    await event.manager.save(auditLog);
  }
}

MikroORM as an Alternative with Advanced State Management

For projects that require more sophisticated state management than TypeORM offers natively, MikroORM is worth considering. It implements the Unit of Work and Identity Map patterns more strictly than TypeORM, automatically tracking changes without requiring explicit save() calls.

// Example showing MikroORM's automatic change tracking
// (for comparison with TypeORM's explicit save approach)
import { MikroORM, Entity, PrimaryKey, Property } from "@mikro-orm/core";

@Entity()
export class User {
  @PrimaryKey()
  id: number;

  @Property()
  name: string;

  @Property()
  email: string;

  @Property()
  isActive: boolean = true;
}

async function mikroOrmExample() {
  const orm = await MikroORM.init({
    entities: [User],
    dbName: "test",
    type: "postgresql",
  });

  const em = orm.em.fork(); // Isolated entity manager

  // Load entity โ€” automatically tracked
  const user = await em.findOne(User, { id: 1 });
  if (user) {
    // Simply modify the property โ€” no save() call needed
    user.name = "Auto-tracked Change";
  }

  // Flush persists ALL tracked changes automatically
  // TypeORM requires explicit save() for each entity
  await em.flush();

  await orm.close();
}

Caching State for Performance

Caching is an important aspect of state management. TypeORM provides a built-in caching mechanism that can significantly reduce database load for frequently accessed, rarely changing data.

import { createConnection } from "typeorm";
import { User } from "./entities/User";

async function setupCaching() {
  const connection = await createConnection({
    type: "postgres",
    host: "localhost",
    port: 5432,
    username: "postgres",
    password: "password",
    database: "test",
    entities: [User],
    cache: {
      type: "redis",
      options: {
        host: "localhost",
        port: 6379,
      },
      duration: 30000, // Cache for 30 seconds
    },
  });

  // First query โ€” hits the database and caches the result
  const users1 = await connection
    .getRepository(User)
    .find({
      where: { isActive: true },
      cache: true, // Uses default cache duration
    });

  // Second query within 30 seconds โ€” returns cached result
  const users2 = await connection
    .getRepository(User)
    .find({
      where: { isActive: true },
      cache: true,
    });

  // Custom cache duration for specific queries
  const users3 = await connection
    .getRepository(User)
    .find({
      where: { isActive: true },
      cache: 60000, // Cache for 60 seconds
    });

  // Named cache with explicit ID for targeted invalidation
  const users4 = await connection
    .getRepository(User)
    .createQueryBuilder("user")
    .where("user.isActive = :isActive", { isActive: true })
    .cache("active-users-list", 30000)
    .getMany();

  // Clear a specific cache entry when state changes
  await connection.queryResultCache.remove(["active-users-list"]);
}

Best Practices for State Management in TypeORM

To get the most out of TypeORM's state management capabilities, follow these best practices:

1. Use Request-Scoped Entity Managers

In web applications, each HTTP request should have its own entity manager to prevent state leakage between concurrent requests. This ensures that the identity map is fresh for each request and avoids stale data issues.

// Express.js middleware for request-scoped state management
import { Connection } from "typeorm";
import { Request, Response, NextFunction } from "express";

export function requestScopedMiddleware(connection: Connection) {
  return (req: Request, res: Response, next: NextFunction) => {
    const queryRunner = connection.createQueryRunner();
    queryRunner.connect().then(() => {
      // Attach the request-scoped manager to the request object
      (req as any).entityManager = queryRunner.manager;
      (req as any).queryRunner = queryRunner;

      res.on("finish", () => {
        queryRunner.release();
      });

      next();
    });
  };
}

// Usage in a route handler
app.get("/users/:id", async (req, res) => {
  const manager = (req as any).entityManager;
  const user = await manager.findOne(User, {
    where: { id: parseInt(req.params.id) },
  });
  res.json(user);
});

2. Avoid Mixing Active Record and Data Mapper Patterns

While TypeORM supports both patterns, mixing them in the same codebase leads to confusion about where state is managed. Choose one pattern and stick with it consistently across your application.

3. Always Handle Transaction Cleanup

When using QueryRunner for transactions, always release the runner in a finally block to prevent connection leaks. Failing to do so can exhaust your connection pool under load.

async function safeTransaction(
  connection: Connection,
  work: (manager: EntityManager) => Promise<void>
) {
  const queryRunner = connection.createQueryRunner();
  await queryRunner.connect();
  await queryRunner.startTransaction();

  try {
    await work(queryRunner.manager);
    await queryRunner.commitTransaction();
  } catch (error) {
    await queryRunner.rollbackTransaction();
    throw error;
  } finally {
    await queryRunner.release();
  }
}

4. Use Soft Deletes to Preserve State History

Instead of hard-deleting records, consider soft deletes to maintain a history of state changes. TypeORM provides built-in soft delete support.

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  DeleteDateColumn,
} from "typeorm";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column()
  email: string;

  // TypeORM automatically manages this column for soft deletes
  @DeleteDateColumn()
  deletedAt: Date | null;
}

// Usage
import { getManager } from "typeorm";

async function softDeleteUser(id: number) {
  const manager = getManager();
  await manager.softDelete(User, id);
  // The record remains in the database with deletedAt set
  // Regular queries automatically exclude soft-deleted records
}

async function restoreUser(id: number) {
  const manager = getManager();
  await manager.restore(User, id);
  // The deletedAt column is set back to null
}

async function findIncludingDeleted() {
  const manager = getManager();
  // Use withDeleted to include soft-deleted records
  const allUsers = await manager.find(User, {
    withDeleted: true,
  });
  return allUsers;
}

5. Validate State Before Persistence

Always validate entity state before saving it to the database. While database constraints provide a safety net, validating in your application layer provides better error messages and prevents unnecessary database round trips.

6. Be Mindful of N+1 Query Problems

When loading related entities, be aware of how your state management approach affects query patterns. Use eager loading or query builder joins to avoid N+1 queries.

import { getRepository } from "typeorm";
import { User } from "./entities/User";

async function loadUsersWithPosts() {
  const userRepo = getRepository(User);

  // BAD: N+1 queries โ€” one for users, then one per user for posts
  const users = await userRepo.find();
  for (const user of users) {
    const posts = await user.posts; // Triggers a separate query each time
  }

  // GOOD: Single query with a join
  const usersWithPosts = await userRepo.find({
    relations: ["posts"],
  });

  // GOOD: Using query builder for more control
  const usersWithActivePosts = await userRepo
    .createQueryBuilder("user")
    .leftJoinAndSelect("user.posts", "post", "post.isActive = :active", {
      active: true,
    })
    .getMany();
}

7. Use Migrations Instead of Synchronize for Production

While synchronize: true is convenient for development, it can cause data loss in production by altering schema based on entity changes. Use migrations to manage schema state changes explicitly and safely.

import { MigrationInterface, QueryRunner } from "typeorm";

export class AddUserBioColumn1612345678901 implements MigrationInterface {
  name = "AddUserBioColumn1612345678901";

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      "ALTER TABLE \"user\" ADD COLUMN \"bio\" character varying(500)"
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query("ALTER TABLE \"user\" DROP COLUMN \"bio\"");
  }
}

Conclusion

State management in TypeORM is a multifaceted topic that touches every layer of your application, from entity design to transaction handling to caching strategy. By understanding TypeORM's built-in mechanisms โ€” the EntityManager, identity map, unit of work, and change tracking โ€” you can build a solid foundation for consistent data handling. Layering on established patterns like the Repository or Data Mapper pattern helps keep your code organized and testable, while libraries such as class-validator, TypeDI, and TypeORM's own subscriber system extend your capabilities for validation, dependency management, and event-driven state changes. Perhaps most importantly, following best practices like request-scoped entity managers, proper transaction cleanup, optimistic locking for concurrency, and soft deletes for state history will help you avoid the common pitfalls that plague database-driven applications. Remember that state management is not a one-size-fits-all concern โ€” the right approach depends on your application's specific needs, complexity, and scale. By thoughtfully applying the patterns and tools covered in this tutorial, you can build TypeORM applications that are reliable, performant, and maintainable for the long term.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles