TypeORM Entity Listeners & Subscribers: A Complete Guide
Audience: Developers working with TypeORM 0.3+ who need to implement lifecycle hooks, audit trails, or cross-cutting concerns.
Goal: Move from "it works" to "I understand the architecture, trade-offs, and edge cases."
1. Introduction: Listeners vs Subscribers
🎯 What Problem Do They Solve?
TypeORM provides lifecycle hooks to execute logic automatically at specific points in an entity's lifecycle:
- Before/after inserting, updating, deleting, or loading an entity
- Before/after database queries or transactions
- For auditing, validation, caching, notifications, or side-effects
🔍 Two Approaches, Same Goal
| Feature | Entity Listener | Entity Subscriber |
|---|---|---|
| Location | Inside the entity class | Separate class implementing EntitySubscriberInterface |
| Registration | Automatic via decorators (@BeforeInsert()) | Explicit: add to DataSource.subscribers[] |
| Coupling | Tightly coupled to entity | Decoupled; follows separation of concerns |
| Context Access | Only this (the entity) | Rich event object (entity, databaseEntity, manager, queryRunner, metadata) |
| Scope | Single entity only | One, multiple, or all entities via listenTo() |
| Best For | Simple, self-contained logic (hash passwords, set defaults) | Audit trails, cross-entity validation, transactional side-effects |
🧭 When to Choose Which
2. The Entity Hydration Pipeline
🔍 What Is "Hydration"?
Hydration is TypeORM's internal process of transforming raw database rows into fully-initialized entity instances.
🔄 Three Separate Execution Layers
TypeORM has three distinct pipelines—hooks only run in the layer they're designed for:
| Hook Category | Pipeline | Triggered When |
|---|---|---|
@AfterLoad / afterLoad | Hydration (Read) | Every time TypeORM maps DB rows → entities |
@BeforeInsert, @AfterUpdate, etc. | Persistence (Write) | During save(), insert(), update(), remove() |
beforeQuery / afterQuery | Driver Layer | Right before/after SQL is sent to the database |
| Transaction hooks | Driver Layer | When TypeORM explicitly manages transactions |
⚠️ Key Insight: Only
@AfterLoadruns in the hydration pipeline. Persistence hooks run in the save/update pipeline. Query/Transaction hooks operate at the driver level.
3. Lifecycle Hooks Deep Dive
📦 Persistence Hooks (@BeforeInsert, @AfterUpdate, etc.)
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
password: string;
@BeforeInsert()
hashPassword() {
this.password = bcrypt.hashSync(this.password, 10);
}
}
Trigger: During save(), insert(), update(), remove(), recover().
📖 Hydration Hook (@AfterLoad)
@Entity()
export class Post {
@Column({ nullable: true })
likesCount?: number;
@AfterLoad()
initializeDefaults() {
// Runs after entity is loaded from DB
if (this.likesCount === undefined) this.likesCount = 0;
}
}
Trigger: Every time an entity is hydrated via find(), findOne(), relations loaded.
🔍 Soft-Delete Recovery Hooks (@BeforeRecover, @AfterRecover)
@Entity()
export class Article {
@DeleteDateColumn()
deletedAt?: Date;
@AfterRecover()
logRecovery() {
console.log(`Article ${this.id} was recovered`);
}
}
Trigger: Only when calling repository.recover() or manager.recover().
Does NOT trigger on manual UPDATE that sets deleted_at = NULL.
🔌 Query Hooks (beforeQuery, afterQuery)
@EventSubscriber()
export class QueryLogger implements EntitySubscriberInterface {
beforeQuery(event: QueryEvent) {
console.log(`🔍 Executing: ${event.query}`, event.parameters);
}
afterQuery(event: QueryEvent) {
console.log(`✅ Completed in ${event.executionTime}ms`);
}
}
Trigger: Any query executed through TypeORM's DataSource, EntityManager, Repository, or QueryRunner.
🔄 Transaction Hooks (beforeTransactionStart, afterTransactionCommit, etc.)
@EventSubscriber()
export class AuditSubscriber implements EntitySubscriberInterface {
beforeTransactionStart(event: TransactionStartEvent) {
console.log(`🔒 TX started on ${event.queryRunner.databaseConnection}`);
}
afterTransactionCommit(event: TransactionCommitEvent) {
// Flush audit queue, clear caches, emit domain events
}
}
Trigger: Only when transactions are explicitly managed by TypeORM:
// ✅ Fires hooks
await dataSource.manager.transaction(async (manager) => { ... });
const qr = dataSource.createQueryRunner();
await qr.startTransaction();
// ❌ Will NOT fire TypeORM transaction hooks
await dataSource.query("BEGIN;"); // Raw SQL
4. Technical Terminology: Methods, Hooks, Decorators
🎯 Precise Definitions
| Term | What It Is | Example |
|---|---|---|
| Method | A function attached to a class (language construct) | updateCounters() { ... } |
| Hook / Callback | A method the framework recognizes and invokes automatically at predefined lifecycle points | afterLoad(), @BeforeInsert-decorated method |
| Decorator | TypeScript metadata attachment that registers a method as a hook | @AfterLoad(), @EventSubscriber() |
| Lifecycle Hook | Architectural pattern: framework-driven event callback | Collective term for all TypeORM hooks |
📐 Correct Naming for Documentation
// ❌ Vague
"the afterLoad method"
// ✅ Precise
"the afterLoad subscriber lifecycle hook"
"the @AfterLoad entity listener decorator"
"the updateCounters entity listener hook"
// ✅ Collective term
"TypeORM lifecycle hooks" or "ORM event callbacks"
💡 Rule of Thumb: All hooks are methods, but not all methods are hooks. Use "hook" when discussing framework-driven, event-bound execution.
5. The listenTo() Method & Scoping Strategies
🔍 Why listenTo() Exists
| Reason | Explanation |
|---|---|
| Performance Filtering | TypeORM calls listenTo() once at startup. Without it, every subscriber would run on every operation. |
| Targeted Execution | Ensures hooks only run for relevant entities. |
| Type Safety | Combined with EntitySubscriberInterface<User>, enables proper TypeScript inference for event.entity. |
📦 Common Patterns
Pattern 1: Specific Entity (Recommended)
@EventSubscriber()
export class UserSubscriber implements EntitySubscriberInterface<User> {
listenTo() { return User; } // 🔍 Scoped to User only
beforeInsert(event: InsertEvent<User>) {
// event.entity is typed as User ✅
}
}
Pattern 2: Global Listener (Object)
@EventSubscriber()
export class GlobalAuditSubscriber implements EntitySubscriberInterface {
listenTo() { return Object; } // 👈 Listens to EVERY entity
afterInsert(event: InsertEvent<any>) {
// Manual filtering required
if (!(event.entity instanceof User || event.entity instanceof Post)) return;
// event.entity is any ❌
}
}
Pattern 3: Separate Subscribers + Shared Service (Best Practice)
// Shared logic
export class EntityAuditService {
static logCreation(entity: any, metadata: EntityMetadata) {
console.log(`✅ ${metadata.name} created:`, entity.id);
}
}
@EventSubscriber()
export class UserSubscriber implements EntitySubscriberInterface<User> {
listenTo() { return User; }
afterInsert(event: InsertEvent<User>) {
EntityAuditService.logCreation(event.entity, event.metadata);
}
}
@EventSubscriber()
export class PostSubscriber implements EntitySubscriberInterface<Post> {
listenTo() { return Post; }
afterInsert(event: InsertEvent<Post>) {
EntityAuditService.logCreation(event.entity, event.metadata);
}
}
⚠️ Critical Caveats
- Cannot return an array:
listenTo()accepts only a single constructor orObject. - Performance:
return Objectruns hooks for every TypeORM operation, including relations, cascades, and migrations. - Type Safety: Global subscribers lose strict typing (
event.entity: any).
6. Event Objects: Structure & Usage
🔍 What Is the Event Object?
A framework-generated context payload automatically injected into subscriber hooks. It carries everything needed to inspect, modify, or react to the operation.
📦 Core Properties (Persistence Events)
| Property | Type | Purpose | Available In |
|---|---|---|---|
entity | T | The entity instance after your changes. Modifying it affects what gets persisted. | All persistence events |
databaseEntity | T | undefined | The current DB state before changes. Used for diffing. | UpdateEvent, RemoveEvent |
metadata | EntityMetadata | TypeORM's internal schema info (table name, columns, relations) | All events |
manager | EntityManager | DB operations scoped to the current transaction/connection | All persistence events |
queryRunner | QueryRunner | Low-level query execution & explicit transaction control | All persistence & query events |
connection | DataSource | The active TypeORM connection instance | All events |
💡 Concrete Examples
UpdateEvent<T> (The only event with databaseEntity)
beforeUpdate(event: UpdateEvent<User>) {
console.log(event.entity.role); // ✅ Changed to
console.log(event.databaseEntity?.role); // ✅ Was before
console.log(event.updatedColumns); // ✅ ['role']
// ⚠️ entity is mutable, databaseEntity is read-only
event.entity.role = 'admin'; // This change WILL be persisted
}
LoadEvent<T> (Read-only context)
afterLoad(event: LoadEvent<User>) {
console.log(event.entity.fullName); // ✅ Hydrated instance
// ❌ No databaseEntity, no manager save context
}
🆚 Event Objects vs Entity Listeners (this)
| Context | How You Access Data |
|---|---|
| Subscriber Hook | event.entity, event.databaseEntity, event.manager |
| Entity Listener | this (the entity instance only). No event object, no transaction context. |
// Subscriber
afterUpdate(event: UpdateEvent<User>) {
const diff = event.entity.status !== event.databaseEntity?.status;
}
// Listener
@AfterUpdate()
logChange() {
// ❌ Cannot access databaseEntity or manager here
console.log(this.status);
}
7. The update() Gotcha: Partial Entities & Primary Keys (With Real-World Example)
⚠️ This section addresses a critical production issue that trips up many developers. If your subscriber logic isn't firing as expected, read this first.
🔍 The Core Problem: update() vs save()
| Method | What It Does | What event.entity Contains |
|---|---|---|
repository.update(id, partial) | Direct UPDATE SQL without fetching | ❌ Only the partial object you passed. No primary key, no relations, no other columns. |
repository.save(entity) | Fetch → Merge → Persist → Re-fetch | ✅ The complete hydrated entity, including PK, relations, all columns. |
🧪 Real-World Example: Why Your Subscriber Isn't Working
Your Code
// Service layer
await queryRunner.manager.update(
ContactView,
{ contact_view_id: contactViewId },
{ sharing_status: sharingStatus, permission: resolvedPermission }
);
// Subscriber
@EventSubscriber()
export class ContactViewSubscriber implements EntitySubscriberInterface {
async afterUpdate(event: UpdateEvent<any>): Promise<void> {
// ❌ This check FAILS: partial object is NOT an instance of ContactView
if (!(event.entity instanceof ContactView)) return;
const { entity } = event;
if (entity.sharing_status !== ContactViewSharingStatus.EVERYONE) return;
// ❌ These are undefined because they weren't in the update partial
const actorUserId = entity.updated_by ?? entity.created_by;
// ... notification logic never runs
}
}
What Actually Happens
// What afterUpdate receives:
afterUpdate(event: UpdateEvent<any>) {
console.log(event.entity);
// Output: { sharing_status: "EVERYONE", permission: "READ" }
// ❌ No contact_view_id, updated_by, created_by, or other columns
console.log(event.entity instanceof ContactView); // false ❌
console.log(event.entity.updated_by); // undefined ❌
console.log(event.entity.contact_view_id); // undefined ❌
}
✅ Four Solutions (Ranked by Best Practice)
🔹 Solution 1: Include Required Fields in the Update Partial (Quick Fix)
Best for: Keeping update() performance while making minimal changes.
// Service: Include PK and actor fields in the partial
await queryRunner.manager.update(
ContactView,
{ contact_view_id: contactViewId },
{
contact_view_id: contactViewId, // 👈 Include PK (ignored in SQL WHERE, but passed to hooks)
updated_by: currentUserId, // 👈 Include actor info needed by subscriber
created_by: originalCreatorId, // 👈 If needed for fallback
sharing_status: sharingStatus,
permission: resolvedPermission
}
);
// Subscriber: Replace instanceof with metadata check
async afterUpdate(event: UpdateEvent<any>): Promise<void> {
// ✅ Use metadata to check entity type (works with partials)
if (event.metadata.target !== ContactView) return;
const entity = event.entity;
// ✅ Now these fields exist because we passed them
if (entity.sharing_status !== ContactViewSharingStatus.EVERYONE) return;
const actorUserId = entity.updated_by ?? entity.created_by;
const contactViewId = entity.contact_view_id; // ✅ Now available
await this.notificationTask.notifyOnPermissionChange(
{ ...entity, contact_view_id: contactViewId }, // Reconstruct minimal context
[],
actorUserId
).catch((err) =>
writeLogError({
...getExceptionEntries(err),
action: 'ContactViewSubscriber.afterUpdate.notify',
})
);
}
⚠️ Caveat: This is a "pragmatic hack". TypeORM ignores contact_view_id in the SQL WHERE clause (it's already there from the first argument), but it does include it in the partial object passed to subscribers.
🔹 Solution 2: Use save() Instead (Recommended for Full Context)
Best for: When you need the complete entity state, relations, or type safety.
// Service: Load, modify, then save
const contactView = await queryRunner.manager.findOne(ContactView, {
where: { contact_view_id: contactViewId }
});
if (!contactView) throw new Error('ContactView not found');
// Modify the hydrated entity
contactView.sharing_status = sharingStatus;
contactView.permission = resolvedPermission;
contactView.updated_by = currentUserId;
await queryRunner.manager.save(contactView); // 👈 Full lifecycle
// Subscriber: Now receives a fully hydrated entity
afterUpdate(event: UpdateEvent<ContactView>) {
// ✅ instanceof works because it's a real ContactView instance
if (!(event.entity instanceof ContactView)) return;
// ✅ All columns are available
const actorUserId = event.entity.updated_by ?? event.entity.created_by;
// ✅ Relations available if you loaded them
// ✅ Type-safe: event.entity is typed as ContactView
}
| Pros | Cons |
|---|---|
| ✅ Type-safe, full context | ❌ Extra SELECT query + re-fetch overhead |
✅ instanceof checks work | ❌ Slightly slower for simple updates |
| ✅ Relations available if requested | ❌ May load more data than needed |
🔹 Solution 3: Use beforeUpdate + databaseEntity (Best for Audit/Diffing)
Best for: Comparing old vs. new values or accessing pre-change state without extra queries.
async beforeUpdate(event: UpdateEvent<any>): Promise<void> {
if (event.metadata.target !== ContactView) return;
// ✅ databaseEntity has the full pre-update row from DB
const oldEntity = event.databaseEntity;
const newPartial = event.entity; // Your update partial
// ✅ Safe to access PK and all original columns
if (oldEntity?.sharing_status !== ContactViewSharingStatus.EVERYONE) return;
const actorUserId = oldEntity.updated_by ?? oldEntity.created_by;
const contactViewId = oldEntity.contact_view_id;
// ⚠️ Note: Changes aren't committed yet at this point
// Consider deferring notification to afterUpdate if you need post-commit guarantees
await this.notificationTask.notifyOnPermissionChange(
{ ...oldEntity, ...newPartial }, // Merge for context
[],
actorUserId
);
}
| Pros | Cons |
|---|---|
| ✅ No extra queries | ❌ Runs before commit; side-effects execute even if TX rolls back |
| ✅ Access to full pre-change state | ❌ Cannot see final merged state (only old + partial) |
| ✅ Transaction-safe for reads | ❌ Must handle rollback scenarios carefully |
🔹 Solution 4: Hybrid Approach (Fetch Only What You Need)
Best for: Keeping update() performance but needing specific fields only when certain conditions are met.
async afterUpdate(event: UpdateEvent<any>): Promise<void> {
if (event.metadata.target !== ContactView) return;
const partial = event.entity;
// Quick filter using only the partial data
if (partial.sharing_status !== ContactViewSharingStatus.EVERYONE) return;
// ✅ Use event.manager to fetch full entity WITHIN SAME TRANSACTION
// 👈 You MUST pass contact_view_id in the update() partial for this to work
const fullEntity = await event.manager.findOne(ContactView, {
where: { contact_view_id: partial.contact_view_id }
});
if (!fullEntity) return;
const actorUserId = fullEntity.updated_by ?? fullEntity.created_by;
await this.notificationTask.notifyOnPermissionChange(fullEntity, [], actorUserId);
}
| Pros | Cons |
|---|---|
✅ Keeps update() performance for non-matching cases | ❌ Extra query when condition matches (but same transaction) |
| ✅ Still gets full context when needed | ❌ Must ensure contact_view_id is in the update partial |
| ✅ Conditional fetch minimizes overhead | ❌ Slightly more complex logic |
📋 Decision Matrix: Which Solution for Your Use Case?
| Requirement | Best Approach | Why |
|---|---|---|
Keep update() performance + need PK in subscriber | ✅ Solution 1 | Minimal change, no extra queries |
| Need full entity + relations in subscriber | ✅ Solution 2 | Type-safe, complete context |
| Only need to compare old vs. new values | ✅ Solution 3 | Access to databaseEntity without extra fetch |
| Need full context but want to avoid extra SELECT | ✅ Solution 4 | Conditional fetch within same transaction |
| Audit trail must survive direct SQL updates | ⚠️ Add DB triggers | TypeORM hooks won't fire for raw SQL |
⚠️ Critical Reminder: Async Side-Effects in Subscribers
Your notification logic runs asynchronously. Understand the transaction implications:
// Pattern A: Fire-and-forget (generally safe for notifications)
afterUpdate(event: UpdateEvent<any>) {
this.notificationTask.notifyOnPermissionChange(...).catch((err) =>
writeLogError({ action: 'notify', ...getExceptionEntries(err) })
);
// ✅ Returns immediately; transaction can commit
// ⚠️ Notification may fail after TX commits (no rollback)
}
// Pattern B: Await the side-effect (transaction-safe but slower)
async afterUpdate(event: UpdateEvent<any>) {
await this.notificationTask.notifyOnPermissionChange(...);
// ✅ Notification succeeds or fails WITH the transaction
// ❌ Holds transaction open; can cause lock contention
}
// Pattern C: Emit to queue (recommended for production)
async afterUpdate(event: UpdateEvent<any>) {
await event.manager.save(new DomainEvent({
type: 'CONTACT_VIEW_PERMISSION_CHANGED',
payload: { contactViewId: event.entity.contact_view_id }
}));
// ✅ Transaction commits with event record
// ✅ Queue worker handles notification asynchronously
// ✅ Decouples concerns; easier to retry/failover
}
🛠️ Immediate Fix Checklist
Apply these changes to make your current code work with update():
-
Replace
instanceofwith metadata checktypescript// ❌ Won't work with update() if (!(event.entity instanceof ContactView)) return; // ✅ Works with partials if (event.metadata.target !== ContactView) return; -
Include required fields in your
update()partialtypescriptawait queryRunner.manager.update( ContactView, { contact_view_id: contactViewId }, { contact_view_id: contactViewId, // 👈 Critical for subscriber updated_by: currentUserId, // 👈 Critical for actor tracking sharing_status: sharingStatus, permission: resolvedPermission } ); -
Test that subscriber receives expected data
typescriptafterUpdate(event: UpdateEvent<any>) { console.assert(event.entity.contact_view_id !== undefined, 'PK missing'); console.assert(event.entity.updated_by !== undefined, 'Actor missing'); console.assert(event.entity.sharing_status !== undefined, 'Status missing'); } -
Consider if
beforeUpdate+databaseEntitybetter fits audit needs- Do you need to compare old vs. new values?
- Do you need the full pre-change state?
- Can side-effects safely run before commit?
-
Evaluate async notification strategy
- Is eventual consistency acceptable? → Fire-and-forget
- Must notification succeed with transaction? → Await or use queue
- High volume? → Emit to queue for background processing
💡 Pro Tips for Production
-
Log subscriber execution for debugging
typescriptafterUpdate(event: UpdateEvent<any>) { console.debug('ContactViewSubscriber.afterUpdate', { target: event.metadata.target, entityKeys: Object.keys(event.entity), hasPk: 'contact_view_id' in event.entity }); } -
Create a helper for metadata-based type checking
typescriptfunction isEntity<T>(event: UpdateEvent<any>, target: EntityTarget<T>): event is UpdateEvent<T> { return event.metadata.target === target; } // Usage if (!isEntity(event, ContactView)) return; // event.entity is now typed as ContactView ✅ -
Use TypeScript utility types for partial entities
typescripttype UpdatePartial<T> = Partial<Pick<T, 'sharing_status' | 'permission'>> & { contact_view_id: string; // Always include PK updated_by?: string; // Optional actor info }; // Enforce at call site const partial: UpdatePartial<ContactView> = { contact_view_id: contactViewId, updated_by: currentUserId, sharing_status: sharingStatus, permission: resolvedPermission }; -
Write integration tests for subscriber behavior
typescriptdescribe('ContactViewSubscriber', () => { it('notifies when sharing_status changes to EVERYONE via update()', async () => { const spy = jest.spyOn(notificationTask, 'notifyOnPermissionChange'); await queryRunner.manager.update(ContactView, { contact_view_id: 'test-id' }, { contact_view_id: 'test-id', sharing_status: 'EVERYONE' } ); expect(spy).toHaveBeenCalledWith( expect.objectContaining({ contact_view_id: 'test-id' }), [], expect.any(String) ); }); });
🎯 Key Takeaway:
update()is a performance optimization that bypasses entity hydration. When using it with subscribers, you must explicitly pass any data your hooks need and replaceinstanceofchecks withevent.metadata.targetcomparisons. For complex logic, prefersave()or the hybrid fetch pattern.
8. Transaction Safety: event.manager vs dataSource.manager
🚫 The Problem: Creating New Connections Breaks Transactions
// ❌ WRONG - Creates a NEW, unrelated connection
afterInsert(event: InsertEvent<User>) {
const newManager = dataSource.manager; // 👈 New EntityManager, new connection!
await newManager.save(new AuditLog({ userId: event.entity.id }));
// This save runs OUTSIDE the current transaction!
}
If the main User insert fails and rolls back, the AuditLog insert still commits. Data inconsistency!
✅ The Solution: Use the Event's Transaction-Aware Context
// ✅ CORRECT - Uses the SAME transaction/connection
afterInsert(event: InsertEvent<User>) {
await event.manager.save(new AuditLog({ userId: event.entity.id }));
// OR for raw queries:
await event.queryRunner.query(
"INSERT INTO audit_log (user_id) VALUES ($1)",
[event.entity.id]
);
}
🔗 Why This Works (Internal Wiring)
- When you start a transaction via
dataSource.manager.transaction(), TypeORM creates aQueryRunnerwith an active DB transaction. - This
QueryRunneris passed down through the entire operation pipeline. event.managerandevent.queryRunnerare bound to that sameQueryRunner.- Any operation using them automatically participates in the same transaction.
📋 Quick Reference: When to Use What
| Scenario | Use | Why |
|---|---|---|
| Read/write entities in subscriber | event.manager | Type-safe, transaction-aware, auto-relation handling |
| Run raw SQL in subscriber | event.queryRunner.query() | Direct control, still transaction-bound |
| Need to check if entity existed before update | event.databaseEntity | Pre-change DB snapshot |
Need primary keys with update() | Pass id in partial OR use save() | update() doesn't hydrate entities |
| Logging/metrics (no DB write) | Any method | No transaction risk |
9. Query Methods & Hook Trigger Matrix
🎯 Short Answer Table
| Query Method | Persistence Hooks@BeforeInsert, @AfterUpdate, etc. | Hydration Hook@AfterLoad | Query/TX HooksbeforeQuery, beforeTransactionStart |
|---|---|---|---|
repository.save() | ✅ Yes | ✅ Yes (on re-fetch) | ✅ Yes |
repository.insert() | ✅ Yes (minimal) | ❌ No | ✅ Yes |
repository.update() | ✅ Yes (partial entity) | ❌ No | ✅ Yes |
repository.delete() / remove() | ✅ Yes | ❌ No | ✅ Yes |
repository.recover() | ✅ Yes | ❌ No | ✅ Yes |
repository.find() / findOne() | ❌ N/A | ✅ Yes | ✅ Yes |
QueryBuilder.getMany() / getOne() | ❌ N/A | ✅ Yes | ✅ Yes |
QueryBuilder.getRawMany() | ❌ No entity → No hooks | ❌ No | ✅ Yes |
QueryBuilder.execute() | ❌ Raw → No hooks | ❌ No | ✅ Yes |
repository.query() / dataSource.query() | ❌ No entity hooks | ❌ No | ✅ Yes |
| Direct DB client (psql, DBeaver, etc.) | ❌ No | ❌ No | ❌ No |
🔍 Detailed Breakdown by Method
✅ Methods That Trigger Persistence Hooks
These methods go through TypeORM's SubjectExecutor, which manages entity state and fires lifecycle hooks.
// All of these trigger persistence hooks:
await repo.save(entity); // Full lifecycle: load → merge → save → re-fetch
await repo.insert(partial); // Direct INSERT, minimal hydration
await repo.update(id, partial); // Direct UPDATE, partial entity in hooks
await repo.remove(entity); // Full entity → DELETE
await repo.delete(id); // Direct DELETE by ID
await repo.recover(entity); // Soft-delete recovery
await repo.softRemove(entity); // Soft-delete (triggers remove hooks)
✅ Methods That Trigger @AfterLoad (Hydration Hook)
These methods map database rows → entity instances, triggering the hydration pipeline.
// Repository API
await repo.find();
await repo.findOneBy({ id: 1 });
await repo.findBy({ status: 'active' });
// QueryBuilder (entity-mapped results)
await repo.createQueryBuilder('u')
.leftJoinAndSelect('u.profile', 'p')
.getMany(); // ✅ afterLoad fires for each User
// EntityManager (same as Repository)
await manager.find(User, { where: { role: 'admin' } });
❌ Does NOT trigger @AfterLoad:
// Raw results (plain objects, no entity instantiation)
await qb.getRawMany();
await qb.getRawOne();
await repo.query('SELECT * FROM users'); // Raw SQL
❌ Raw SQL: repository.query() / dataSource.query()
const result = await dataSource.query(
'UPDATE users SET last_login = NOW() WHERE id = $1',
[123]
);
| Hook Type | Fires? | Why |
|---|---|---|
@BeforeUpdate / afterUpdate | ❌ No | Bypasses SubjectExecutor; no entity hydration or persistence tracking |
@AfterLoad | ❌ No | Returns raw rows (any[]), not entity instances |
beforeQuery / afterQuery | ✅ Yes | Operates at QueryRunner level, which all TypeORM queries pass through |
| Transaction hooks | ✅ Yes (if inside manager.transaction()) | Transaction context is managed by QueryRunner |
💡 Key Insight:
beforeQuery/afterQueryare the only hooks that fire for raw SQL, because they intercept at the driver layer, not the entity layer.
10. Practical Patterns: Multiple Entities & Shared Logic
🎯 Problem: listenTo() Only Accepts One Entity
TypeORM does not support listenTo() { return [User, Post]; }. Here are the 3 standard patterns to handle multiple entities:
✅ Pattern 1: Separate Subscribers + Shared Service (Recommended)
// Shared logic
export class EntityAuditService {
static logCreation(entity: any, metadata: EntityMetadata) {
console.log(`✅ ${metadata.name} created:`, entity.id);
}
}
@EventSubscriber()
export class UserSubscriber implements EntitySubscriberInterface<User> {
listenTo() { return User; }
afterInsert(event: InsertEvent<User>) {
EntityAuditService.logCreation(event.entity, event.metadata);
}
}
@EventSubscriber()
export class PostSubscriber implements EntitySubscriberInterface<Post> {
listenTo() { return Post; }
afterInsert(event: InsertEvent<Post>) {
EntityAuditService.logCreation(event.entity, event.metadata);
}
}
✅ Pros: Type-safe, performant, follows Single Responsibility Principle
❌ Cons: Slightly more boilerplate
🌐 Pattern 2: Global Subscriber (Object) + Manual Filtering
@EventSubscriber()
export class MultiEntitySubscriber implements EntitySubscriberInterface {
listenTo() { return Object; } // 👈 Listens to EVERY entity
afterInsert(event: InsertEvent<any>) {
const e = event.entity;
if (e instanceof User || e instanceof Post) {
console.log(`📝 Inserted: ${e.constructor.name} #${e.id}`);
}
}
}
✅ Pros: Single class, easy to maintain for simple cross-entity logic
❌ Cons:
- Hooks fire for every entity operation (including relations, junction tables, migrations)
- Manual
instanceofchecks add runtime overhead - Loses strict TypeScript typing (
event.entitybecomesany)
🧩 Pattern 3: Abstract Base Subscriber (TypeScript Inheritance)
abstract class BaseAuditSubscriber<T> implements EntitySubscriberInterface<T> {
abstract listenTo(): Function;
afterInsert(event: InsertEvent<T>) {
console.log(`📦 Inserted: ${event.entity.constructor.name}`, event.entity);
}
}
@EventSubscriber()
export class UserAuditSubscriber extends BaseAuditSubscriber<User> {
listenTo() { return User; }
}
@EventSubscriber()
export class PostAuditSubscriber extends BaseAuditSubscriber<Post> {
listenTo() { return Post; }
}
✅ Pros: Reuses code, keeps type safety, avoids instanceof checks
❌ Cons: Slightly more complex TypeScript setup; doesn't reduce subscriber count
📊 Decision Matrix
| Scenario | Best Pattern |
|---|---|
| Different logic per entity | ✅ Pattern 1 (Separate + Shared Service) |
| Identical lightweight logic across many entities | ✅ Pattern 3 (Abstract Base) |
| Quick prototype / global audit / tenant filtering | ⚠️ Pattern 2 (Object + filter) |
| High-traffic app, performance-sensitive | ✅ Pattern 1 or 3 |
| Need to handle 2-3 specific entities only | ✅ Pattern 1 or 3 |
11. Best Practices & Anti-Patterns
✅ Do This
- Prefer Subscribers for cross-cutting concerns: Keep entities as pure data models.
- Use
event.manager/event.queryRunnerfor DB operations: Ensures transaction safety. - Keep hooks lightweight: Avoid heavy async operations or external API calls.
- Use
beforeUpdate+databaseEntityfor diffing: Compare old vs. new values safely. - Explicitly declare
listenTo() { return Object; }: Makes global subscription intent clear. - Test subscribers in isolation: Mock the event object for unit tests.
❌ Avoid This
- Don't rely on subscribers for security or data integrity: Use DB-level constraints/triggers for that.
- Don't use
instanceofin subscribers withupdate(): Useevent.metadata.targetinstead. - Don't create new connections in hooks: Always use
event.managerorevent.queryRunner. - Don't block transactions with long-running async work: Use queues for emails, webhooks, etc.
- Don't use global subscribers without profiling: They run on every TypeORM operation.
⚠️ Critical Gotchas
| Gotcha | Solution |
|---|---|
update() → partial event.entity | Include PK in partial or use save() |
insert() doesn't re-fetch → no @AfterLoad | Use beforeInsert to mutate event.entity |
| Raw SQL bypasses all entity logic | Use database triggers for audit trails that must survive direct SQL |
beforeQuery/afterQuery fire for ALL queries | Keep them fast; consider sampling for high-traffic apps |
| Transaction hooks require explicit transactions | Use manager.transaction() or queryRunner.startTransaction() |
12. Common Doubts Clarified (FAQ)
❓ "If I run SELECT * FROM users directly in the database, will subscriber methods be called?"
No. Absolutely not.
- TypeORM's subscriber system lives in the Node.js application layer.
- It only wraps queries that pass through TypeORM's
DataSource→QueryRunner→Driver. - Running SQL in
psql, DBeaver, or any external client connects directly to the DB engine. TypeORM has zero visibility.
💡 Exception: Even if you use TypeORM's
dataSource.query('SELECT * FROM users'),beforeQuery/afterQuerywill fire, but entity-level hooks (afterLoad,beforeRecover, etc.) will not, because raw queries don't go through TypeORM's entity hydration pipeline.
❓ "Are listeners/subscribers methods or hooks?"
They are methods that function as lifecycle hooks (event callbacks).
Methoddescribes what they are in code (a function attached to a class).Hook/Callbackdescribes how the framework uses them (automatically invoked at specific lifecycle phases).
✅ All hooks are methods, but not all methods are hooks.
❓ "Why does event.entity instanceof ContactView return false with update()?"
Because update() doesn't hydrate entities. event.entity is a plain object containing only the partial data you passed, not an instance of the entity class.
Solution: Use event.metadata.target === ContactView instead of instanceof.
❓ "Can I use await in subscriber hooks?"
Yes, but be careful:
// ✅ This works, but holds the transaction open
async afterInsert(event: InsertEvent<User>) {
await this.sendWelcomeEmail(event.entity.email); // ⚠️ Transaction waits
}
// ✅ Better: emit to queue for async processing
async afterInsert(event: InsertEvent<User>) {
await this.queue.emit('user.created', { userId: event.entity.id });
}
❓ "Do I need to register subscribers in TypeORM 0.3+?"
Yes. Subscribers are not auto-discovered in TypeORM 0.3+. You must explicitly add them to subscribers: [...] in your DataSource configuration.
const AppDataSource = new DataSource({
// ... other config
subscribers: [UserSubscriber, PostSubscriber],
});
❓ "What's the difference between event.entity and event.databaseEntity?"
| Property | Meaning | When Available |
|---|---|---|
event.entity | The entity instance after your changes. Modifying it affects what gets persisted. | All persistence events |
event.databaseEntity | The current DB state before changes. Used for diffing. Read-only. | UpdateEvent, RemoveEvent |
beforeUpdate(event: UpdateEvent<User>) {
// Compare old vs. new
if (event.databaseEntity?.role !== event.entity.role) {
console.log(`Role changed from ${event.databaseEntity.role} to ${event.entity.role}`);
}
}
13. Quick Reference Cheat Sheet
🎯 Hook Trigger Summary
📋 Decision Flowchart: Which Query Method to Use?
🔑 Key Takeaways
- Use
save()when you need full entity context in subscribers; useupdate()for performance but design subscribers to handle partial entities. - Always use
event.manager/event.queryRunnerfor any DB operation inside a subscriber to ensure transaction safety. - Replace
instanceofchecks withevent.metadata.targetwhen usingupdate()or global subscribers. beforeQuery/afterQueryare the only hooks that fire for raw SQL; entity hooks require TypeORM's entity pipeline.- Keep hooks lightweight and avoid external API calls; use queues for async side-effects.
- Explicitly declare
listenTo()to make subscriber scope clear and avoid accidental global execution.
🚀 Final Thought: TypeORM's lifecycle hooks are powerful but come with architectural trade-offs. Understand the execution layers, choose the right query method for your use case, and always design for transaction safety. When in doubt, prefer explicitness over convenience—your future self (and your production database) will thank you.
Happy coding! 🎯