Skip to content

EntityManager -- Advanced

This page covers the advanced features of EntityManager: event listeners, entity subscribers, multi-tenancy, plugins, query diagnostics, the repository pattern, and graceful shutdown.

For basic CRUD, see CRUD Basics. For querying, see Querying & Pagination. For batch writes and transactions, see Writes & Transactions.


Event Listeners

Why events?

Every save(), delete(), or softDelete() call changes data in your database. Sometimes you need to react to those changes -- not inside the CRUD logic itself, but in a separate, decoupled piece of code. Common examples:

  • Audit logging: Record who changed what, and when.
  • Cache invalidation: Clear a Redis cache when the underlying data changes.
  • Analytics: Send a tracking event whenever a new user is created.
  • Notifications: Trigger a webhook or email after an order is placed.

You could put this logic directly in your service code, but then every service that creates a user would need to remember to log, invalidate cache, and send analytics. Events let you write that logic once, in one place, and it fires automatically no matter where the data change originates.

When to use events vs. lifecycle hooks

The distinction matters:

  • Lifecycle hooks (@BeforeInsert, @AfterUpdate, etc.) are defined on the entity class itself. They are tightly coupled to the entity and run as part of the save/delete pipeline. Use them for entity-internal concerns like "hash the password before insert" or "normalize the email to lowercase."

  • Event listeners are defined on the EntityManager. They are decoupled from the entity and can observe all entity types at once. Use them for cross-cutting concerns like "log every database write to an audit table."

Available events

EventFires When
beforeInsertBefore a new row is inserted
afterInsertAfter a new row is inserted
beforeUpdateBefore an existing row is updated
afterUpdateAfter an existing row is updated
beforeDeleteBefore a row is deleted
afterDeleteAfter a row is deleted

Registering a listener

typescript
em.on("afterInsert", ({ entity, data }) => {
  console.log(`${entity.name} created:`, data);
});

em.on("beforeUpdate", ({ entity, data }) => {
  console.log(`About to update ${entity.name}:`, data);
});

The listener receives an object with:

  • entity -- the entity class (constructor function)
  • data -- the entity data being inserted/updated/deleted

Removing listeners

typescript
// Remove a specific listener
const listener = ({ entity, data }) => { /* ... */ };
em.on("afterInsert", listener);
em.off("afterInsert", listener);

// Remove ALL listeners across ALL events
em.removeAllListeners();

TIP

Event listeners fire for all entities. If you need listeners scoped to a specific entity class, use Entity Subscribers instead (see below).


Entity Subscribers

Why subscribers when events already exist?

Event listeners fire for every entity type. If you register an afterInsert listener, it fires when a User is created, when a Post is created, when an Order is created -- for everything. Your listener code must then check which entity triggered it.

Subscribers solve this by binding to a specific entity class. A UserSubscriber only fires for User events, never for Post or Order. This is cleaner, safer, and easier to reason about.

Think of it as the difference between a security camera that watches the entire building (events) versus one that watches only the vault (subscribers).

When to use subscribers vs. global events

Use caseRecommended approach
Log every database write to an audit tableGlobal event listener
Invalidate all caches on any data changeGlobal event listener
Send a welcome email when a User is createdEntity subscriber (UserSubscriber)
Update a search index when a Post changesEntity subscriber (PostSubscriber)
Recalculate order total when OrderItem changesEntity subscriber (OrderItemSubscriber)

Creating a subscriber

typescript
import { EntitySubscriber, InsertEvent, UpdateEvent, DeleteEvent } from "@stingerloom/orm";

class UserSubscriber implements EntitySubscriber<User> {
  listenTo() {
    return User;
  }

  afterInsert(event: InsertEvent<User>) {
    console.log("New user created:", event.entity);
  }

  beforeUpdate(event: UpdateEvent<User>) {
    console.log("About to update user:", event.entity);
  }

  afterDelete(event: DeleteEvent<User>) {
    console.log("User deleted:", event.entity);
  }
}

Registering and removing subscribers

typescript
const subscriber = new UserSubscriber();

// Register
em.addSubscriber(subscriber);

// Remove
em.removeSubscriber(subscriber);

Subscribers support the same lifecycle methods as event listeners: beforeInsert, afterInsert, beforeUpdate, afterUpdate, beforeDelete, afterDelete. Each is optional -- implement only the ones you need.

For a comprehensive guide on event patterns (audit logging, cache invalidation), see Events & Subscribers.


Multi-Tenancy -- withTenant()

Why multi-tenancy?

Multi-tenancy means serving multiple customers (tenants) from the same application, with each tenant's data isolated from the others. Think of an apartment building: everyone shares the same building infrastructure, but each tenant has their own locked apartment.

Without an ORM-level solution, you would need to manually prefix every query with the tenant schema, manage search paths, and ensure no query accidentally crosses tenant boundaries. withTenant() handles all of this automatically.

How it works

withTenant() executes a callback in the context of a specific tenant. All EntityManager operations inside the callback are automatically scoped to that tenant's schema/data.

typescript
const result = await em.withTenant("tenant_acme", async (tenantEm) => {
  // All queries inside here target the "tenant_acme" schema
  const users = await tenantEm.find(User);
  return users;
});

Under the hood, withTenant() uses MetadataContext.run() with AsyncLocalStorage to isolate the tenant context. This means it is safe to use in concurrent request handlers -- each HTTP request gets its own isolated context, even though they all run in the same Node.js process.

The SQL difference: two strategies

The behavior depends on your tenantStrategy setting in register():

Strategy 1: search_path (default)

With search_path, the ORM sets the PostgreSQL search path inside a transaction before running any queries:

sql
-- 5 round-trips per tenant read:
BEGIN
SET LOCAL search_path = 'tenant_acme'
SELECT "id", "name", "email" FROM "user" WHERE "isActive" = $1
COMMIT
-- (+ connection acquire/release)

SET LOCAL scopes the search path to the current transaction only. Once the transaction ends, the search path reverts. This is safe but requires a transaction wrapper even for simple reads.

StrategyBehaviorTradeoff
"search_path" (default)SET LOCAL search_path = 'tenant_acme' inside a transactionSafe for all cases, but requires 5 round-trips per read

Strategy 2: schema_qualified

With schema_qualified, the ORM prefixes table names directly in the SQL, eliminating the need for a transaction:

sql
-- 1 round-trip:
SELECT "id", "name", "email" FROM "tenant_acme"."user" WHERE "isActive" = $1
StrategyBehaviorTradeoff
"schema_qualified"Uses "tenant_acme"."users" in the querySingle round-trip, but all queries must be schema-aware

To enable it:

typescript
await em.register({
  type: "postgres",
  // ...
  tenantStrategy: "schema_qualified",
});

The schema_qualified strategy is faster (1 round-trip vs 5), but search_path is the default because it works with every PostgreSQL feature without surprises (e.g., functions, triggers, and extensions all respect the search path).

For the full multi-tenancy setup guide (tenant provisioning, schema migration), see Multi-Tenancy.


Plugin System -- extend()

Why plugins?

As an ORM grows, every team wants different features: write buffering, audit trails, soft-delete overrides, custom caching. Putting all of these into the core would make EntityManager massive and force every user to pay the cost (in bundle size and complexity) for features they may never use.

Plugins solve this by letting you opt into additional capabilities. The core stays lean. You add what you need.

Installing a plugin

Install a plugin with extend():

typescript
import { bufferPlugin } from "@stingerloom/orm";

const em = new EntityManager();
await em.register({ /* ... */ });

// Install plugin -- new methods are mixed into `em`
em.extend(bufferPlugin());

You can also install plugins declaratively in register():

typescript
await em.register({
  type: "postgres",
  // ...
  plugins: [bufferPlugin()],
});

Plugin introspection

typescript
// Check if a plugin is installed
em.hasPlugin("buffer"); // true

// Get a plugin's API object
const api = em.getPluginApi<BufferApi>("buffer");

Idempotency and dependencies

  • Installing the same plugin twice is a no-op (safe to call multiple times).
  • Plugins can declare dependencies. If a dependency is not installed, extend() throws OrmError with code PLUGIN_DEPENDENCY_MISSING.
  • Plugin method names must not conflict with existing EntityManager members -- conflicts throw OrmError with code PLUGIN_CONFLICT.

For the full plugin authoring guide and built-in plugins, see Plugin System.


Query Builder -- createQueryBuilder()

EntityManager provides two flavors of query builder for complex queries that go beyond find().

SelectQueryBuilder (type-safe)

Created by passing an entity class and alias. Provides type-safe column references and narrowed return types.

typescript
const users = await em
  .createQueryBuilder(User, "u")
  .select(["id", "name", "email"])     // Return type narrows to Pick<User, "id" | "name" | "email">
  .where("isActive", true)
  .andWhere("age", ">=", 18)
  .orderBy({ createdAt: "DESC" })
  .limit(10)
  .getMany();

RawQueryBuilder (free-form)

Created with no arguments. Provides full SQL control with no type constraints.

typescript
import sql from "sql-template-tag";

const qb = em.createQueryBuilder();
const query = qb
  .select(["*"])
  .from('"users"')
  .where([sql`"is_active" = ${true}`])
  .build();

const result = await em.query(query);

For the full guide (JOIN, UNION, CTE, window functions, subqueries, validation), see Query Builder.


Repository Pattern -- getRepository()

If you prefer to encapsulate CRUD per entity rather than passing the entity class to every method, use repositories.

typescript
const userRepo = em.getRepository(User);

const users = await userRepo.find();
const user = await userRepo.findOne({ where: { id: 1 } });
await userRepo.save({ name: "Alice" });
await userRepo.delete({ id: 1 });

A repository wraps the same EntityManager methods but is pre-bound to a specific entity class.

NestJS injection

In NestJS, inject repositories into services with @InjectRepository():

typescript
import { Injectable } from "@nestjs/common";
import { InjectRepository, BaseRepository } from "@stingerloom/orm/nestjs";
import { User } from "./user.entity";

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User) private readonly userRepo: BaseRepository<User>,
  ) {}

  findAll() {
    return this.userRepo.find();
  }
}

For multi-database environments, pass the connection name as the second argument:

typescript
@InjectRepository(Event, "analytics")
private readonly eventRepo: BaseRepository<Event>,

You can also inject the EntityManager directly:

typescript
import { InjectEntityManager } from "@stingerloom/orm/nestjs";

@Injectable()
export class StatsService {
  constructor(
    @InjectEntityManager() private readonly em: EntityManager,
    // Named connection: @InjectEntityManager("analytics")
  ) {}
}

For the full NestJS integration guide, see NestJS Module Setup.


Driver Access -- getDriver()

Access the underlying SQL driver for low-level operations:

typescript
const driver = em.getDriver();

if (driver) {
  // Direct access to driver-specific features
  const tables = await driver.getTables();
  console.log(tables);
}

The driver implements the ISqlDriver interface. Common use cases include schema introspection and direct DDL operations. Returns undefined if register() has not been called yet.


Query Diagnostics

Why query diagnostics?

When your application slows down, the cause is almost always in the database layer. But which queries are slow? Are you accidentally running the same query hundreds of times (the N+1 problem)? Without visibility into what SQL is being executed, you are debugging blind.

Query diagnostics give you that visibility. You can log every query, detect N+1 patterns automatically, and get warnings when a query exceeds a time threshold.

getQueryLog()

Returns the query tracker's log -- an array of recent queries with entity name, SQL text, and duration.

typescript
const log = em.getQueryLog();
for (const entry of log) {
  console.log(`[${entry.entityName}] ${entry.sql} (${entry.durationMs}ms)`);
}

This is useful for:

  • Debugging: "What SQL did my last API call actually execute?"
  • Performance monitoring: "Which queries are taking the longest?"
  • Test assertions: "Did this service call execute the expected number of queries?"

INFO

Query logging requires the logging option in register(). Without it, getQueryLog() returns an empty array.

typescript
await em.register({
  // ...
  logging: {
    queries: true,       // Log SQL to console
    slowQueryMs: 500,    // Warn on queries slower than 500ms
    nPlusOne: true,      // Detect N+1 query patterns
  },
});

getQueryTracker()

Returns the QueryTracker instance (or null if tracking is disabled). Useful for programmatic access to query statistics in tests or diagnostics.

typescript
const tracker = em.getQueryTracker();
if (tracker) {
  console.log("Active queries:", tracker.activeQueryCount);
}

For the full logging and diagnostics guide, see Logging & Diagnostics.


Shutdown -- propagateShutdown()

Why explicit shutdown?

Node.js processes can hold resources that outlive individual requests: connection pools, event listeners, subscriber references, plugin state, and query tracker buffers. If you just stop the process without cleaning up, you risk:

  • Connection pool leaks: The database sees abandoned connections that count against its max_connections limit.
  • Unfinished transactions: In-flight queries may leave locks held or transactions open.
  • Memory leaks in long-running processes: If the EntityManager is recreated without shutting down the old one (common in hot-reload development), listeners and subscribers accumulate.

propagateShutdown() cleans up all of these resources in the correct order.

Basic usage

typescript
await em.propagateShutdown();

Options

typescript
const allCompleted = await em.propagateShutdown({
  gracefulTimeoutMs: 5000,  // Wait up to 5s for active queries to finish
  closeConnections: true,   // Also close the database connection pool
});

if (!allCompleted) {
  console.warn("Some queries were still running when shutdown was forced");
}
OptionTypeDefaultDescription
gracefulTimeoutMsnumber0Max time (ms) to wait for in-flight queries. 0 = don't wait.
closeConnectionsbooleanfalseWhether to close the underlying connection pool.

Return value: boolean -- true if all active queries completed within the timeout, false if the shutdown was forced.

Shutdown sequence

Here is what happens internally, in order:

  1. Wait for active queries (if gracefulTimeoutMs > 0) -- The ORM checks if any queries are currently executing. If so, it waits up to the specified timeout for them to finish.
  2. Shutdown plugins in reverse installation order (LIFO) -- If you installed plugins A, then B, then C, they shut down in order C, B, A. This respects dependencies (a plugin that depends on another shuts down before its dependency).
  3. Clear event listeners, subscribers, and dirty entity tracking -- Removes all registered on() listeners and subscriber instances to prevent memory leaks.
  4. Reset the query tracker -- Clears accumulated query logs and statistics.
  5. Shutdown the replication router -- Stops health checks for read replicas.
  6. Close connection pool (if closeConnections: true) -- Terminates all database connections in the pool.

Real-world NestJS scenario

In a NestJS application, the typical pattern is to call propagateShutdown() in the OnModuleDestroy lifecycle hook. This ensures that when NestJS shuts down (due to a SIGTERM signal from Kubernetes, a deployment, or a test teardown), the ORM releases all resources:

typescript
import { OnModuleDestroy, Injectable } from "@nestjs/common";
import { InjectEntityManager } from "@stingerloom/orm/nestjs";
import { EntityManager } from "@stingerloom/orm";

@Injectable()
export class AppService implements OnModuleDestroy {
  constructor(
    @InjectEntityManager() private readonly em: EntityManager,
  ) {}

  async onModuleDestroy() {
    // Wait up to 10 seconds for queries to finish, then close everything
    const clean = await em.propagateShutdown({
      gracefulTimeoutMs: 10_000,
      closeConnections: true,
    });

    if (!clean) {
      console.warn("ORM shutdown was forced -- some queries may not have completed");
    }
  }
}

In a Kubernetes environment, the timeline looks like this:

1. Kubernetes sends SIGTERM to the pod
2. NestJS receives SIGTERM, starts shutting down modules
3. onModuleDestroy() fires
4. propagateShutdown() starts:
   - Waits up to 10s for 3 active queries to finish (they complete in 2s)
   - Shuts down buffer plugin
   - Clears 5 event listeners and 2 subscribers
   - Resets the query tracker
   - Closes the connection pool (releases 10 connections)
5. Returns true (all queries completed)
6. NestJS finishes shutdown
7. Process exits cleanly

FindOption Reference

Complete list of options accepted by find(), findOne(), findAndCount(), findWithCursor(), stream(), and explain().

OptionTypeDescription
whereWhereClause<T>WHERE conditions. Each key-value pair becomes an AND condition.
select(keyof T)[] or Record<keyof T, boolean>Columns to select. Omit for SELECT *.
orderByRecord<keyof T, "ASC" | "DESC">Sort order. Multiple keys = multi-column sort.
limitnumber or [offset, count]Raw LIMIT. Prefer skip/take for pagination.
skipnumberOffset for pagination. Used with take.
takenumberMax rows to return. Used with skip.
relations(keyof T | string)[]Relations to eager-load via LEFT JOIN. Supports nested paths (e.g., "author.profile").
withDeletedbooleanInclude soft-deleted entities (@DeletedAt). Default: false.
groupBy(keyof T)[]GROUP BY columns.
havingSql[]HAVING conditions (sql-template-tag). Joined with AND.
timeoutnumberPer-query timeout in ms. Overrides the connection-level queryTimeout.
distinctbooleanGenerate SELECT DISTINCT. Default: false.
useMasterbooleanForce read from master node in a replication setup. Default: false.
lockLockModePessimistic lock: PESSIMISTIC_WRITE (FOR UPDATE) or PESSIMISTIC_READ (FOR SHARE).

Next Steps

Released under the MIT License.