js

Build Type-Safe Event-Driven Architecture: TypeScript, EventEmitter3, and Redis Pub/Sub Guide

Master TypeScript Event-Driven Architecture with Redis Pub/Sub. Learn type-safe event systems, distributed scaling, CQRS patterns & production best practices.

Build Type-Safe Event-Driven Architecture: TypeScript, EventEmitter3, and Redis Pub/Sub Guide

I’ve been thinking about building more resilient systems lately. Why? Because in my last project, a tightly coupled monolith caused cascading failures that took hours to resolve. That frustration sparked my journey into event-driven systems. Today I’ll share how to build a type-safe event architecture using TypeScript, EventEmitter3, and Redis Pub/Sub. Stick with me – this approach could prevent those late-night fire drills for you too.

First, let’s establish our foundation. We need a project structure that scales. Here’s what works well:

npm install eventemitter3 redis ioredis zod
npm install -D typescript @types/node

Our TypeScript config ensures strict type safety:

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "moduleResolution": "node"
  }
}

Now, the core question: how do we make events both type-safe and extensible? The answer lies in our base event structure:

// events/base/Event.ts
import { v4 as uuidv4 } from 'uuid';

export abstract class Event {
  public readonly id: string;
  public readonly timestamp: Date = new Date();

  constructor(
    public readonly type: string,
    public readonly payload: unknown
  ) {
    this.id = uuidv4();
  }
}

This abstract class gives us consistent event metadata. But how do we enforce payload shapes? That’s where Zod schemas come in:

// events/user/UserEvents.ts
import { z } from 'zod';

export const UserCreatedSchema = z.object({
  userId: z.string().uuid(),
  email: z.string().email(),
  name: z.string()
});

export class UserCreatedEvent extends Event {
  constructor(payload: z.infer<typeof UserCreatedSchema>) {
    super('UserCreated', UserCreatedSchema.parse(payload));
  }
}

Now, what about handling these events? We need a bus that understands our types. Here’s a local implementation using EventEmitter3:

// events/local/LocalEventBus.ts
import EventEmitter from 'eventemitter3';
import { Event } from '../base/Event';

type EventHandler<T extends Event> = (event: T) => void;

export class LocalEventBus {
  private emitter = new EventEmitter();

  publish<T extends Event>(event: T): void {
    this.emitter.emit(event.type, event);
  }

  subscribe<T extends Event>(
    eventType: string,
    handler: EventHandler<T>
  ): void {
    this.emitter.on(eventType, handler);
  }
}

But what happens when we scale beyond a single process? That’s where Redis Pub/Sub enters the picture. Notice how we maintain type safety even across network boundaries:

// events/distributed/RedisEventBus.ts
import { Redis } from 'ioredis';
import { Event } from '../base/Event';

export class RedisEventBus {
  private publisher: Redis;
  private subscriber: Redis;

  constructor() {
    this.publisher = new Redis();
    this.subscriber = new Redis();
  }

  async publish(event: Event): Promise<void> {
    await this.publisher.publish(
      event.type,
      JSON.stringify(event)
    );
  }

  subscribe<T extends Event>(
    eventType: string,
    handler: (event: T) => void
  ): void {
    this.subscriber.subscribe(eventType);
    this.subscriber.on('message', (channel, message) => {
      if (channel === eventType) {
        handler(JSON.parse(message) as T);
      }
    });
  }
}

Now, let’s address error handling – a critical but often overlooked aspect. How do we ensure failed events don’t disappear into the void? We implement a dead-letter queue pattern:

// events/handlers/ErrorHandler.ts
import { EventBus } from '../base/EventBus';

export class ErrorHandler {
  constructor(
    private mainBus: EventBus,
    private dlqBus: EventBus
  ) {}

  async handleEvent<T extends Event>(
    event: T,
    handler: (e: T) => Promise<void>
  ): Promise<void> {
    try {
      await handler(event);
    } catch (error) {
      this.dlqBus.publish({
        ...event,
        error: error.message
      });
    }
  }
}

For production environments, monitoring is non-negotiable. Here’s how we track event flow:

// utils/metrics.ts
import { Event } from '../events/base/Event';

export class EventMetrics {
  static trackPublished(event: Event): void {
    console.log(`[Published] ${event.type} @ ${event.timestamp}`);
  }

  static trackProcessed(event: Event, handler: string): void {
    console.log(`[Processed] ${handler} for ${event.id}`);
  }

  static trackFailed(event: Event, error: Error): void {
    console.error(`[Failed] ${event.type}: ${error.message}`);
  }
}

A common pitfall? Underestimating event ordering needs. If you need strict ordering, consider Redis Streams instead of Pub/Sub. But for most cases, the simpler Pub/Sub works beautifully.

What about testing our events? We use Zod’s built-in validation:

// tests/userEvents.test.ts
import { UserCreatedEvent, UserCreatedSchema } from '../events/user/UserEvents';

test('rejects invalid user data', () => {
  const invalidPayload = { email: 'not-an-email' };
  expect(() => UserCreatedSchema.parse(invalidPayload)).toThrow();
});

As we wrap up, remember this: type safety isn’t just about preventing bugs. It’s about creating systems that communicate clearly with future developers. When your events carry their own documentation through types, onboarding new team members becomes effortless.

If this approach resonates with you, give it a try in your next project. Have questions about specific implementation details? Share them in the comments – I’d love to hear what challenges you’re facing with event-driven systems. Found this useful? Pass it along to others who might benefit!

Keywords: event-driven architecture TypeScript, TypeScript EventEmitter3 tutorial, Redis Pub/Sub Node.js, type-safe event system, distributed event handling, event sourcing CQRS patterns, microservices event communication, Node.js event-driven design, TypeScript Redis integration, scalable event architecture



Similar Posts
Blog Image
Build Multi-Tenant SaaS with NestJS, Prisma, PostgreSQL RLS: Complete Tutorial

Learn to build scalable multi-tenant SaaS apps with NestJS, Prisma, and PostgreSQL RLS. Covers tenant isolation, dynamic schemas, and security best practices.

Blog Image
Build High-Performance GraphQL API with NestJS, Prisma & Redis: Complete Guide

Learn to build a high-performance GraphQL API with NestJS, Prisma ORM, and Redis caching. Master DataLoader, authentication, and optimization techniques.

Blog Image
Build Event-Driven Microservices with Fastify, Redis Streams, and TypeScript: Complete Production Guide

Learn to build scalable event-driven microservices with Fastify, Redis Streams & TypeScript. Covers consumer groups, error handling & production monitoring.

Blog Image
Build High-Performance GraphQL APIs with NestJS, Prisma, and DataLoader: Complete Tutorial

Learn to build scalable GraphQL APIs with NestJS, Prisma & DataLoader. Master authentication, query optimization, real-time subscriptions & production best practices.

Blog Image
Build Type-Safe Event-Driven Architecture: TypeScript, RabbitMQ & Domain Events Tutorial

Learn to build scalable, type-safe event-driven architecture using TypeScript, RabbitMQ & domain events. Master CQRS, event sourcing & reliable messaging patterns.

Blog Image
Build Real-time Web Apps: Complete Svelte and Supabase Integration Guide for Modern Developers

Learn to integrate Svelte with Supabase for building high-performance real-time web applications. Discover seamless data sync, authentication, and reactive UI updates.