Event-Driven Architecture — Node.js and TypeScript Patterns 2026
Advertisement
Introduction
Why This Matters
Event-driven architecture (EDA) decouples producers from consumers — a service emits an event and never needs to know which services react to it. This enables independent scaling, loose coupling, and temporal decoupling (consumers can process events when they are available, not only when the producer is running).
Node.js's asynchronous event loop makes it a natural fit for EDA. From the built-in EventEmitter for in-process eventing to Kafka for distributed, durable event streams, the same mental model scales across every level.
In-Process Events with TypeScript EventEmitter
The EventEmitter class is Node.js's primitive for in-process pub/sub. TypeScript makes it type-safe with a typed event map:
import EventEmitter from 'events';
// Define the event map for type safety
interface AppEvents {
'user:created': { userId: string; email: string; name: string };
'user:deleted': { userId: string };
'order:placed': { orderId: string; userId: string; total: number };
'payment:failed': { orderId: string; reason: string };
}
// Typed EventEmitter wrapper
class TypedEventBus extends EventEmitter {
emit<K extends keyof AppEvents>(event: K, payload: AppEvents[K]): boolean {
return super.emit(event as string, payload);
}
on<K extends keyof AppEvents>(
event: K,
listener: (payload: AppEvents[K]) => void
): this {
return super.on(event as string, listener);
}
once<K extends keyof AppEvents>(
event: K,
listener: (payload: AppEvents[K]) => void
): this {
return super.once(event as string, listener);
}
}
export const eventBus = new TypedEventBus();
eventBus.setMaxListeners(50); // Increase from default 10 for large appsUsage across your application:
// In user service
eventBus.emit('user:created', {
userId: 'usr_123',
email: 'alice@example.com',
name: 'Alice',
});
// In email service (listener registered at startup)
eventBus.on('user:created', async (user) => {
await emailService.sendWelcome(user.email, user.name);
});
// In analytics service
eventBus.on('user:created', async (user) => {
await analytics.track('user_created', { userId: user.userId });
});In-process events are synchronous and in-memory only — they do not survive process restarts and cannot cross service boundaries.
Distributed Pub/Sub with RabbitMQ
For cross-service eventing, RabbitMQ provides durable, reliable message delivery with flexible routing via exchanges.
import amqp, { Connection, Channel } from 'amqplib';
interface EventEnvelope<T> {
eventType: string;
payload: T;
timestamp: string;
correlationId: string;
version: number;
}
class RabbitMQEventBus {
private connection!: Connection;
private channel!: Channel;
private readonly exchangeName = 'app.events';
async connect(url: string): Promise<void> {
this.connection = await amqp.connect(url);
this.channel = await this.connection.createChannel();
await this.channel.assertExchange(this.exchangeName, 'topic', {
durable: true,
});
}
async publish<T>(eventType: string, payload: T, correlationId: string): Promise<void> {
const envelope: EventEnvelope<T> = {
eventType,
payload,
timestamp: new Date().toISOString(),
correlationId,
version: 1,
};
this.channel.publish(
this.exchangeName,
eventType, // Routing key: "user.created", "order.placed"
Buffer.from(JSON.stringify(envelope)),
{ persistent: true, contentType: 'application/json' }
);
}
async subscribe<T>(
eventType: string,
handler: (envelope: EventEnvelope<T>) => Promise<void>
): Promise<void> {
const queueName = `${process.env.SERVICE_NAME}.${eventType}`;
await this.channel.assertQueue(queueName, { durable: true });
await this.channel.bindQueue(queueName, this.exchangeName, eventType);
this.channel.prefetch(10); // Process 10 messages concurrently per consumer
this.channel.consume(queueName, async (msg) => {
if (!msg) return;
try {
const envelope = JSON.parse(msg.content.toString()) as EventEnvelope<T>;
await handler(envelope);
this.channel.ack(msg); // Acknowledge only after successful processing
} catch (err) {
console.error('Event processing failed:', err);
// Reject and requeue once; dead-letter after second failure
this.channel.nack(msg, false, !msg.fields.redelivered);
}
});
}
}High-Throughput Streaming with Kafka
Kafka is appropriate when event volume exceeds what a traditional message broker can handle, or when event log retention and replay are required.
import { Kafka, Producer, Consumer, EachMessagePayload } from 'kafkajs';
const kafka = new Kafka({
clientId: process.env.SERVICE_NAME ?? 'my-service',
brokers: (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(','),
});
class KafkaEventBus {
private producer!: Producer;
private consumer!: Consumer;
async connect(groupId: string): Promise<void> {
this.producer = kafka.producer({ idempotent: true });
this.consumer = kafka.consumer({ groupId });
await this.producer.connect();
await this.consumer.connect();
}
async publish(topic: string, key: string, payload: object): Promise<void> {
await this.producer.send({
topic,
messages: [
{
key,
value: JSON.stringify(payload),
headers: { timestamp: Date.now().toString() },
},
],
});
}
async subscribe(
topics: string[],
handler: (topic: string, key: string, value: object) => Promise<void>
): Promise<void> {
await this.consumer.subscribe({ topics, fromBeginning: false });
await this.consumer.run({
eachMessage: async ({ topic, message }: EachMessagePayload) => {
const key = message.key?.toString() ?? '';
const value = JSON.parse(message.value?.toString() ?? '{}');
await handler(topic, key, value);
},
});
}
async disconnect(): Promise<void> {
await this.producer.disconnect();
await this.consumer.disconnect();
}
}Kafka consumers in the same groupId share partition consumption — enabling parallel processing with guaranteed ordering per partition key.
Event Schema Design and Versioning
Events are the API of event-driven systems. Breaking changes to event schemas are as disruptive as breaking REST API changes.
// Version your events explicitly
interface UserCreatedV1 {
version: 1;
userId: string;
email: string;
}
interface UserCreatedV2 {
version: 2;
userId: string;
email: string;
name: string; // Added in v2
plan: 'free' | 'pro' | 'enterprise'; // Added in v2
}
type UserCreatedEvent = UserCreatedV1 | UserCreatedV2;
function handleUserCreated(event: UserCreatedEvent): void {
switch (event.version) {
case 1:
// Handle legacy v1 format
console.log(`User ${event.userId} created (legacy)`);
break;
case 2:
console.log(`User ${event.userId} (${event.plan}) created`);
break;
}
}Follow the Postel's Law principle for events: be conservative in what you emit, liberal in what you accept. Consumers should ignore unknown fields rather than failing.
Common Mistakes
- Not acknowledging messages after processing — with RabbitMQ, unacknowledged messages are redelivered indefinitely. Always ack after successful handling and nack on failure.
- Emitting events inside database transactions — if the transaction rolls back after the event is emitted, consumers process an event for a state that no longer exists. Emit events after the transaction commits, or use the outbox pattern.
- No dead-letter queue — messages that repeatedly fail processing accumulate and block the queue. Configure a dead-letter exchange for poison messages.
- Tight event coupling — events should contain only the data consumers need, not internal domain objects. Fat events that expose internal state create implicit coupling.
- Missing correlation IDs — without a correlation ID in every event, tracing a workflow across multiple services is impossible.
Best Practices
- Use the outbox pattern: write events to a database table in the same transaction as domain changes, then publish them asynchronously.
- Set
prefetch(RabbitMQ) ormaxInFlightRequests(Kafka) to control consumer concurrency and prevent overwhelm. - Validate event schemas on both publish and consume sides using Zod or JSON Schema.
- Use topic naming conventions:
<service>.<entity>.<action>(e.g.,user.account.created). - Implement idempotent consumers — re-processing an event must produce the same result as processing it once.
- Monitor queue depth, consumer lag, and processing error rate as primary operational metrics for event-driven systems.
Key Takeaways
- Event-driven architecture decouples producers from consumers, enabling independent scaling and deployment of services.
- Node.js
EventEmitterprovides typed in-process pub/sub; RabbitMQ and Kafka provide durable cross-service messaging. - RabbitMQ topic exchanges route events via wildcard routing keys; consumers bind named queues to exchanges for durable subscription.
- Kafka consumer groups enable parallel processing with per-partition ordering guarantees.
- Event schema versioning is required from day one — adding a
versionfield and using discriminated unions in TypeScript enables backward-compatible evolution. - The outbox pattern (write events to a DB table in the same transaction, publish asynchronously) prevents the dual-write problem between database and message broker.
- Idempotent consumers — those that produce the same result when processing an event multiple times — are essential for reliable event-driven systems.
- Dead-letter queues and consumer lag monitoring are mandatory production infrastructure for event-driven systems.
Advertisement