Loading content...
When an event occurs, how many handlers should respond? Should a single, comprehensive handler orchestrate all reactions? Or should multiple focused handlers each handle one aspect independently?
This is not merely an implementation detail—it's an architectural decision that affects fault isolation, testability, scalability, and system complexity. Both approaches have legitimate use cases, and understanding when to apply each is essential for effective event-driven design.
By the end of this page, you will understand when to use single handlers vs multiple handlers, the trade-offs of each approach, and how to evolve from one pattern to another. You'll gain clear decision criteria and implementation strategies for both patterns.
A single handler approach means one handler class responds to each event type. All logic triggered by that event lives in this one handler—potentially organized into private methods, but executed as a single unit.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// Single handler approach - one handler does everythingclass OrderPlacedHandler implements EventHandler<OrderPlacedEvent> { readonly eventTypes = ['OrderPlaced']; constructor( private readonly inventoryService: InventoryService, private readonly paymentService: PaymentService, private readonly notificationService: NotificationService, private readonly analyticsService: AnalyticsService, private readonly fraudService: FraudService ) {} async handle(event: OrderPlacedEvent): Promise<void> { // Step 1: Reserve inventory await this.reserveInventory(event); // Step 2: Authorize payment await this.authorizePayment(event); // Step 3: Send confirmation await this.sendConfirmation(event); // Step 4: Track analytics await this.trackAnalytics(event); // Step 5: Check for fraud await this.checkFraud(event); } private async reserveInventory(event: OrderPlacedEvent): Promise<void> { await this.inventoryService.reserve(event.payload.items); } private async authorizePayment(event: OrderPlacedEvent): Promise<void> { await this.paymentService.authorize( event.payload.paymentMethodId, event.payload.totalAmount ); } private async sendConfirmation(event: OrderPlacedEvent): Promise<void> { await this.notificationService.sendOrderConfirmation( event.payload.customerId, event.payload.orderId ); } private async trackAnalytics(event: OrderPlacedEvent): Promise<void> { await this.analyticsService.track('order_placed', { orderId: event.payload.orderId, amount: event.payload.totalAmount, itemCount: event.payload.items.length }); } private async checkFraud(event: OrderPlacedEvent): Promise<void> { await this.fraudService.analyze(event.payload); }}A single handler that grows to encompass many unrelated concerns becomes a 'monolith within a microservice.' It centralizes logic that should be independent, making changes risky and testing difficult. Monitor handler size and complexity as signals to split.
A multiple handler approach means several independent handlers subscribe to the same event type. Each handler focuses on one concern and executes independently of others.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// Multiple handler approach - each handler has one job// Handler 1: Inventory concernclass InventoryReservationHandler implements EventHandler<OrderPlacedEvent> { readonly eventTypes = ['OrderPlaced']; constructor(private readonly inventoryService: InventoryService) {} async handle(event: OrderPlacedEvent): Promise<void> { await this.inventoryService.reserve(event.payload.items); }} // Handler 2: Payment concernclass PaymentAuthorizationHandler implements EventHandler<OrderPlacedEvent> { readonly eventTypes = ['OrderPlaced']; constructor(private readonly paymentService: PaymentService) {} async handle(event: OrderPlacedEvent): Promise<void> { await this.paymentService.authorize( event.payload.paymentMethodId, event.payload.totalAmount ); }} // Handler 3: Notification concernclass OrderConfirmationHandler implements EventHandler<OrderPlacedEvent> { readonly eventTypes = ['OrderPlaced']; constructor(private readonly notificationService: NotificationService) {} async handle(event: OrderPlacedEvent): Promise<void> { await this.notificationService.sendOrderConfirmation( event.payload.customerId, event.payload.orderId ); }} // Handler 4: Analytics concernclass OrderAnalyticsHandler implements EventHandler<OrderPlacedEvent> { readonly eventTypes = ['OrderPlaced']; constructor(private readonly analyticsService: AnalyticsService) {} async handle(event: OrderPlacedEvent): Promise<void> { await this.analyticsService.track('order_placed', { orderId: event.payload.orderId, amount: event.payload.totalAmount, itemCount: event.payload.items.length }); }} // Handler 5: Fraud concernclass FraudDetectionHandler implements EventHandler<OrderPlacedEvent> { readonly eventTypes = ['OrderPlaced']; constructor(private readonly fraudService: FraudService) {} async handle(event: OrderPlacedEvent): Promise<void> { await this.fraudService.analyze(event.payload); }}Multiple handlers exemplify the Open/Closed Principle: the system is open for extension (add new handlers) but closed for modification (existing handlers untouched). Adding a new reaction to an event means adding a new handler file, not modifying existing code.
Understanding the trade-offs between single and multiple handlers requires examining multiple dimensions. Each approach excels in different areas.
| Dimension | Single Handler | Multiple Handlers |
|---|---|---|
| Fault Isolation | ❌ One failure fails all | ✅ Failures are isolated |
| Testability | ❌ Must mock many dependencies | ✅ Test each handler independently |
| Code Locality | ✅ All logic in one place | ❌ Logic spread across files |
| Execution Order | ✅ Explicit, controlled order | ❌ Order may be non-deterministic |
| Performance | ✅ Single handler invocation | ❌ Multiple invocations overhead |
| Parallelism | ❌ Sequential by default | ✅ Natural parallelism |
| Scaling | ❌ All-or-nothing scaling | ✅ Scale handlers independently |
| Complexity | ✅ Simple for few concerns | ✅ Scalable for many concerns |
| Team Ownership | ❌ Single owner for all logic | ✅ Different teams can own handlers |
| Adding Features | ❌ Modify existing handler | ✅ Add new handler |
| Debugging | ✅ Single stack trace | ❌ Distributed across handlers |
| Transactional Integrity | ✅ Single transaction scope | ❌ Separate transactions |
Key insight: The trade-offs favor single handlers for tightly coupled, transactional operations, and multiple handlers for loosely coupled, independently failing operations. Most mature systems use a mix of both approaches based on the nature of each event.
Use this decision framework to choose between single and multiple handlers for a given event:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// Example 1: Single handler - operations must succeed together// Payment capture and inventory deduction are transactionally coupledclass OrderConfirmedHandler implements EventHandler<OrderConfirmedEvent> { readonly eventTypes = ['OrderConfirmed']; constructor( private readonly paymentService: PaymentService, private readonly inventoryService: InventoryService, private readonly db: Database ) {} async handle(event: OrderConfirmedEvent): Promise<void> { // Single transaction - both must succeed await this.db.transaction(async (tx) => { await this.paymentService.capture(event.payload.paymentId, tx); await this.inventoryService.deduct(event.payload.items, tx); }); }} // Example 2: Multiple handlers - independent concerns// Notifications, analytics, and integrations are completely independent class CustomerNotificationHandler implements EventHandler<OrderConfirmedEvent> { readonly eventTypes = ['OrderConfirmed']; // Email failure shouldn't affect anything else async handle(event: OrderConfirmedEvent): Promise<void> { await this.emailService.sendOrderConfirmation(event.payload); }} class OrderAnalyticsHandler implements EventHandler<OrderConfirmedEvent> { readonly eventTypes = ['OrderConfirmed']; // Analytics is best-effort, can fail silently async handle(event: OrderConfirmedEvent): Promise<void> { await this.analytics.track('order_confirmed', event.payload); }} class ERPIntegrationHandler implements EventHandler<OrderConfirmedEvent> { readonly eventTypes = ['OrderConfirmed']; // ERP sync can be retried independently async handle(event: OrderConfirmedEvent): Promise<void> { await this.erpClient.syncOrder(event.payload); }} class LoyaltyPointsHandler implements EventHandler<OrderConfirmedEvent> { readonly eventTypes = ['OrderConfirmed']; // Points can be added later if this fails async handle(event: OrderConfirmedEvent): Promise<void> { await this.loyaltyService.awardPoints(event.payload); }}Many systems use a hybrid approach: a primary handler for critical, transactionally coupled operations, and multiple secondary handlers for independent concerns. The primary handler handles payment and inventory; secondary handlers handle notifications, analytics, and integrations.
When multiple handlers subscribe to an event, the event system must decide how to execute them. Different execution semantics have different implications for performance, ordering, and error handling.
Parallel execution invokes all handlers concurrently. This maximizes throughput but provides no ordering guarantees and complicates error handling.
12345678910111213141516171819202122232425262728293031
class ParallelEventBus implements EventBus { private handlers: Map<string, EventHandler<Event>[]> = new Map(); async publish(event: Event): Promise<void> { const handlers = this.handlers.get(event.type) || []; // Execute all handlers in parallel const results = await Promise.allSettled( handlers.map(handler => handler.handle(event)) ); // Check for failures const failures = results.filter( (r): r is PromiseRejectedResult => r.status === 'rejected' ); if (failures.length > 0) { // Log failures but don't fail the publish failures.forEach(f => console.error('Handler failed:', f.reason) ); } }} // Characteristics:// ✅ Maximum throughput - all handlers run simultaneously// ✅ No handler blocks another// ❌ No guaranteed order// ❌ Complex error semantics - what does "failure" mean?// ❌ Resource contention possibleWhen multiple handlers process an event, error handling becomes more complex. What happens if one handler fails? Should others continue? Should the event be retried for all handlers or just the failing one?
| Strategy | Behavior | Use Case |
|---|---|---|
| Fail Fast | Stop all handlers on first failure | Transactional - all-or-nothing |
| Continue on Error | Execute all handlers, collect failures | Independent handlers, best-effort |
| Retry Failed Only | Only retry handlers that failed | Optimize retry cost |
| Retry All | Retry event for all handlers (idempotent) | Simple, requires idempotency |
| Quarantine Failed | Dead-letter just the failed handler's state | Granular error recovery |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
// Strategy: Continue on Error with Granular Trackingclass ResilientEventBus implements EventBus { async publish(event: Event): Promise<PublishResult> { const handlers = this.getHandlers(event.type); const results: HandlerResult[] = []; // Execute all handlers, don't stop on failure await Promise.all(handlers.map(async (handler) => { const startTime = Date.now(); try { await handler.handle(event); results.push({ handler: handler.constructor.name, status: 'success', durationMs: Date.now() - startTime }); } catch (error) { results.push({ handler: handler.constructor.name, status: 'failed', error: error.message, durationMs: Date.now() - startTime }); } })); // Report results for monitoring const failed = results.filter(r => r.status === 'failed'); if (failed.length > 0) { await this.reportFailures(event, failed); } return { event, results }; } private async reportFailures( event: Event, failures: HandlerResult[] ): Promise<void> { // Option 1: Log for alerting console.error('Some handlers failed', { eventId: event.id, failures }); // Option 2: Dead-letter with handler-specific metadata for (const failure of failures) { await this.deadLetterQueue.send({ event, failedHandler: failure.handler, error: failure.error, timestamp: new Date() }); } // Option 3: Schedule retry for failed handlers only for (const failure of failures) { await this.retryQueue.schedule({ event, handlerName: failure.handler, retryCount: 1, retryAt: new Date(Date.now() + 60000) }); } }} // Handler-specific retry mechanismclass HandlerRetryProcessor { async processRetry(retry: RetryRecord): Promise<void> { const handler = this.getHandler(retry.handlerName); try { await handler.handle(retry.event); console.log(`Retry succeeded: ${retry.handlerName}`); } catch (error) { if (retry.retryCount >= this.maxRetries) { // Max retries exceeded - dead-letter await this.deadLetterQueue.send({ ...retry, finalError: error.message }); } else { // Schedule another retry with backoff await this.retryQueue.schedule({ ...retry, retryCount: retry.retryCount + 1, retryAt: this.calculateBackoff(retry.retryCount) }); } } }}The simplest approach—retrying the event for all handlers—works when all handlers are idempotent. Handlers that already succeeded will detect the duplicate and skip. This simplifies the event bus but requires disciplined handler design.
Systems rarely stay static. A single handler that was appropriate at launch may need to become multiple handlers as the system grows. Understanding how to evolve handler architecture safely is important for long-term maintainability.
Pattern: Strangler Fig for Handlers
When splitting a single handler into multiple handlers, use the strangler fig pattern:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// Phase 1: Original monolithic handlerclass OriginalOrderHandler implements EventHandler<OrderPlacedEvent> { readonly eventTypes = ['OrderPlaced']; async handle(event: OrderPlacedEvent): Promise<void> { await this.reserveInventory(event); // Concern 1 await this.sendConfirmation(event); // Concern 2 await this.trackAnalytics(event); // Concern 3 } // ... private methods} // Phase 2: Extract first concern to new handler// Both handlers run on same eventclass InventoryHandler implements EventHandler<OrderPlacedEvent> { readonly eventTypes = ['OrderPlaced']; async handle(event: OrderPlacedEvent): Promise<void> { await this.inventoryService.reserve(event.payload.items); }} // Original handler with feature flagclass OriginalOrderHandler implements EventHandler<OrderPlacedEvent> { async handle(event: OrderPlacedEvent): Promise<void> { // Feature flag: let new handler do inventory if (!this.featureFlags.isEnabled('new-inventory-handler')) { await this.reserveInventory(event); // Legacy path } await this.sendConfirmation(event); await this.trackAnalytics(event); }} // Phase 3: Extract more concerns, remove from originalclass NotificationHandler implements EventHandler<OrderPlacedEvent> { /*...*/ }class AnalyticsHandler implements EventHandler<OrderPlacedEvent> { /*...*/ } // Original handler now empty - safe to deleteclass OriginalOrderHandler implements EventHandler<OrderPlacedEvent> { async handle(event: OrderPlacedEvent): Promise<void> { // All concerns extracted to dedicated handlers // This handler can now be removed }} // Phase 4: Final state - clean, focused handlers// InventoryHandler, NotificationHandler, AnalyticsHandler// OriginalOrderHandler deletedThe choice between single and multiple handlers is an architectural decision with significant implications. Here's what we've learned:
What's next:
With handler multiplicity addressed, we'll explore handler ordering—when and how to control the sequence in which multiple handlers execute. The next page covers ordering guarantees, explicit ordering mechanisms, and patterns for coordinating handler execution.
You now understand when to use single vs multiple handlers, the trade-offs involved, and how to evolve between approaches. This knowledge enables you to design handler architectures that match your system's needs.