🚨 ¡Nueva review! 🔇 Los mejores cascos con ANC del mercado: los Sony WH-1000XM4 . ¡Échale un ojo! 👀

Events y Event-Driven Architecture en NestJS

Serie NestJS #17 : @nestjs/event-emitter, @OnEvent, eventos tipados, pub/sub interno, desacoplamiento de módulos y patrones async

Escrito por domin el 15 de abril de 2026

Hoy venimos con el post número 17 de la serie NestJS y primero del bloque de Eventos, Colas y Tiempo Real. Los cuatro bloques anteriores nos dieron la base completa: estructura (posts 1-5), base de datos (posts 6-9), seguridad (posts 10-12) y funcionalidades avanzadas (posts 13-16). Ahora entramos en comunicación asíncrona.

Imagina que un usuario se registra. ¿Qué pasa después? Envías un email de bienvenida, creas un registro de auditoría, notificas al equipo de soporte, actualizas métricas… Si metes todo eso en AuthService.register(), el service se convierte en un monstruo que sabe demasiado y hace demasiadas cosas y encima el usuario tiene que esperar a que TODO termine antes de recibir su respuesta.

La solución para evitar todo este percal fragil es implementar eventos. El AuthService emite un evento user.registered y se olvida de todo. Otros módulos escuchan ese evento y reaccionan de forma independiente, así conseguiremos un desacoplamiento total del flujo.

EA, amo al lío.

Diagrama de eventos fluyendo entre módulos desacoplados en NestJS con flechas de pub/sub.

1. El problema: el service monolítico

Mira como es este AuthService antes de implementar eventos:

// ❌ AuthService que sabe demasiado y hace demasiadas cosas
@Injectable()
export class AuthService {
    constructor(
        private readonly usersRepository: Repository<User>,
        private readonly mailService: MailService, // Dependencia directa
        private readonly auditService: AuditService, // Dependencia directa
        private readonly notificationService: NotificationService, // Dependencia directa
        private readonly analyticsService: AnalyticsService // Dependencia directa
    ) {}

    async register(dto: RegisterDto): Promise<AuthTokens> {
        const user = await this.createUser(dto);
        const tokens = await this.generateTokens(user);

        // Todo esto bloquea la respuesta al usuario
        await this.mailService.sendWelcome(user.email); // 2 segundos
        await this.auditService.log('user.registered', user); // 500ms
        await this.notificationService.notifyTeam(user); // 1 segundo
        await this.analyticsService.trackSignup(user); // 300ms

        // El usuario espera 3.8 segundos extra por cosas que no le importan
        return tokens;
    }
}

Vamos a ver qué problemitas tiene:

  1. Acoplamiento: AuthService importa 4 módulos que no tienen nada que ver con autenticación.
  2. Latencia: el usuario espera 3.8 segundos extra por tareas secundarias.
  3. Fragilidad: si el mail server se cae, el registro falla aunque el usuario se creó correctamente.
  4. Mantenimiento: cada nueva acción post-registro requiere modificar AuthService.

2. Event-Driven Architecture: la solución

cambia el flujo:

ANTES (acoplado):
  AuthService MailService
  AuthService AuditService
  AuthService NotificationService
  AuthService AnalyticsService

DESPUÉS (eventos):
  AuthService emit('user.registered')  EventBus

                              MailListener ←──┤
                              AuditListener ←─┤
                              NotifListener ←─┤
                              AnalyticsListener ←─┘

El AuthService emite un evento y se olvida de todo porque ya se cansó de vivir y ni sabe ni le importa quién escucha. Los listeners se registran por su cuenta en sus propios módulos.

🔗 Sin eventos (acoplado)

AuthService importa y llama directamente a 4 servicios. Si añades una acción nueva, modificas AuthService. Si un servicio falla, el registro falla.

📡 Con eventos (desacoplado)

AuthService emite un evento y ya. Los listeners se registran en sus propios módulos. Añadir una acción nueva = crear un listener nuevo. Sin tocar AuthService.


3. Instalación de @nestjs/event-emitter

Instalación 0 / 1
$
Pulsa para ejecutar el siguiente comando

Internamente usa eventemitter2 , una versión potenciada del EventEmitter nativo de Node.js.


4. Configuración del módulo

// src/app.module.ts
import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';

@Module({
    imports: [
        EventEmitterModule.forRoot({
            // Wildcards: poder escuchar 'user.*' para todos los eventos de user
            wildcard: true,
            // Separador de namespaces
            delimiter: '.',
            // Máximo de listeners por evento (previene memory leaks)
            maxListeners: 20,
            // Los errores en listeners no crashean la app
            ignoreErrors: false,
        }),
        // ... otros módulos
    ],
})
export class AppModule {}

Las opciones más importantes:


5. Eventos tipados: la clase del evento

Antes de emitir y escuchar, define qué datos lleva cada evento. Usa clases, no interfaces, para tener runtime type checking:

// src/auth/events/user-registered.event.ts
export class UserRegisteredEvent {
    constructor(
        public readonly userId: string,
        public readonly email: string,
        public readonly name: string,
        public readonly registeredAt: Date
    ) {}
}
// src/users/events/user-updated.event.ts
import { type UpdateUserDto } from '../dto/update-user.dto';

export class UserUpdatedEvent {
    constructor(
        public readonly userId: string,
        public readonly changes: Partial<UpdateUserDto>,
        public readonly updatedBy: string,
        public readonly updatedAt: Date
    ) {}
}
// src/users/events/user-deleted.event.ts
export class UserDeletedEvent {
    constructor(
        public readonly userId: string,
        public readonly deletedBy: string,
        public readonly deletedAt: Date
    ) {}
}

¿Por qué clases y no interfaces?


6. Constantes de nombres de eventos

Para evitar magic strings dispersos por el código lo mejor es hacerte un enum para declararlo todo y luego llamarlo:

// src/common/events/event-names.ts
export const EventNames = {
    USER_REGISTERED: 'user.registered',
    USER_UPDATED: 'user.updated',
    USER_DELETED: 'user.deleted',
    USER_LOGIN: 'user.login',
    USER_LOGOUT: 'user.logout',
    POST_CREATED: 'post.created',
    POST_PUBLISHED: 'post.published',
    POST_DELETED: 'post.deleted',
} as const;

export type EventName = (typeof EventNames)[keyof typeof EventNames];

as const hace que TypeScript trate los valores como literales ('user.registered'), no como string genérico. Y el tipo EventName es la unión de todos los valores posibles.


7. Emitiendo eventos con EventEmitter2

// src/auth/auth.service.ts
import { Injectable, ConflictException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from '../users/entities/user.entity';
import { type RegisterDto } from './dto/register.dto';
import { type AuthTokens } from './interfaces/auth-tokens.interface';
import { UserRegisteredEvent } from './events/user-registered.event';
import { EventNames } from '../common/events/event-names';

@Injectable()
export class AuthService {
    constructor(
        @InjectRepository(User)
        private readonly usersRepository: Repository<User>,
        private readonly jwtService: JwtService,
        private readonly configService: ConfigService,
        private readonly eventEmitter: EventEmitter2 // ← Inyectar
    ) {}

    async register(registerDto: RegisterDto): Promise<AuthTokens> {
        const emailTaken = await this.usersRepository.exists({
            where: { email: registerDto.email },
        });

        if (emailTaken) {
            throw new ConflictException('El email ya está registrado');
        }

        const hashedPassword = await bcrypt.hash(registerDto.password, 10);
        const user = this.usersRepository.create({
            ...registerDto,
            password: hashedPassword,
        });
        const savedUser = await this.usersRepository.save(user);
        const tokens = await this.generateTokens(savedUser);
        await this.updateRefreshToken(savedUser.id, tokens.refreshToken);

        // Emitir evento DESPUÉS de que todo el registro esté completo
        this.eventEmitter.emit(
            EventNames.USER_REGISTERED,
            new UserRegisteredEvent(savedUser.id, savedUser.email, savedUser.name, new Date())
        );

        return tokens;
    }
}

Vamos a ver que ha cambiado:

  1. Inyectamos EventEmitter2 : No un service concreto, sino el event emitter genérico.
  2. this.eventEmitter.emit() : Emite el evento con nombre y payload tipado.
  3. Sin await en el emit : El emit es síncrono (dispara y olvida). Los listeners pueden ser async pero el service no espera a que terminen.
  4. Cero imports de MailService, AuditService, etc. : AuthService ya no sabe nada de esos módulos.

8. Escuchando eventos con @OnEvent

Los listeners se definen en sus propios módulos con el decorador @OnEvent:

8.1. Listener de email de bienvenida

// src/mail/listeners/user-registered.listener.ts
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MailService } from '../mail.service';
import { UserRegisteredEvent } from '../../auth/events/user-registered.event';
import { EventNames } from '../../common/events/event-names';

@Injectable()
export class UserRegisteredMailListener {
    private readonly logger = new Logger(UserRegisteredMailListener.name);

    constructor(private readonly mailService: MailService) {}

    @OnEvent(EventNames.USER_REGISTERED)
    async handleUserRegistered(event: UserRegisteredEvent): Promise<void> {
        this.logger.log(`Enviando email de bienvenida a ${event.email}`);

        try {
            await this.mailService.sendWelcomeEmail(event.email, event.name);
        } catch (error) {
            // El error NO afecta al registro del usuario
            this.logger.error(`Error enviando email de bienvenida a ${event.email}: ${(error as Error).message}`);
        }
    }
}

8.2. Listener de auditoría

// src/audit/listeners/user-events.listener.ts
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { AuditService } from '../audit.service';
import { UserRegisteredEvent } from '../../auth/events/user-registered.event';
import { UserDeletedEvent } from '../../users/events/user-deleted.event';
import { EventNames } from '../../common/events/event-names';

@Injectable()
export class UserEventsAuditListener {
    private readonly logger = new Logger(UserEventsAuditListener.name);

    constructor(private readonly auditService: AuditService) {}

    @OnEvent(EventNames.USER_REGISTERED)
    async handleUserRegistered(event: UserRegisteredEvent): Promise<void> {
        await this.auditService.log({
            action: 'user.registered',
            userId: event.userId,
            metadata: { email: event.email },
            timestamp: event.registeredAt,
        });
    }

    @OnEvent(EventNames.USER_DELETED)
    async handleUserDeleted(event: UserDeletedEvent): Promise<void> {
        await this.auditService.log({
            action: 'user.deleted',
            userId: event.userId,
            metadata: { deletedBy: event.deletedBy },
            timestamp: event.deletedAt,
        });
    }
}

8.3. Registrar los listeners en sus módulos

// src/mail/mail.module.ts
import { Module } from '@nestjs/common';
import { MailService } from './mail.service';
import { UserRegisteredMailListener } from './listeners/user-registered.listener';

@Module({
    providers: [MailService, UserRegisteredMailListener],
    exports: [MailService],
})
export class MailModule {}
// src/audit/audit.module.ts
import { Module } from '@nestjs/common';
import { AuditService } from './audit.service';
import { UserEventsAuditListener } from './listeners/user-events.listener';

@Module({
    providers: [AuditService, UserEventsAuditListener],
    exports: [AuditService],
})
export class AuditModule {}

Cada listener vive en su módulo. AuthModule no importa MailModule ni AuditModule, solo emite eventos. Los listeners se registran automáticamente cuando sus módulos se importan en AppModule.


9. Opciones de @OnEvent

@OnEvent(EventNames.USER_REGISTERED, {
    // Si es true, se ejecuta DESPUÉS de todos los listeners síncronos
    async: true,

    // Si es true, los errores en este listener se propagan al emisor
    // Si es false (defecto), los errores se manejan internamente
    promisify: false,

    // Prioridad: menor número = se ejecuta primero
    // Útil si un listener debe ejecutarse antes que otro
    prependListener: false,
})
async handleUserRegistered(event: UserRegisteredEvent): Promise<void> {
    // ...
}
OpciónDefaultQué hace
asyncfalse

Si es true, el listener no bloquea al emisor. Se ejecuta en background

promisifyfalse

Si es true y el emisor usa emitAsync(), el emisor espera a que el listener termine

prependListenerfalse

Si es true, se ejecuta antes que los otros listeners del mismo evento


10. Wildcards: escuchar grupos de eventos

Con wildcard: true en la configuración, puedes escuchar grupos de eventos:

// Escucha TODOS los eventos que empiecen por 'user.'
@OnEvent('user.*')
async handleAllUserEvents(event: unknown): Promise<void> {
    this.logger.log(`Evento de usuario: ${JSON.stringify(event)}`);
}

// Escucha TODOS los eventos de la aplicación
@OnEvent('**')
async handleAllEvents(event: unknown): Promise<void> {
    this.metricsService.increment('events.total');
}

Casos de uso:

**Cuidado con ****: si tienes muchos eventos y el listener hace algo pesado, puede afectar al rendimiento. Úsalo solo para operaciones ligeras como incrementar contadores.


11. emitAsync: cuando necesitas esperar a los listeners

Por defecto, emit() es fire-and-forget. Si necesitas que el emisor espere a que los listeners terminen (por ejemplo, para validaciones pre-acción):

// Emitir y ESPERAR a que todos los listeners terminen
const results = await this.eventEmitter.emitAsync(
    EventNames.USER_REGISTERED,
    new UserRegisteredEvent(user.id, user.email, user.name, new Date())
);
// results es un array con los valores devueltos por cada listener
emit() : Fire & Forget
  • Tareas secundarias que no afectan al flujo principal: emails, auditoría, notificaciones, analytics
  • No importa si un listener falla: el usuario ya tiene su respuesta
  • El 90% de los casos. Desacoplamiento real
emitAsync() : Esperar listeners
  • Necesitas validar algo ANTES de continuar: "¿puede este usuario crear más de 5 posts?"
  • Los listeners generan datos que necesitas en la respuesta
  • Cuidado: si un listener falla con emitAsync, el error se propaga al emisor

12. Eventos en el UsersService

No solo en el registro se deben implementar eventos si no en cualquier acción importante debería emitir un evento:

// src/users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { type CreateUserDto } from './dto/create-user.dto';
import { type UpdateUserDto } from './dto/update-user.dto';
import { UserUpdatedEvent } from './events/user-updated.event';
import { UserDeletedEvent } from './events/user-deleted.event';
import { EventNames } from '../common/events/event-names';

@Injectable()
export class UsersService {
    constructor(
        @InjectRepository(User)
        private readonly usersRepository: Repository<User>,
        private readonly eventEmitter: EventEmitter2
    ) {}

    async update(id: string, updateUserDto: UpdateUserDto, updatedBy: string): Promise<User> {
        const user = await this.findOne(id);
        const merged = this.usersRepository.merge(user, updateUserDto);
        const saved = await this.usersRepository.save(merged);

        this.eventEmitter.emit(EventNames.USER_UPDATED, new UserUpdatedEvent(id, updateUserDto, updatedBy, new Date()));

        return saved;
    }

    async remove(id: string, deletedBy: string): Promise<void> {
        const user = await this.findOne(id);
        await this.usersRepository.softRemove(user);

        this.eventEmitter.emit(EventNames.USER_DELETED, new UserDeletedEvent(id, deletedBy, new Date()));
    }

    async findOne(id: string): Promise<User> {
        const user = await this.usersRepository.findOne({ where: { id } });
        if (!user) {
            throw new NotFoundException(`Usuario con ID "${id}" no encontrado`);
        }
        return user;
    }
}

El patrón siempre es el mismo:

  1. Ejecuta la operación de negocio (update, delete).
  2. Si fue bien, emite el evento.
  3. Los listeners reaccionan de forma independiente.

Emite DESPUÉS de la operación, no antes. Si emites antes y la operación falla, los listeners habrán reaccionado a algo que no pasó.


13. Patrones avanzados: Event Map tipado

Para proyectos grandes, un mapa que asocie cada nombre de evento con su tipo de payload:

// src/common/events/event-map.ts
import { UserRegisteredEvent } from '../../auth/events/user-registered.event';
import { UserUpdatedEvent } from '../../users/events/user-updated.event';
import { UserDeletedEvent } from '../../users/events/user-deleted.event';
import { EventNames } from './event-names';

export interface EventMap {
    [EventNames.USER_REGISTERED]: UserRegisteredEvent;
    [EventNames.USER_UPDATED]: UserUpdatedEvent;
    [EventNames.USER_DELETED]: UserDeletedEvent;
}

Y un wrapper tipado sobre el EventEmitter2:

// src/common/events/typed-event-emitter.service.ts
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { type EventMap } from './event-map';

@Injectable()
export class TypedEventEmitter {
    constructor(private readonly eventEmitter: EventEmitter2) {}

    emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): boolean {
        return this.eventEmitter.emit(event, payload);
    }

    async emitAsync<K extends keyof EventMap>(event: K, payload: EventMap[K]): Promise<unknown[]> {
        return this.eventEmitter.emitAsync(event, payload);
    }
}

Uso:

// ✅ TypeScript verifica que el payload coincide con el evento
this.typedEmitter.emit(EventNames.USER_REGISTERED, new UserRegisteredEvent(id, email, name, new Date()));

// ❌ TypeScript error: UserDeletedEvent no es compatible con UserRegisteredEvent
this.typedEmitter.emit(EventNames.USER_REGISTERED, new UserDeletedEvent(id, 'admin', new Date()));

Ahora es imposible emitir un evento con el payload equivocado porque TypeScript te avisa en la compilación.


14. Manejo de errores en listeners

Los listeners no deberían fallar silenciosamente, pero tampoco deberían tumbar tu aplicación:

@Injectable()
export class UserRegisteredMailListener {
    private readonly logger = new Logger(UserRegisteredMailListener.name);

    @OnEvent(EventNames.USER_REGISTERED)
    async handleUserRegistered(event: UserRegisteredEvent): Promise<void> {
        try {
            await this.mailService.sendWelcomeEmail(event.email, event.name);
            this.logger.log(`Email de bienvenida enviado a ${event.email}`);
        } catch (error) {
            // Loggear el error pero NO relanzarlo
            this.logger.error(`Error enviando email de bienvenida a ${event.email}`, (error as Error).stack);

            // Aquí podrías:
            // 1. Reintentar después
            // 2. Mover a una cola de reintentos (lo veremos en el post 18)
            // 3. Emitir un evento de fallo
            this.eventEmitter.emit('mail.failed', {
                type: 'welcome',
                email: event.email,
                error: (error as Error).message,
            });
        }
    }
}

Está bien tener como norma usar un try/catch en cada listener. El error del listener de emails no debería afectar al listener de auditoría.


15. Estructura de archivos con eventos

src/
├── common/
   └── events/
       ├── event-names.ts Constantes de nombres
       ├── event-map.ts Mapa evento tipo
       └── typed-event-emitter.service.ts
├── auth/
   ├── auth.service.ts Emite user.registered
   └── events/
       └── user-registered.event.ts
├── users/
   ├── users.service.ts Emite user.updated, user.deleted
   └── events/
       ├── user-updated.event.ts
       └── user-deleted.event.ts
├── mail/
   ├── mail.module.ts
   ├── mail.service.ts
   └── listeners/
       └── user-registered.listener.ts Escucha user.registered
├── audit/
   ├── audit.module.ts
   ├── audit.service.ts
   └── listeners/
       └── user-events.listener.ts Escucha user.*

Cada módulo tiene su carpeta events/ (para las clases de evento) y listeners/ (para los handlers). Los eventos están cerca de quien los emite y los listeners están cerca de quien reacciona.


16. Eventos vs llamadas directas: ¿cuándo usar cada uno?

Usa eventos
  • Tareas secundarias que no afectan al resultado principal: emails, notificaciones, auditoría, analytics
  • Múltiples módulos necesitan reaccionar a la misma acción
  • No importa si la tarea secundaria falla o tarda
  • Quieres poder añadir reacciones sin modificar el servicio emisor
Usa llamadas directas
  • La acción secundaria es PARTE del flujo principal: calcular un precio, validar stock
  • Necesitas el resultado de la acción secundaria para responder al cliente
  • Solo hay un consumidor y la relación es estable y directa
  • El orden de ejecución es crítico

17. Errores comunes

Error 1: No registrar el listener como provider

// ❌ El listener existe pero no está en providers → nunca se ejecuta
@Module({
    providers: [MailService], // Falta UserRegisteredMailListener
})
export class MailModule {}

// ✅ El listener debe estar en providers
@Module({
    providers: [MailService, UserRegisteredMailListener],
})
export class MailModule {}

@OnEvent solo funciona si la clase está registrada como provider en un módulo que esté importado en la app.

Error 2: Emitir antes de completar la operación

// ❌ Si save() falla, los listeners ya reaccionaron a algo que no pasó
this.eventEmitter.emit(EventNames.USER_REGISTERED, event);
await this.usersRepository.save(user); // Puede fallar

// ✅ Emitir DESPUÉS de que la operación sea exitosa
await this.usersRepository.save(user);
this.eventEmitter.emit(EventNames.USER_REGISTERED, event);

Error 3: Listener que relanza errores

// ❌ El error del email tumba al emisor si se usa con emitAsync
@OnEvent(EventNames.USER_REGISTERED)
async handle(event: UserRegisteredEvent): Promise<void> {
    await this.mailService.send(event.email); // Si falla, el error se propaga
}

// ✅ Try/catch en cada listener
@OnEvent(EventNames.USER_REGISTERED)
async handle(event: UserRegisteredEvent): Promise<void> {
    try {
        await this.mailService.send(event.email);
    } catch (error) {
        this.logger.error('Error enviando email', (error as Error).stack);
    }
}

Error 4: Magic strings en los nombres de eventos

// ❌ Si escribes mal el string, falla silenciosamente
this.eventEmitter.emit('user.registred', event); // Typo: 'registred'
// Nadie escucha 'user.registred', el listener espera 'user.registered'

// ✅ Constantes tipadas: TypeScript detecta el error
this.eventEmitter.emit(EventNames.USER_REGISTERED, event);

18. Recapitulando

📡 EventEmitter2

Se inyecta vía constructor. emit() para fire-and-forget. emitAsync() cuando necesitas esperar a los listeners.

👂 @OnEvent

Decorador que marca un método como listener de un evento. Opciones: async, promisify, prependListener.

🏷️ Eventos tipados

Clases con constructor readonly para cada evento. EventMap + TypedEventEmitter para tipado end-to-end.

🧩 Desacoplamiento

El emisor no sabe quién escucha. Los listeners se registran en sus propios módulos. Añadir reacciones sin tocar el emisor.

🌟 Wildcards

user.* escucha todos los eventos de user. ** escucha todo. Útil para métricas y auditoría global.

🛡️ Manejo de errores

Try/catch en CADA listener. Un listener fallido no debería afectar a los demás ni al emisor.

En el próximo post vamos a ver Queues y Jobs con Bull + Redis: tareas heavys en background, reintentos automáticos, colas de emails y notificaciones. Los eventos dicen “algo pasó”. Las colas dicen “haz esto, pero más tarde y con reintentos”.

EA, nos vemos en los bares!! 🍺


Pon a prueba lo aprendido

1. ¿Cuál es la principal ventaja de usar eventos en vez de llamadas directas entre servicios?

2. ¿Por qué se debe emitir el evento DESPUÉS de completar la operación de negocio?

3. ¿Cuál es la diferencia entre emit() y emitAsync()?

4. ¿Qué pasa si un listener decorado con @OnEvent no está registrado como provider en ningún módulo?

5. ¿Para qué sirve la opción wildcard: true en EventEmitterModule.forRoot()?