Types of Programming

Event-Driven Programming

Structuring flow around the production and consumption of events.

Overview

Event-Driven Programming (EDP) structures programs around the production, detection, and consumption of events. Components emit events without knowing who listens; consumers register handlers and react asynchronously. This decouples producers from consumers in time, space, and implementation. Node.js's EventEmitter, browser DOM events, and Kafka consumers all follow this model.

Origin

Emerged from GUI toolkits in the 1980s (X Window System event loop, Macintosh Event Manager). DOM events standardised the browser model. Node.js (Ryan Dahl, 2009) brought EDP to server-side I/O. Enterprise messaging systems (MOM: JMS in 1998, AMQP in 2006) extended it to distributed architectures.

Examples

Domain event bus in TypeScript

type Handler<T> = (event: T) => void | Promise<void>;

class EventBus {
  private handlers = new Map<string, Handler<unknown>[]>();

  on<T>(event: string, handler: Handler<T>): void {
    const existing = this.handlers.get(event) ?? [];
    this.handlers.set(event, [...existing, handler as Handler<unknown>]);
  }

  async emit<T>(event: string, payload: T): Promise<void> {
    const handlers = this.handlers.get(event) ?? [];
    await Promise.all(handlers.map(h => h(payload)));
  }
}

const bus = new EventBus();

bus.on<{ orderId: string; total: number }>('order.placed', async event => {
  await sendConfirmationEmail(event.orderId);
});

bus.on<{ orderId: string; total: number }>('order.placed', async event => {
  await updateInventory(event.orderId);
});

await bus.emit('order.placed', { orderId: 'ord-42', total: 99.99 });

Multiple handlers for one event run in parallel via Promise.all. The emitter has no reference to email or inventory logic, so adding a third handler (e.g. analytics) requires zero changes to existing code.

Node.js EventEmitter with typed events

import { EventEmitter } from 'events';

interface FileProcessorEvents {
  progress: [fileName: string, percent: number];
  done: [fileName: string, outputPath: string];
  error: [fileName: string, err: Error];
}

class FileProcessor extends EventEmitter {
  declare emit: <K extends keyof FileProcessorEvents>(
    event: K,
    ...args: FileProcessorEvents[K]
  ) => boolean;

  async process(filePath: string): Promise<void> {
    this.emit('progress', filePath, 0);
    const output = await compress(filePath, pct => {
      this.emit('progress', filePath, pct);
    });
    this.emit('done', filePath, output);
  }
}

const processor = new FileProcessor();
processor.on('progress', (file, pct) => console.log(file, pct + '%'));
processor.on('done', (file, out) => console.log('Wrote', out));
await processor.process('/tmp/video.mp4');

TypeScript 4.7 added typed EventEmitter via declare emit overloads. This catches mismatched event names and payload shapes at compile time without a third-party library.

Use Cases

  • 01Order processing systems where placing an order triggers independent downstream steps: email, inventory, analytics, fraud detection
  • 02GUI applications where user interactions (click, keydown, resize) drive state changes through handler chains
  • 03Microservice communication via Kafka or RabbitMQ where services publish domain events and consume independently
  • 04Webhook architectures where third-party systems push events to registered HTTP endpoints

When Not to Use

  • //Simple request-response flows where direct function calls are clearer and tracing is easier than following event chains
  • //When strong ordering guarantees are required and the event system does not provide them; asynchronous fan-out can interleave events
  • //Deeply nested event chains become difficult to debug; stack traces end at the event dispatch boundary, requiring distributed tracing (OpenTelemetry) to reconstruct causality

Technical Notes

  • The difference between message queues and event buses is durability: a message queue (Kafka, RabbitMQ) persists events so late-starting consumers can replay; an in-process EventEmitter fires and forgets
  • At-least-once vs exactly-once delivery semantics: Kafka guarantees at-least-once by default; exactly-once requires idempotent producers and transactional consumers, available since Kafka 0.11 (2017)
  • EventEmitter in Node.js uses a synchronous, sequential dispatch model by default; handlers run in registration order, so emit is synchronous unless handlers explicitly defer via setImmediate or process.nextTick
  • The "event storming" workshop technique (Alberto Brandolini, 2013) maps domain processes by identifying domain events first, making it a useful design tool before committing to an event-driven implementation