← Back to postsCoding Notes
EnglishPublished Apr 25, 2026Updated Apr 25, 202616 min read

TypeORM Entity Listeners & Subscribers: A Complete Guide

Tips

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

FeatureEntity ListenerEntity Subscriber
LocationInside the entity classSeparate class implementing EntitySubscriberInterface
RegistrationAutomatic via decorators (@BeforeInsert())Explicit: add to DataSource.subscribers[]
CouplingTightly coupled to entityDecoupled; follows separation of concerns
Context AccessOnly this (the entity)Rich event object (entity, databaseEntity, manager, queryRunner, metadata)
ScopeSingle entity onlyOne, multiple, or all entities via listenTo()
Best ForSimple, self-contained logic (hash passwords, set defaults)Audit trails, cross-entity validation, transactional side-effects

🧭 When to Choose Which

Diagram
Rendering diagram…

2. The Entity Hydration Pipeline

🔍 What Is "Hydration"?

Hydration is TypeORM's internal process of transforming raw database rows into fully-initialized entity instances.

Diagram
Rendering diagram…

🔄 Three Separate Execution Layers

TypeORM has three distinct pipelines—hooks only run in the layer they're designed for:

Diagram
Rendering diagram…
Hook CategoryPipelineTriggered When
@AfterLoad / afterLoadHydration (Read)Every time TypeORM maps DB rows → entities
@BeforeInsert, @AfterUpdate, etc.Persistence (Write)During save(), insert(), update(), remove()
beforeQuery / afterQueryDriver LayerRight before/after SQL is sent to the database
Transaction hooksDriver LayerWhen TypeORM explicitly manages transactions

⚠️ Key Insight: Only @AfterLoad runs 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.)

typescript
@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)

typescript
@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)

typescript
@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)

typescript
@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.)

typescript
@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:

typescript
// ✅ 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

TermWhat It IsExample
MethodA function attached to a class (language construct)updateCounters() { ... }
Hook / CallbackA method the framework recognizes and invokes automatically at predefined lifecycle pointsafterLoad(), @BeforeInsert-decorated method
DecoratorTypeScript metadata attachment that registers a method as a hook@AfterLoad(), @EventSubscriber()
Lifecycle HookArchitectural pattern: framework-driven event callbackCollective term for all TypeORM hooks

📐 Correct Naming for Documentation

typescript
// ❌ 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

Diagram
Rendering diagram…
ReasonExplanation
Performance FilteringTypeORM calls listenTo() once at startup. Without it, every subscriber would run on every operation.
Targeted ExecutionEnsures hooks only run for relevant entities.
Type SafetyCombined with EntitySubscriberInterface<User>, enables proper TypeScript inference for event.entity.

📦 Common Patterns

Pattern 1: Specific Entity (Recommended)

typescript
@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)

typescript
@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)

typescript
// 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

  1. Cannot return an array: listenTo() accepts only a single constructor or Object.
  2. Performance: return Object runs hooks for every TypeORM operation, including relations, cascades, and migrations.
  3. 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)

PropertyTypePurposeAvailable In
entityTThe entity instance after your changes. Modifying it affects what gets persisted.All persistence events
databaseEntityT | undefinedThe current DB state before changes. Used for diffing.UpdateEvent, RemoveEvent
metadataEntityMetadataTypeORM's internal schema info (table name, columns, relations)All events
managerEntityManagerDB operations scoped to the current transaction/connectionAll persistence events
queryRunnerQueryRunnerLow-level query execution & explicit transaction controlAll persistence & query events
connectionDataSourceThe active TypeORM connection instanceAll events

💡 Concrete Examples

UpdateEvent<T> (The only event with databaseEntity)

typescript
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)

typescript
afterLoad(event: LoadEvent<User>) {
  console.log(event.entity.fullName); // ✅ Hydrated instance
  // ❌ No databaseEntity, no manager save context
}

🆚 Event Objects vs Entity Listeners (this)

ContextHow You Access Data
Subscriber Hookevent.entity, event.databaseEntity, event.manager
Entity Listenerthis (the entity instance only). No event object, no transaction context.
typescript
// 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()

Diagram
Rendering diagram…
MethodWhat It DoesWhat 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

typescript
// 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

typescript
// 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.

typescript
// 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 
    }
);
typescript
// 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.

typescript
// 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
typescript
// 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
}
ProsCons
✅ 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.

typescript
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
    );
}
ProsCons
✅ 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.

typescript
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);
}
ProsCons
✅ 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?

Diagram
Rendering diagram…
RequirementBest ApproachWhy
Keep update() performance + need PK in subscriber✅ Solution 1Minimal change, no extra queries
Need full entity + relations in subscriber✅ Solution 2Type-safe, complete context
Only need to compare old vs. new values✅ Solution 3Access to databaseEntity without extra fetch
Need full context but want to avoid extra SELECT✅ Solution 4Conditional fetch within same transaction
Audit trail must survive direct SQL updates⚠️ Add DB triggersTypeORM hooks won't fire for raw SQL

⚠️ Critical Reminder: Async Side-Effects in Subscribers

Your notification logic runs asynchronously. Understand the transaction implications:

typescript
// 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 instanceof with metadata check

    typescript
    // ❌ 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() partial

    typescript
    await 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

    typescript
    afterUpdate(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 + databaseEntity better 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

  1. Log subscriber execution for debugging

    typescript
    afterUpdate(event: UpdateEvent<any>) {
        console.debug('ContactViewSubscriber.afterUpdate', {
            target: event.metadata.target,
            entityKeys: Object.keys(event.entity),
            hasPk: 'contact_view_id' in event.entity
        });
    }
    
  2. Create a helper for metadata-based type checking

    typescript
    function 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 ✅
    
  3. Use TypeScript utility types for partial entities

    typescript
    type 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
    };
    
  4. Write integration tests for subscriber behavior

    typescript
    describe('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 replace instanceof checks with event.metadata.target comparisons. For complex logic, prefer save() or the hybrid fetch pattern.


8. Transaction Safety: event.manager vs dataSource.manager

🚫 The Problem: Creating New Connections Breaks Transactions

typescript
// ❌ 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

typescript
// ✅ 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)

Diagram
Rendering diagram…
  1. When you start a transaction via dataSource.manager.transaction(), TypeORM creates a QueryRunner with an active DB transaction.
  2. This QueryRunner is passed down through the entire operation pipeline.
  3. event.manager and event.queryRunner are bound to that same QueryRunner.
  4. Any operation using them automatically participates in the same transaction.

📋 Quick Reference: When to Use What

ScenarioUseWhy
Read/write entities in subscriberevent.managerType-safe, transaction-aware, auto-relation handling
Run raw SQL in subscriberevent.queryRunner.query()Direct control, still transaction-bound
Need to check if entity existed before updateevent.databaseEntityPre-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 methodNo transaction risk

9. Query Methods & Hook Trigger Matrix

🎯 Short Answer Table

Query MethodPersistence Hooks
@BeforeInsert, @AfterUpdate, etc.
Hydration Hook
@AfterLoad
Query/TX Hooks
beforeQuery, 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.

typescript
// 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.

typescript
// 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:

typescript
// 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()

typescript
const result = await dataSource.query(
  'UPDATE users SET last_login = NOW() WHERE id = $1',
  [123]
);
Hook TypeFires?Why
@BeforeUpdate / afterUpdate❌ NoBypasses SubjectExecutor; no entity hydration or persistence tracking
@AfterLoad❌ NoReturns raw rows (any[]), not entity instances
beforeQuery / afterQueryYesOperates 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/afterQuery are 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)

typescript
// 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

typescript
@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 instanceof checks add runtime overhead
  • Loses strict TypeScript typing (event.entity becomes any)

🧩 Pattern 3: Abstract Base Subscriber (TypeScript Inheritance)

typescript
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

ScenarioBest 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

  1. Prefer Subscribers for cross-cutting concerns: Keep entities as pure data models.
  2. Use event.manager/event.queryRunner for DB operations: Ensures transaction safety.
  3. Keep hooks lightweight: Avoid heavy async operations or external API calls.
  4. Use beforeUpdate + databaseEntity for diffing: Compare old vs. new values safely.
  5. Explicitly declare listenTo() { return Object; }: Makes global subscription intent clear.
  6. Test subscribers in isolation: Mock the event object for unit tests.

❌ Avoid This

  1. Don't rely on subscribers for security or data integrity: Use DB-level constraints/triggers for that.
  2. Don't use instanceof in subscribers with update(): Use event.metadata.target instead.
  3. Don't create new connections in hooks: Always use event.manager or event.queryRunner.
  4. Don't block transactions with long-running async work: Use queues for emails, webhooks, etc.
  5. Don't use global subscribers without profiling: They run on every TypeORM operation.

⚠️ Critical Gotchas

GotchaSolution
update() → partial event.entityInclude PK in partial or use save()
insert() doesn't re-fetch → no @AfterLoadUse beforeInsert to mutate event.entity
Raw SQL bypasses all entity logicUse database triggers for audit trails that must survive direct SQL
beforeQuery/afterQuery fire for ALL queriesKeep them fast; consider sampling for high-traffic apps
Transaction hooks require explicit transactionsUse 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 DataSourceQueryRunnerDriver.
  • 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/afterQuery will 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).

  • Method describes what they are in code (a function attached to a class).
  • Hook / Callback describes 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:

typescript
// ✅ 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.

typescript
const AppDataSource = new DataSource({
  // ... other config
  subscribers: [UserSubscriber, PostSubscriber],
});

❓ "What's the difference between event.entity and event.databaseEntity?"

PropertyMeaningWhen Available
event.entityThe entity instance after your changes. Modifying it affects what gets persisted.All persistence events
event.databaseEntityThe current DB state before changes. Used for diffing. Read-only.UpdateEvent, RemoveEvent
typescript
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

Diagram
Rendering diagram…

📋 Decision Flowchart: Which Query Method to Use?

Diagram
Rendering diagram…

🔑 Key Takeaways

  1. Use save() when you need full entity context in subscribers; use update() for performance but design subscribers to handle partial entities.
  2. Always use event.manager/event.queryRunner for any DB operation inside a subscriber to ensure transaction safety.
  3. Replace instanceof checks with event.metadata.target when using update() or global subscribers.
  4. beforeQuery/afterQuery are the only hooks that fire for raw SQL; entity hooks require TypeORM's entity pipeline.
  5. Keep hooks lightweight and avoid external API calls; use queues for async side-effects.
  6. 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! 🎯