Loading content...
In event-driven systems, events represent facts that have occurred—something happened in the system that may be of interest to other components. But events alone are inert; they are merely notifications floating in the ether. The component that breathes life into these events, that takes action in response to their occurrence, is the event handler.
Understanding event handlers is fundamental to building event-driven systems. They are the reactive building blocks that translate events into behavior, transforming passive notifications into active system responses. Yet despite their central importance, event handlers are often treated as an afterthought—leading to brittle, untestable, and unmaintainable code.
By the end of this page, you will understand what event handlers are at a conceptual and implementation level. You'll learn how they differ from traditional request handlers, their core responsibilities, and the fundamental contract they establish with the event-driven system. This foundation is critical before exploring design principles, multiplicity, and ordering.
An event handler is a software component that subscribes to specific event types and executes logic when those events occur. The handler doesn't know or care who published the event—it only cares that the event happened and what information the event carries.
This is fundamentally different from traditional request-response programming:
This distinction creates a profound shift in how we design software. Handlers are not "called"—they are "triggered." They don't return values to a waiting caller—they perform side effects or publish new events.
An event handler establishes a contract: "When event X occurs, I will perform action Y." The handler promises to react to certain event types, but makes no promises about timing, order relative to other handlers, or mutual exclusion with concurrent handlers—unless the system explicitly provides such guarantees.
| Aspect | Request Handler | Event Handler |
|---|---|---|
| Invocation | Explicit call from client | Triggered by event occurrence |
| Caller awareness | Caller knows the handler exists | Publisher unaware of handlers |
| Return value | Expected and awaited | None (fire-and-forget) |
| Coupling | Tight coupling to caller | Loose coupling via events |
| Error handling | Exception propagates to caller | Handler manages errors locally |
| Concurrency | Caller controls timing | Multiple handlers may run concurrently |
| Testability | Test by invoking directly | Test by publishing events |
While implementations vary across languages and frameworks, every event handler shares a common structure. Understanding this anatomy helps you design handlers that are consistent, readable, and maintainable.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// Basic structure of an event handlerinterface Event { id: string; // Unique event identifier type: string; // Event type for routing timestamp: Date; // When the event occurred payload: unknown; // Event-specific data} // Handler interface - the contractinterface EventHandler<T extends Event> { // Which event type(s) this handler responds to readonly eventTypes: string[]; // The handle method - receives event, returns nothing handle(event: T): Promise<void>;} // Concrete handler implementationclass OrderPlacedHandler implements EventHandler<OrderPlacedEvent> { readonly eventTypes = ['OrderPlaced']; constructor( private readonly orderService: OrderService, private readonly notificationService: NotificationService, private readonly idempotencyStore: IdempotencyStore ) {} async handle(event: OrderPlacedEvent): Promise<void> { // 1. Idempotency check - have we processed this event before? if (await this.idempotencyStore.hasProcessed(event.id)) { console.log(`Event ${event.id} already processed, skipping`); return; } try { // 2. Core business logic await this.orderService.confirmOrder(event.payload.orderId); await this.notificationService.sendOrderConfirmation( event.payload.customerId, event.payload.orderId ); // 3. Mark as processed (idempotency) await this.idempotencyStore.markProcessed(event.id); } catch (error) { // 4. Error handling - handler must manage its own errors console.error(`Failed to handle OrderPlaced event: ${event.id}`, error); // Option A: Rethrow to trigger retry (if infrastructure supports it) throw error; // Option B: Send to dead-letter queue for manual review // await this.deadLetterQueue.send(event, error); // Option C: Log and continue (for non-critical handlers) // return; } }}Notice that event handlers return void (or Promise<void>). This is not an oversight—it's a fundamental characteristic. Since the publisher doesn't wait for handlers and doesn't receive their output, there's nothing meaningful to return. Handlers communicate through side effects: database writes, external API calls, or publishing new events.
A well-designed event handler has clear, bounded responsibilities. Understanding what a handler should and should not do is crucial for building maintainable event-driven systems.
The Single Responsibility Principle applies strongly to handlers. Each handler should do one thing well. If you find a handler doing multiple unrelated things—sending an email, updating inventory, and calculating loyalty points—consider splitting it into multiple focused handlers that each subscribe to the same event.
Beware the "fat handler" that grows to encompass all reactions to an event. This centralizes logic, makes testing difficult, and creates a single point of failure. If the email-sending code breaks, it shouldn't prevent inventory updates. Split handlers to achieve fault isolation.
Understanding the lifecycle of an event handler—from registration to execution to completion—helps you design handlers that work correctly within the event system's execution model.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
// Event bus with lifecycle hooksinterface EventBus { // Registration phase - called at startup register<T extends Event>(handler: EventHandler<T>): void; // Publishing phase - routes events to registered handlers publish(event: Event): Promise<void>;} class InMemoryEventBus implements EventBus { private handlers: Map<string, EventHandler<Event>[]> = new Map(); // 1. Registration phase register<T extends Event>(handler: EventHandler<T>): void { for (const eventType of handler.eventTypes) { const existing = this.handlers.get(eventType) || []; existing.push(handler as EventHandler<Event>); this.handlers.set(eventType, existing); console.log(`Registered handler for event type: ${eventType}`); } } // 2. Event dispatch - triggers handler lifecycle async publish(event: Event): Promise<void> { const handlers = this.handlers.get(event.type) || []; console.log(`Dispatching ${event.type} to ${handlers.length} handlers`); // Execute all handlers (could be parallel or sequential) const promises = handlers.map(async (handler) => { const handlerName = handler.constructor.name; try { console.log(`[${handlerName}] Executing...`); // 3. Handler execution await handler.handle(event); // 4. Successful completion console.log(`[${handlerName}] Completed successfully`); } catch (error) { // 5. Error handling console.error(`[${handlerName}] Failed: ${error}`); // In a real system, apply retry policy or dead-letter throw error; } }); // Wait for all handlers (this is a policy decision) await Promise.allSettled(promises); }} // Application startup - registration phasefunction bootstrap() { const eventBus = new InMemoryEventBus(); // Register all handlers eventBus.register(new OrderPlacedHandler(/* dependencies */)); eventBus.register(new InventoryReservationHandler(/* dependencies */)); eventBus.register(new NotificationHandler(/* dependencies */)); return eventBus;}Handler instances may be singletons (one instance handles all events), transient (new instance per event), or scoped (instance per request/batch). The choice affects how you manage state and dependencies. Singletons must be thread-safe; transient handlers can hold request-specific state.
Event handlers rarely operate in isolation. They typically need access to repositories, services, external clients, and cross-cutting concerns like logging. How you inject these dependencies significantly impacts testability, maintainability, and runtime behavior.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
// Well-structured handler with constructor injectionclass OrderFulfillmentHandler implements EventHandler<OrderPaidEvent> { readonly eventTypes = ['OrderPaid']; constructor( // Domain services private readonly orderRepository: OrderRepository, private readonly inventoryService: InventoryService, private readonly shippingService: ShippingService, // Infrastructure services private readonly eventBus: EventBus, private readonly logger: Logger, // Cross-cutting concerns private readonly metrics: MetricsClient, private readonly tracer: Tracer ) {} async handle(event: OrderPaidEvent): Promise<void> { // Create trace span for observability const span = this.tracer.startSpan('OrderFulfillmentHandler.handle'); span.setAttribute('event.id', event.id); span.setAttribute('order.id', event.payload.orderId); try { this.logger.info('Processing order fulfillment', { eventId: event.id, orderId: event.payload.orderId }); // Business logic using injected dependencies const order = await this.orderRepository.findById(event.payload.orderId); if (!order) { this.logger.warn('Order not found', { orderId: event.payload.orderId }); return; // Idempotent - order might have been deleted } // Reserve inventory await this.inventoryService.reserveItems(order.items); // Create shipping request const shipment = await this.shippingService.createShipment(order); // Update order status order.markAsFulfilling(shipment.trackingNumber); await this.orderRepository.save(order); // Publish downstream event await this.eventBus.publish({ id: generateId(), type: 'OrderFulfillmentStarted', timestamp: new Date(), payload: { orderId: order.id, shipmentId: shipment.id, trackingNumber: shipment.trackingNumber } }); // Record metrics this.metrics.increment('order.fulfillment.started'); this.metrics.timing('order.fulfillment.latency', Date.now() - event.timestamp.getTime()); span.setStatus({ code: SpanStatusCode.OK }); } catch (error) { span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message }); this.logger.error('Order fulfillment failed', { eventId: event.id, orderId: event.payload.orderId, error: error.message }); this.metrics.increment('order.fulfillment.failed'); throw error; } finally { span.end(); } }} // DI container registration (example with tsyringe)container.register('EventHandler', { useFactory: (c) => new OrderFulfillmentHandler( c.resolve(OrderRepository), c.resolve(InventoryService), c.resolve(ShippingService), c.resolve(EventBus), c.resolve(Logger), c.resolve(MetricsClient), c.resolve(Tracer) )});Prefer explicit constructor injection over service locators or global registries. Explicit dependencies make handlers testable (you can inject mocks/stubs), self-documenting (the constructor tells you what the handler needs), and maintainable (changes to dependencies are tracked at compile time).
Event handlers are inherently testable when designed correctly. The key is that handlers have a single entry point (the handle method), take a well-defined input (the event), and produce observable outputs (side effects). This makes test setup predictable and assertions straightforward.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
import { describe, it, expect, beforeEach, vi } from 'vitest'; describe('OrderFulfillmentHandler', () => { // Mock dependencies let mockOrderRepository: jest.Mocked<OrderRepository>; let mockInventoryService: jest.Mocked<InventoryService>; let mockShippingService: jest.Mocked<ShippingService>; let mockEventBus: jest.Mocked<EventBus>; let mockLogger: jest.Mocked<Logger>; let handler: OrderFulfillmentHandler; beforeEach(() => { // Create fresh mocks for each test mockOrderRepository = { findById: vi.fn(), save: vi.fn(), }; mockInventoryService = { reserveItems: vi.fn(), }; mockShippingService = { createShipment: vi.fn(), }; mockEventBus = { publish: vi.fn(), }; mockLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), }; // Inject mocks into handler handler = new OrderFulfillmentHandler( mockOrderRepository, mockInventoryService, mockShippingService, mockEventBus, mockLogger ); }); describe('handle', () => { it('should fulfill order and publish downstream event', async () => { // Arrange const event = createOrderPaidEvent({ orderId: 'order-123' }); const order = createOrder({ id: 'order-123', items: [{ sku: 'ABC', qty: 2 }] }); const shipment = { id: 'ship-456', trackingNumber: 'TRACK123' }; mockOrderRepository.findById.mockResolvedValue(order); mockShippingService.createShipment.mockResolvedValue(shipment); // Act await handler.handle(event); // Assert - verify all expected side effects expect(mockOrderRepository.findById).toHaveBeenCalledWith('order-123'); expect(mockInventoryService.reserveItems).toHaveBeenCalledWith(order.items); expect(mockShippingService.createShipment).toHaveBeenCalledWith(order); expect(mockOrderRepository.save).toHaveBeenCalled(); expect(mockEventBus.publish).toHaveBeenCalledWith( expect.objectContaining({ type: 'OrderFulfillmentStarted', payload: expect.objectContaining({ orderId: 'order-123', trackingNumber: 'TRACK123' }) }) ); }); it('should be idempotent when order not found', async () => { // Arrange const event = createOrderPaidEvent({ orderId: 'deleted-order' }); mockOrderRepository.findById.mockResolvedValue(null); // Act - should not throw await handler.handle(event); // Assert - no downstream actions taken expect(mockInventoryService.reserveItems).not.toHaveBeenCalled(); expect(mockShippingService.createShipment).not.toHaveBeenCalled(); expect(mockEventBus.publish).not.toHaveBeenCalled(); expect(mockLogger.warn).toHaveBeenCalledWith( 'Order not found', expect.objectContaining({ orderId: 'deleted-order' }) ); }); it('should propagate errors for retry', async () => { // Arrange const event = createOrderPaidEvent({ orderId: 'order-123' }); const order = createOrder({ id: 'order-123' }); const inventoryError = new Error('Insufficient inventory'); mockOrderRepository.findById.mockResolvedValue(order); mockInventoryService.reserveItems.mockRejectedValue(inventoryError); // Act & Assert await expect(handler.handle(event)).rejects.toThrow('Insufficient inventory'); // Verify error was logged expect(mockLogger.error).toHaveBeenCalledWith( 'Order fulfillment failed', expect.objectContaining({ error: 'Insufficient inventory' }) ); }); });}); // Helper factory for test eventsfunction createOrderPaidEvent(overrides = {}) { return { id: 'event-' + Math.random().toString(36).slice(2), type: 'OrderPaid', timestamp: new Date(), payload: { orderId: 'order-default', customerId: 'customer-default', amount: 99.99, ...overrides } };}In production systems, event handlers often follow recognizable patterns. Understanding these patterns helps you design handlers that are battle-tested and proven in real-world scenarios.
Purpose: Send notifications (email, SMS, push) in response to domain events.
Characteristics:
123456789101112131415161718192021222324252627282930313233343536373839
class OrderShippedNotificationHandler implements EventHandler<OrderShippedEvent> { readonly eventTypes = ['OrderShipped']; constructor( private readonly emailService: EmailService, private readonly templateEngine: TemplateEngine, private readonly customerRepo: CustomerRepository, private readonly idempotencyStore: IdempotencyStore ) {} async handle(event: OrderShippedEvent): Promise<void> { // Idempotency - don't send duplicate emails const idempotencyKey = `notification-order-shipped-${event.payload.orderId}`; if (await this.idempotencyStore.hasProcessed(idempotencyKey)) { return; } const customer = await this.customerRepo.findById(event.payload.customerId); if (!customer?.email) { return; // No email on file, skip gracefully } const emailContent = this.templateEngine.render('order-shipped', { customerName: customer.name, orderId: event.payload.orderId, trackingNumber: event.payload.trackingNumber, carrier: event.payload.carrier, estimatedDelivery: event.payload.estimatedDelivery }); await this.emailService.send({ to: customer.email, subject: `Your order #${event.payload.orderId} has shipped!`, html: emailContent }); await this.idempotencyStore.markProcessed(idempotencyKey); }}We've established the foundational understanding of event handlers—the reactive building blocks that transform events into system behavior. Let's consolidate the key concepts:
What's next:
Now that we understand what event handlers are, we'll explore the design principles that guide their construction. The next page examines how to design handlers that are robust, maintainable, and resilient—covering error handling strategies, idempotency patterns, and the principles that separate good handlers from great ones.
You now understand what event handlers are, their anatomy, responsibilities, and lifecycle. This foundation prepares you for designing handlers that follow proven principles and patterns in the next page.