Loading learning content...
At first glance, events and commands might seem interchangeable. Both are messages passed between components. Both trigger behavior in response. Both can be modeled as simple objects with a type and payload. But beneath this superficial similarity lies a profound difference that shapes how you design, evolve, and maintain your systems.
Confusing events and commands is one of the most common mistakes in event-driven (and message-driven) design. It leads to tightly coupled systems masquerading as loosely coupled ones, to architectural patterns that fight against themselves, and to code that's harder to reason about than the direct method calls it replaced.
This page will give you crystal clarity on the distinction, enabling you to choose correctly every time.
By the end of this page, you will understand the fundamental semantic difference between events and commands, how this difference affects coupling, why it matters for system evolution, and when to use each pattern. You'll be able to look at any message and immediately classify it correctly.
The core difference between events and commands can be stated simply:
An Event says: "This happened." A Command says: "Do this."
This grammatical difference—past tense versus imperative—reflects a profound semantic distinction:
| Aspect | Event | Command |
|---|---|---|
| Tense | Past ("UserRegistered") | Imperative ("RegisterUser") |
| Meaning | A fact that occurred | A request for action |
| Sender knows | Something happened | What action is needed |
| Sender expects | Nothing specific | Specific action to be taken |
| Failure semantics | The fact still occurred | Action must be retried/handled |
Let's unpack each of these differences.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// ===== EVENTS: Facts that occurred (past tense) =====// Notice: NO expectation of what should happen in response interface UserRegisteredEvent { readonly type: "UserRegistered"; // Past tense readonly occurredAt: Date; readonly payload: Readonly<{ userId: string; email: string; registeredAt: Date; registrationSource: string; }>;} interface OrderShippedEvent { readonly type: "OrderShipped"; // Past tense readonly occurredAt: Date; readonly payload: Readonly<{ orderId: string; trackingNumber: string; carrier: string; }>;} // The publisher announces facts without knowing or caring// who is listening or what they will do // ===== COMMANDS: Requests for action (imperative) =====// Notice: EXPECTATION of specific action to be taken interface RegisterUserCommand { type: "RegisterUser"; // Imperative email: string; password: string; fullName: string;} interface ShipOrderCommand { type: "ShipOrder"; // Imperative orderId: string; shippingMethod: string; warehouseId: string;} // The sender expects the command handler to DO something specific// There is an implicit contract: "handle this, or report failure"When you're unsure whether something should be an event or command, check the name. If it's naturally past tense ("OrderPlaced", "UserLoggedIn", "PaymentProcessed"), it's an event. If it's naturally imperative ("PlaceOrder", "LogIn", "ProcessPayment"), it's a command. The grammar reflects the semantics.
The event/command distinction reflects fundamentally different relationships of ownership and authority.
Commands: The Sender Has Authority
When you issue a command, you're asserting authority over the receiver. You're saying, "I have the right to tell you what to do." The sender defines what should happen, and the receiver is expected to comply or report failure.
Events: The Publisher Has No Authority
When you publish an event, you're making no demands. You're saying, "This happened; do with it what you will." The publisher has no authority over subscribers and no expectation of any particular response.
Why This Matters for Coupling:
The direction of authority creates different coupling dynamics:
Commands couple the sender to the receiver. If OrderService sends ShipOrder command to ShippingService, OrderService must know about ShippingService's command interface. Changes to that interface require changes to OrderService.
Events couple subscribers to the publisher. If OrderService publishes OrderPlaced event, subscribers (Shipping, Inventory, Notifications) must know about OrderService's event schema. But OrderService knows nothing about them.
This asymmetry is crucial. With events, the publisher (often the core domain) remains pure and independent, while peripheral services adapt to it. With commands, the core domain becomes dependent on its collaborators.
In well-designed systems, we want dependencies to flow toward stable, core abstractions—not toward peripheral, volatile components. Events naturally support this: the core domain publishes events; peripheral services subscribe. Commands invert this, making the core depend on peripherals.
Another fundamental difference lies in how many handlers are expected.
Commands: One Handler (Exactly)
A command is intended to be processed by exactly one handler. If you send CreateUser command, there should be one UserService that handles it. Having multiple handlers for the same command is typically a design error—it suggests the command is ambiguous or the system is misconfigured.
Events: Zero to Many Handlers
An event may have zero, one, or many handlers. This is by design. UserRegistered might trigger:
All of these are valid. The event doesn't care—it's just announcing a fact.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// ===== COMMAND: Expect exactly one handler ===== class CommandBus { private handlers: Map<string, CommandHandler> = new Map(); register(commandType: string, handler: CommandHandler): void { if (this.handlers.has(commandType)) { // Multiple handlers for a command is typically an error throw new Error( `Command ${commandType} already has a handler. ` + `Commands should have exactly one handler.` ); } this.handlers.set(commandType, handler); } async execute<TResult>(command: Command): Promise<TResult> { const handler = this.handlers.get(command.type); if (!handler) { // No handler for a command is an error throw new Error( `No handler registered for command ${command.type}.` ); } // Exactly one handler processes the command return handler.handle(command); }} // ===== EVENT: Zero to many handlers is normal ===== class EventBus { private handlers: Map<string, EventHandler[]> = new Map(); subscribe(eventType: string, handler: EventHandler): void { // Multiple handlers for one event is expected and normal const existing = this.handlers.get(eventType) || []; this.handlers.set(eventType, [...existing, handler]); // No error—events welcome multiple subscribers } async publish(event: DomainEvent): Promise<void> { const handlers = this.handlers.get(event.type) || []; // Zero handlers is also valid—no one might be interested if (handlers.length === 0) { console.log(`No handlers for event ${event.type} (this is OK)`); return; } // All handlers receive the event await Promise.all( handlers.map(handler => handler.handle(event)) ); }} // Usage: Demonstrating the difference // COMMAND: One handler expectedcommandBus.register("CreateUser", new CreateUserHandler());// commandBus.register("CreateUser", new AnotherHandler()); // ERROR! // EVENT: Many handlers expectedeventBus.subscribe("UserCreated", new SendWelcomeEmailHandler());eventBus.subscribe("UserCreated", new TrackNewUserAnalyticsHandler());eventBus.subscribe("UserCreated", new SyncToCRMHandler());eventBus.subscribe("UserCreated", new EnrollInLoyaltyProgramHandler());// All of these are fine—events support multiple subscribersSome advanced patterns (like Saga orchestration) involve commands being handled by different handlers based on state or conditions. But even then, for any given command instance, there's one intended handler. The cardinality principle holds: commands are processed once, events are broadcast to many.
Commands and events differ fundamentally in their response semantics.
Commands: Response Expected
When you send a command, you typically expect a response:
Commands are request/response in nature, even when processed asynchronously.
Events: No Response Expected
When you publish an event, you expect nothing in return. The event is fire-and-forget from the publisher's perspective. If a handler needs to report a result, it does so by publishing its own event—it doesn't "reply" to the original publisher.
This difference has significant implications for how you design workflows and handle failures.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
// ===== COMMANDS: Expect meaningful responses ===== interface CreateOrderCommand { type: "CreateOrder"; customerId: string; items: OrderItem[];} interface CreateOrderResult { orderId: string; orderNumber: string; estimatedDelivery: Date; totalAmount: number;} class OrderCommandHandler { async handle(command: CreateOrderCommand): Promise<CreateOrderResult> { const order = await this.orderService.createOrder( command.customerId, command.items ); // Command handler returns a meaningful result // The sender needs this information return { orderId: order.id, orderNumber: order.orderNumber, estimatedDelivery: order.calculateDeliveryDate(), totalAmount: order.totalAmount, }; }} // Usage: Sender waits for and uses the resultasync function checkout(customerId: string, items: OrderItem[]) { // Command execution expects a result const result = await commandBus.execute<CreateOrderResult>({ type: "CreateOrder", customerId, items, }); // We use the result console.log(`Order ${result.orderNumber} created!`); redirectToConfirmation(result.orderId);} // ===== EVENTS: No response expected or possible ===== class OrderService { constructor(private eventBus: EventBus) {} async createOrder(customerId: string, items: OrderItem[]): Promise<Order> { const order = new Order(customerId, items); await this.orderRepository.save(order); // Event is published—we don't wait for handlers // We don't care what handlers do // We get no result back await this.eventBus.publish({ type: "OrderCreated", payload: { orderId: order.id, customerId, items, totalAmount: order.totalAmount, }, }); // We continue without knowing what happened // Handlers might: // - Send confirmation emails // - Update inventory // - Notify the warehouse // - Do nothing // We don't know, and we don't care return order; }} // If a handler needs to communicate results, it publishes its own eventclass InventoryHandler implements EventHandler<OrderCreatedEvent> { async handle(event: OrderCreatedEvent): Promise<void> { try { await this.inventory.reserve(event.payload.items); // Success: publish a NEW event (not a response) await this.eventBus.publish({ type: "InventoryReserved", payload: { orderId: event.payload.orderId, items: event.payload.items, }, }); } catch (error) { // Failure: publish a failure event (not an exception to caller) await this.eventBus.publish({ type: "InventoryReservationFailed", payload: { orderId: event.payload.orderId, reason: error.message, }, }); } }}When multiple steps must coordinate via events, you get event chains: Event A triggers Handler 1, which publishes Event B, which triggers Handler 2, and so on. This is different from request/response—there's no call stack, no direct return path. Each step is autonomous.
How failures are handled differs dramatically between commands and events.
Command Failure: The Sender's Problem
If a command fails, the sender needs to know. They're expecting an action to be completed. Failure typically means:
The command pattern creates a responsibility chain: the sender is responsible for ensuring the action eventually succeeds (or explicitly giving up).
Event Handler Failure: The Handler's Problem
If an event handler fails, the publisher doesn't know and doesn't care. The event still happened—it's an immutable fact. Handler failure means:
This is sometimes called "at least once" or "at most once" delivery semantics, depending on the infrastructure.
| Scenario | Command Behavior | Event Behavior |
|---|---|---|
| Handler throws exception | Exception propagates to sender | Exception isolated to that handler; others unaffected |
| System determines next action | Sender decides (retry, fail, compensate) | Handler decides (retry, log, dead-letter) |
| State after failure | Original state unchanged (if well-designed) | Event still occurred; failed handler didn't react |
| Visibility of failure | Sender sees failure immediately | Publisher never knows; monitoring detects issues |
| Retry responsibility | Sender must retry the command | Handler infrastructure must retry event processing |
| Compensation/rollback | Sender can initiate rollback | Saga or compensating events needed |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
// ===== COMMAND FAILURE: Sender must handle ===== async function processOrder(orderId: string) { try { // Command: we expect success or failure const result = await commandBus.execute({ type: "ProcessPayment", orderId, amount: 99.99, }); console.log("Payment successful:", result.transactionId); } catch (error) { // Failure comes back to us // We must decide what to do if (error instanceof InsufficientFundsError) { await notifyCustomer("Payment failed: insufficient funds"); await cancelOrder(orderId); } else if (error instanceof TemporaryFailureError) { // Retry logic is OUR responsibility await scheduleRetry(orderId); } else { // Escalate throw error; } }} // ===== EVENT FAILURE: Handler handles its own problems ===== class ShippingHandler implements EventHandler<PaymentProcessedEvent> { async handle(event: PaymentProcessedEvent): Promise<void> { try { await this.shippingService.prepareShipment(event.payload.orderId); } catch (error) { // WE must handle OUR failure // The publisher (PaymentService) doesn't know or care console.error("Shipping preparation failed:", error); // Strategy 1: Retry with backoff await this.retryQueue.enqueue({ event, attemptNumber: 1, nextAttempt: Date.now() + 60000, }); // Strategy 2: Log for manual intervention // await this.alertService.raiseIncident(...); // Strategy 3: Publish failure event for saga // await this.eventBus.publish({ // type: "ShippingPreparationFailed", // payload: { orderId: event.payload.orderId, reason: error.message } // }); // Key point: The PaymentProcessedEvent still happened // The payment was still processed // This handler's failure is this handler's problem } }} // Event dispatcher might provide isolation guaranteesclass ResilientEventDispatcher { async dispatch(event: DomainEvent): Promise<void> { const handlers = this.getHandlers(event.type); // Each handler is called in isolation // One handler's failure doesn't affect others const results = await Promise.allSettled( handlers.map(handler => handler.handle(event)) ); // Log failures but don't propagate them for (const result of results) { if (result.status === "rejected") { console.error(`Handler failed for ${event.type}:`, result.reason); // Could: send to dead-letter queue, alert, retry, etc. } } // Event was published successfully // Individual handler failures are handled separately }}When event-driven workflows need coordinated failure handling (e.g., if shipping fails, refund the payment), you need the Saga pattern. Sagas use compensating events to undo previous actions. This is fundamentally different from command exception handling—it's choreographed recovery rather than orchestrated rollback.
Commands and events have different relationships to time, creating different forms of temporal coupling.
Commands: Immediate Expectation
When you send a command, you typically expect immediate (or near-immediate) processing. The command represents an action that needs to happen now as part of the current workflow. Even asynchronous commands usually have timeout expectations.
Events: No Temporal Expectation
When you publish an event, there's no expectation of when handlers will process it—or if they will at all. The event records a fact that occurred at a specific moment, but handlers might process it milliseconds, minutes, or days later.
This difference has significant implications for system design and resilience.
The Lunch Break Problem:
Imagine a system where OrderService needs to notify ShippingService when an order is ready to ship.
With Commands:
OrderService → ShipOrderCommand → ShippingService
If ShippingService is down for maintenance (lunch break), the command fails. OrderService must:
With Events:
OrderService → OrderReadyToShip event → Event Bus → ShippingService (when available)
If ShippingService is down, the event waits in the event bus/queue. When ShippingService comes back, it processes all pending events. OrderService doesn't even know there was a delay.
This resilience to temporal coupling is one of the key benefits of event-driven design, especially in distributed systems.
| Aspect | Commands | Events |
|---|---|---|
| Processing expectation | Immediate or near-immediate | Whenever convenient |
| Sender waits? | Usually yes (synchronous) or expects callback | No—fire and forget |
| Receiver availability | Must be available when command is sent | Can be unavailable; catches up later |
| Time between send/receive | Expected to be minimal | Can be arbitrary (minutes, hours, days) |
| System resilience | Availability coupling: both must be up | Temporal decoupling: sender and receiver independent |
| Replay/reprocessing | Usually meaningless (action already attempted) | Meaningful: events can be replayed from log |
Events can be persisted in an event log or message broker. This enables not just resilience to temporary failures, but also replay for debugging, new subscriber catch-up, and event sourcing patterns. Commands, being action requests, don't have the same replay semantics—you don't want to re-execute a payment command by accident.
Armed with understanding of the differences, how do you choose? Here are decision criteria:
Use Commands When:
You need a response. The sender needs to know the result of the action—an ID, a status, or confirmation of success.
There's a specific handler expected. Only one component should handle this request, and failure to handle is an error.
The action must happen now. The workflow requires immediate (or bounded-time) completion.
The sender has authority. The sender is directing the receiver to perform a specific task.
Use Events When:
You're announcing, not requesting. Something has happened; interested parties should know.
Multiple parties might care. Zero, one, or many handlers might react—all are valid.
Timing is flexible. Handlers can process when convenient.
Publisher independence is valuable. The publisher shouldn't know or care about subscribers.
Extensibility matters. New reactions should be addable without changing the publisher.
In practice, many systems use both patterns together. A command might trigger an action that publishes events. An event might trigger a handler that sends commands. The patterns are complementary, not mutually exclusive. The key is using each where it's appropriate.
Let's examine common mistakes that occur when the event/command distinction is blurred.
When in doubt, read the message aloud. "User registered" sounds like a fact—it's an event. "Register user" sounds like an instruction—it's a command. "Send email" is a command. "Email sent" is an event. The grammar reveals the semantics.
Let's see commands and events working together in a realistic e-commerce scenario.
Scenario: A customer places an order. The system must:
The Hybrid Approach:
Some of these require commands (need response, must succeed); others fit events (optional, multiple parties, extensible).
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
// ===== ORDER PLACEMENT: Commands where needed, events for broadcast ===== class OrderController { constructor( private commandBus: CommandBus, private eventBus: EventBus ) {} async placeOrder(request: PlaceOrderRequest): Promise<PlaceOrderResponse> { // STEP 1: Create order (COMMAND - we need the order ID back) const createResult = await this.commandBus.execute<CreateOrderResult>({ type: "CreateOrder", customerId: request.customerId, items: request.items, shippingAddress: request.shippingAddress, }); const orderId = createResult.orderId; try { // STEP 2: Process payment (COMMAND - we need confirmation) const paymentResult = await this.commandBus.execute<PaymentResult>({ type: "ProcessPayment", orderId, amount: createResult.totalAmount, paymentMethod: request.paymentMethod, }); // STEP 3: Reserve inventory (COMMAND - must succeed before confirming) await this.commandBus.execute({ type: "ReserveInventory", orderId, items: request.items, }); // === All critical steps done via commands === // Now we can announce success - other services react // STEP 4-6: Publish event for non-critical, extensible workflows await this.eventBus.publish({ type: "OrderPlaced", occurredAt: new Date(), payload: { orderId, customerId: request.customerId, items: request.items, totalAmount: createResult.totalAmount, paymentTransactionId: paymentResult.transactionId, }, }); // The event triggers: // - EmailHandler: sends confirmation (optional - order still valid if fails) // - WarehouseHandler: queues for picking (can catch up if delayed) // - AnalyticsHandler: tracks metrics (purely optional) // - FutureHandler: anything we add later without changing this code return { success: true, orderId, orderNumber: createResult.orderNumber, estimatedDelivery: createResult.estimatedDelivery, }; } catch (error) { // Command failure: we must handle it // Rollback the order if payment or inventory fails await this.commandBus.execute({ type: "CancelOrder", orderId, reason: `Checkout failed: ${error.message}`, }); throw new CheckoutFailedException(orderId, error.message); } }} // ===== COMMAND HANDLERS: Critical path, one handler each ===== class CreateOrderHandler implements CommandHandler<CreateOrderCommand, CreateOrderResult> { async handle(command: CreateOrderCommand): Promise<CreateOrderResult> { const order = new Order(command.customerId, command.items); await this.orderRepository.save(order); return { orderId: order.id, orderNumber: order.orderNumber, totalAmount: order.calculateTotal(), estimatedDelivery: order.estimateDelivery(), }; }} // ===== EVENT HANDLERS: Non-critical, optional, multiple ===== // Handler 1: Send confirmation emailclass OrderConfirmationEmailHandler implements EventHandler<OrderPlacedEvent> { async handle(event: OrderPlacedEvent): Promise<void> { await this.emailService.sendOrderConfirmation( event.payload.customerId, event.payload.orderId, event.payload.totalAmount ); }} // Handler 2: Notify warehouseclass WarehouseNotificationHandler implements EventHandler<OrderPlacedEvent> { async handle(event: OrderPlacedEvent): Promise<void> { await this.warehouseQueue.enqueue({ orderId: event.payload.orderId, items: event.payload.items, priority: "normal", }); }} // Handler 3: Track analyticsclass OrderAnalyticsHandler implements EventHandler<OrderPlacedEvent> { async handle(event: OrderPlacedEvent): Promise<void> { await this.analytics.trackOrder({ orderId: event.payload.orderId, totalAmount: event.payload.totalAmount, itemCount: event.payload.items.length, }); }} // Handler 4: Added later - no change to OrderController needed!class LoyaltyPointsHandler implements EventHandler<OrderPlacedEvent> { async handle(event: OrderPlacedEvent): Promise<void> { const points = Math.floor(event.payload.totalAmount); await this.loyaltyService.awardPoints( event.payload.customerId, points ); }}Commands are used for the critical path where failure must be handled immediately (payment, inventory). Events are used for the extensible path where handlers can be added, removed, or fail independently (notifications, analytics, loyalty). This hybrid approach captures the strengths of both patterns.
We've thoroughly explored the distinction between events and commands. Let's consolidate the key insights:
What's next:
Now that we understand both events and commands, we'll explore why event-driven design is so valuable. The next page examines the concrete benefits of the event-driven approach: decoupling, extensibility, resilience, and testability—and how these translate to real engineering advantages.
You can now distinguish events from commands with confidence. You understand their different semantics, ownership models, cardinality, response expectations, failure handling, and temporal coupling. You're equipped to choose correctly and avoid common anti-patterns.