Loading content...
When multiple handlers respond to the same event, an important question emerges: Does the order in which they execute matter? And if it does, how do we control it?
In distributed event-driven systems, ordering is particularly challenging. Events may arrive out of order, handlers may execute concurrently, and network partitions can cause temporary inconsistencies. Yet many business processes have inherent ordering requirements that must be respected.
By the end of this page, you will understand when handler ordering matters, mechanisms for controlling execution order, event ordering guarantees from messaging systems, and patterns for coordinating handlers without creating tight coupling. You'll learn to design systems that handle ordering requirements gracefully.
Not all handler executions require ordering. Understanding when ordering truly matters helps you avoid over-engineering solutions for problems that don't exist, while ensuring you address ordering when it's critical.
Start with the assumption that handler ordering doesn't matter. Only add ordering constraints when you have a specific, documented requirement. Unnecessary ordering limits parallelism, increases complexity, and reduces system throughput.
Ordering in event-driven systems operates at multiple levels. Each level has different guarantees, costs, and implementation mechanisms.
| Level | Description | Guarantee | Cost |
|---|---|---|---|
| Event Production Order | Order in which events are published | Sequential publishing guarantees order | Low - single publisher |
| Event Delivery Order | Order in which events reach handlers | Varies by message broker | Medium - broker dependent |
| Handler Invocation Order | Order in which handlers for same event execute | Event bus controlled | Medium - coordination needed |
| Cross-Event Order | Ordering across different event types | Complex coordination required | High - saga/process manager |
| Global Order | Total ordering of all events system-wide | Requires distributed coordination | Very High - limits scalability |
Production order is the order in which events are created. A single producer naturally creates events in order, but multiple producers create partial orders that may interleave.
Delivery order is what the message broker guarantees. Kafka guarantees order within a partition; SQS provides no ordering guarantees; RabbitMQ can guarantee order per queue with single consumers.
Invocation order is the order in which handlers for the same event execute. This is controlled by your event bus implementation.
Cross-event order involves coordinating handlers across different events—a saga or process manager pattern.
Global order is a total ordering of all events, which is expensive and rarely necessary.
When you need to control the order in which handlers for the same event execute, several mechanisms are available. Each has different trade-offs.
Assign numeric priorities to handlers. The event bus sorts handlers by priority before execution.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// Priority-based handler orderinginterface OrderedHandler<T extends Event> extends EventHandler<T> { readonly priority: number; // Higher number = earlier execution} class PriorityEventBus implements EventBus { private handlers: Map<string, OrderedHandler<Event>[]> = new Map(); register<T extends Event>(handler: OrderedHandler<T>): void { const eventType = handler.eventTypes[0]; const existing = this.handlers.get(eventType) || []; existing.push(handler as OrderedHandler<Event>); // Sort by priority descending existing.sort((a, b) => b.priority - a.priority); this.handlers.set(eventType, existing); } async publish(event: Event): Promise<void> { const handlers = this.handlers.get(event.type) || []; // Execute in priority order (already sorted) for (const handler of handlers) { await handler.handle(event); } }} // Handler definitions with prioritiesclass ValidationHandler implements OrderedHandler<OrderEvent> { readonly eventTypes = ['OrderPlaced']; readonly priority = 1000; // Execute first - validate before anything else async handle(event: OrderEvent): Promise<void> { if (!this.isValid(event.payload)) { throw new ValidationError('Invalid order data'); } }} class InventoryHandler implements OrderedHandler<OrderEvent> { readonly eventTypes = ['OrderPlaced']; readonly priority = 500; // Execute second - reserve inventory async handle(event: OrderEvent): Promise<void> { await this.inventoryService.reserve(event.payload.items); }} class NotificationHandler implements OrderedHandler<OrderEvent> { readonly eventTypes = ['OrderPlaced']; readonly priority = 100; // Execute last - notify after core processing async handle(event: OrderEvent): Promise<void> { await this.emailService.send(event.payload.customerId, 'Order confirmed'); }}Pros: Simple to implement, explicit ordering, easy to understand.
Cons: Magic numbers, gaps between priorities needed for future handlers, global coordination required to avoid conflicts.
Before handlers can execute in order, events must arrive in order. Different message brokers provide different ordering guarantees, and understanding these is critical for designing ordering-dependent systems.
| Broker | Ordering Guarantee | How to Achieve | Trade-offs |
|---|---|---|---|
| Apache Kafka | Order within partition | Use partition key (e.g., entity ID) | Partition = single consumer = limited parallelism |
| RabbitMQ | Order per queue with single consumer | Single consumer per queue | Single consumer = no parallelism |
| AWS SQS Standard | None (best-effort) | Use SQS FIFO queues | FIFO has throughput limits |
| AWS SQS FIFO | Order within message group | Use MessageGroupId | 300 messages/sec per group |
| Azure Service Bus | Order within session | Use SessionId | Session affinity required |
| Google Pub/Sub | Order with ordering key | Enable ordering + supply key | Increased latency |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// Kafka: Partition by entity ID for orderingclass KafkaEventPublisher { constructor(private readonly producer: KafkaProducer) {} async publish(event: Event): Promise<void> { // Use entity ID as partition key // All events for the same order go to the same partition // Same partition = same consumer = guaranteed order const partitionKey = this.getPartitionKey(event); await this.producer.send({ topic: 'orders', messages: [{ key: partitionKey, value: JSON.stringify(event) }] }); } private getPartitionKey(event: Event): string { // Events for same entity go to same partition switch (event.type) { case 'OrderPlaced': case 'OrderPaid': case 'OrderShipped': case 'OrderDelivered': return event.payload.orderId; default: return event.id; // Fallback - no ordering } }} // SQS FIFO: Use MessageGroupId for orderingclass SQSEventPublisher { constructor(private readonly sqs: SQS) {} async publish(event: Event): Promise<void> { await this.sqs.sendMessage({ QueueUrl: 'https://sqs.../orders.fifo', MessageBody: JSON.stringify(event), MessageGroupId: event.payload.orderId, // Order within group MessageDeduplicationId: event.id // Prevent duplicates }).promise(); }} // Consumer: Single consumer per partition/group maintains orderclass OrderedEventConsumer { async processMessages(messages: Message[]): Promise<void> { // Messages are already ordered by broker // Process sequentially to maintain order for (const message of messages) { const event = JSON.parse(message.body); await this.handleEvent(event); await this.acknowledge(message); } }}Ordering guarantees typically require serial processing. If events for orderId 'A' must be processed in order, only one consumer can process them. This limits throughput. Design partition/group keys carefully—too few means bottlenecks, too many means ordering is lost.
Despite best efforts, events may arrive out of order due to network delays, consumer restarts, or retry mechanisms. Handlers must be designed to cope with this reality.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
// Pattern 1: Version-based orderingclass VersionedEventHandler implements EventHandler<OrderEvent> { readonly eventTypes = ['OrderUpdated']; constructor(private readonly orderRepo: OrderRepository) {} async handle(event: OrderEvent): Promise<void> { const order = await this.orderRepo.findById(event.payload.orderId); // Check version - only process if newer if (event.payload.version <= order.version) { console.log('Skipping out-of-order event', { eventVersion: event.payload.version, currentVersion: order.version }); return; // Idempotent skip } // Apply update await this.orderRepo.updateWithVersion( event.payload.orderId, event.payload.updates, event.payload.version ); }} // Pattern 2: State machine validationclass StateMachineEventHandler implements EventHandler<OrderEvent> { readonly eventTypes = ['OrderShipped']; private readonly validTransitions: Map<string, string[]> = new Map([ ['placed', ['paid', 'cancelled']], ['paid', ['processing', 'refunded']], ['processing', ['shipped', 'cancelled']], ['shipped', ['delivered', 'returned']], ['delivered', ['returned']] ]); async handle(event: OrderEvent): Promise<void> { const order = await this.orderRepo.findById(event.payload.orderId); // Validate state transition const validNextStates = this.validTransitions.get(order.status) || []; if (!validNextStates.includes('shipped')) { console.warn('Invalid state transition', { orderId: order.id, currentStatus: order.status, attemptedStatus: 'shipped' }); // Option A: Skip (idempotent) return; // Option B: Buffer for later processing // await this.eventBuffer.hold(event, 'shipped'); // Option C: Dead-letter for investigation // await this.dlq.send(event, 'invalid_transition'); } await this.orderRepo.updateStatus(order.id, 'shipped'); }} // Pattern 3: Event buffering for orderingclass BufferedEventHandler { private buffer: Map<string, OrderEvent[]> = new Map(); async handle(event: OrderEvent): Promise<void> { const orderId = event.payload.orderId; // Check if we can process this event now if (await this.canProcess(event)) { await this.processEvent(event); // Process any buffered events that are now valid await this.processBuffered(orderId); } else { // Buffer for later const buffered = this.buffer.get(orderId) || []; buffered.push(event); this.buffer.set(orderId, buffered); // Set timeout to prevent infinite buffering setTimeout(() => this.expireBuffer(orderId, event.id), 60000); } } private async canProcess(event: OrderEvent): Promise<boolean> { const order = await this.orderRepo.findById(event.payload.orderId); // Check prerequisites switch (event.type) { case 'OrderShipped': return order.status === 'processing'; case 'OrderDelivered': return order.status === 'shipped'; default: return true; } } private async processBuffered(orderId: string): Promise<void> { const buffered = this.buffer.get(orderId) || []; for (const event of [...buffered]) { if (await this.canProcess(event)) { await this.processEvent(event); buffered.splice(buffered.indexOf(event), 1); } } if (buffered.length === 0) { this.buffer.delete(orderId); } else { this.buffer.set(orderId, buffered); } }}Instead of preventing out-of-order processing, design for 'eventually correct' state. If events arrive out of order but all events eventually arrive, the final state should be correct. Version numbers and idempotent operations enable this.
Sometimes ordering requirements span multiple event types. A saga or process manager coordinates multiple handlers across multiple events, maintaining the overall process state.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
// Saga: Coordinates multi-step order fulfillment processinterface SagaState { sagaId: string; orderId: string; status: 'started' | 'inventory_reserved' | 'payment_captured' | 'shipped' | 'completed' | 'failed'; compensating: boolean; completedSteps: string[]; failedStep?: string; error?: string;} class OrderFulfillmentSaga { constructor( private readonly sagaStore: SagaStore, private readonly eventBus: EventBus ) {} // Handler for starting event async handleOrderPlaced(event: OrderPlacedEvent): Promise<void> { const saga: SagaState = { sagaId: generateId(), orderId: event.payload.orderId, status: 'started', compensating: false, completedSteps: [] }; await this.sagaStore.save(saga); // Start first step await this.eventBus.publish({ type: 'ReserveInventoryCommand', payload: { sagaId: saga.sagaId, orderId: event.payload.orderId, items: event.payload.items } }); } // Handler for inventory reserved async handleInventoryReserved(event: InventoryReservedEvent): Promise<void> { const saga = await this.sagaStore.findBySagaId(event.payload.sagaId); if (saga.compensating) { return; // Saga is rolling back, ignore forward events } saga.status = 'inventory_reserved'; saga.completedSteps.push('inventory'); await this.sagaStore.save(saga); // Proceed to next step await this.eventBus.publish({ type: 'CapturePaymentCommand', payload: { sagaId: saga.sagaId, orderId: saga.orderId } }); } // Handler for payment captured async handlePaymentCaptured(event: PaymentCapturedEvent): Promise<void> { const saga = await this.sagaStore.findBySagaId(event.payload.sagaId); if (saga.compensating) return; saga.status = 'payment_captured'; saga.completedSteps.push('payment'); await this.sagaStore.save(saga); // Proceed to shipping await this.eventBus.publish({ type: 'CreateShipmentCommand', payload: { sagaId: saga.sagaId, orderId: saga.orderId } }); } // Handler for failures - trigger compensation async handleStepFailed(event: StepFailedEvent): Promise<void> { const saga = await this.sagaStore.findBySagaId(event.payload.sagaId); saga.status = 'failed'; saga.compensating = true; saga.failedStep = event.payload.step; saga.error = event.payload.error; await this.sagaStore.save(saga); // Compensate in reverse order const stepsToCompensate = [...saga.completedSteps].reverse(); for (const step of stepsToCompensate) { await this.compensateStep(saga, step); } } private async compensateStep(saga: SagaState, step: string): Promise<void> { switch (step) { case 'inventory': await this.eventBus.publish({ type: 'ReleaseInventoryCommand', payload: { sagaId: saga.sagaId, orderId: saga.orderId } }); break; case 'payment': await this.eventBus.publish({ type: 'RefundPaymentCommand', payload: { sagaId: saga.sagaId, orderId: saga.orderId } }); break; // ... other compensation steps } }}Sagas provide explicit ordering through a central coordinator. Choreography relies on handlers reacting to events independently. Sagas are better for complex multi-step processes with compensation; choreography is simpler for loosely coupled handlers.
The best ordering solution is often to eliminate the ordering requirement entirely. Design handlers and events so that ordering doesn't matter, and you avoid the complexity of ordering mechanisms.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// Before: Ordering required - InventoryHandler depends on ValidationHandler// ValidationHandler populates order.validatedItems which InventoryHandler reads class ValidationHandler implements EventHandler<OrderPlacedEvent> { async handle(event: OrderPlacedEvent): Promise<void> { const validatedItems = await this.validate(event.payload.items); // Writes to shared state - creates ordering dependency await this.orderRepo.setValidatedItems(event.payload.orderId, validatedItems); }} class InventoryHandler implements EventHandler<OrderPlacedEvent> { async handle(event: OrderPlacedEvent): Promise<void> { // Reads from shared state - must run AFTER ValidationHandler const order = await this.orderRepo.findById(event.payload.orderId); if (!order.validatedItems) { throw new Error('Items not validated yet'); // Ordering violation! } await this.inventoryService.reserve(order.validatedItems); }} // After: No ordering required - fat event includes validated items// Validation happens during event creation, not in handler class OrderService { async placeOrder(request: OrderRequest): void { // Validate BEFORE publishing event const validatedItems = await this.validator.validate(request.items); await this.eventBus.publish({ type: 'OrderPlaced', payload: { orderId: generateId(), items: request.items, validatedItems: validatedItems // Fat event - includes validation } }); }} class InventoryHandler implements EventHandler<OrderPlacedEvent> { async handle(event: OrderPlacedEvent): Promise<void> { // No ordering dependency - event contains everything needed await this.inventoryService.reserve(event.payload.validatedItems); }} class ValidationAuditHandler implements EventHandler<OrderPlacedEvent> { async handle(event: OrderPlacedEvent): Promise<void> { // No ordering dependency - runs independently await this.auditLog.record({ orderId: event.payload.orderId, validationResult: event.payload.validatedItems }); }}Handler ordering is a nuanced topic that requires understanding when ordering matters, how to achieve it when needed, and how to design systems that minimize ordering requirements.
Module Complete:
This concludes our deep dive into Event Handlers. You've learned what handlers are, the principles that guide their design, when to use single vs multiple handlers, and how to manage handler ordering. These concepts form the foundation for building robust, scalable event-driven systems.
Next steps: Apply these patterns in your own event-driven designs. Start simple with autonomous, idempotent handlers. Add ordering mechanisms only when requirements demand them. Continuously observe and refine based on production behavior.
Congratulations! You've completed the Event Handlers module. You now understand the full lifecycle of event handler design—from basic concepts through advanced ordering patterns. You're equipped to design handlers that are robust, testable, and production-ready.