Loading content...
Understanding event-driven design is not enough—you must know when to apply it. Over-using events creates unnecessary indirection and complexity. Under-using them leaves coupling and extensibility problems unsolved. The goal is principled decision-making: choosing the right communication pattern for each situation.
This page provides practical decision frameworks. You'll learn to recognize situations where events excel, where they're harmful, and the gray areas that require judgment. By the end, you'll be able to confidently choose between events and direct calls for any given scenario.
By the end of this page, you will have clear criteria for when to use events, specific scenarios that favor events vs. direct calls, anti-patterns to avoid, and a practical decision flowchart you can apply immediately.
When deciding whether to use events or direct calls, ask yourself one fundamental question:
"Does the caller need to control what happens, or just announce that something happened?"
This question cuts through most ambiguity. Let's see how it applies to concrete scenarios.
| Scenario | Control Needed? | Recommendation |
|---|---|---|
| Process payment for order | Yes—need confirmation and transaction ID | Command/Direct Call |
| Send order confirmation email | No—email is fire-and-forget | Event |
| Validate user input | Yes—need validation result immediately | Direct Call |
| Track analytics event | No—analytics is purely observational | Event |
| Reserve inventory for order | Maybe—depends on business criticality | See detailed analysis |
| Notify warehouse of new order | No—warehouse reacts on its own schedule | Event |
A quick test: if you would check the return value or catch an exception from this call, you probably need control. If you'd ignore the return value anyway, it's a candidate for events.
Let's examine specific scenarios where event-driven design provides clear value.
Not every interaction benefits from event-driven design. Here are scenarios where direct calls are preferable.
Some scenarios don't have clear answers. They require judgment based on your specific context, team, and requirements. Let's examine a nuanced example.
Case Study: Inventory Reservation for Orders
When an order is placed, should inventory reservation be:
The answer depends on your business requirements.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
// ===== OPTION A: Direct Command for Critical-Path Inventory =====// Use when: inventory availability must be confirmed before order confirmation class OrderServiceWithDirectReservation { async placeOrder(orderData: OrderData): Promise<Order> { const order = new Order(orderData); // SYNCHRONOUS: Reserve inventory BEFORE confirming order // Customer sees failure immediately if out of stock try { await this.inventoryService.reserve(order.items); // Command } catch (error) { if (error instanceof OutOfStockError) { throw new OrderFailedException( "Some items are out of stock", error.unavailableItems ); } throw error; } await this.orderRepository.save(order); // Non-critical actions via events await this.eventDispatcher.dispatch({ type: "OrderPlaced", payload: order.toEventPayload(), }); return order; }} // ===== OPTION B: Event Handler for Resilient Inventory =====// Use when: system should accept order and handle inventory async class OrderServiceWithEventReservation { async placeOrder(orderData: OrderData): Promise<Order> { const order = new Order(orderData); order.status = "PENDING_INVENTORY"; // Explicit pending state await this.orderRepository.save(order); // ALL reactions via events, including inventory await this.eventDispatcher.dispatch({ type: "OrderPlaced", payload: order.toEventPayload(), }); // Order is "accepted" but not "confirmed" // Customer will be notified async if inventory fails return order; }} class InventoryReservationHandler implements EventHandler<OrderPlacedEvent> { async handle(event: OrderPlacedEvent): Promise<void> { try { await this.inventoryService.reserve(event.payload.items); await this.eventDispatcher.dispatch({ type: "InventoryReserved", payload: { orderId: event.payload.orderId }, }); } catch (error) { // Async failure—notify customer, potentially cancel order await this.eventDispatcher.dispatch({ type: "InventoryReservationFailed", payload: { orderId: event.payload.orderId, reason: error.message, }, }); } }} // ===== OPTION C: Hybrid—Check then Reserve =====// Use when: want immediate feedback but eventual reservation class OrderServiceHybrid { async placeOrder(orderData: OrderData): Promise<Order> { const order = new Order(orderData); // SYNCHRONOUS check (fast, read-only) const available = await this.inventoryService.checkAvailability(order.items); if (!available.allAvailable) { throw new OrderFailedException("Items unavailable"); } await this.orderRepository.save(order); // Actual reservation via event (more robust) await this.eventDispatcher.dispatch({ type: "OrderPlaced", payload: order.toEventPayload(), }); // Note: Small race condition possible—another order might // reserve between check and actual reservation return order; }}There's no universally "right" answer. A B2C e-commerce site might prefer sync (immediate customer feedback). A B2B bulk ordering system might prefer async (orders are large, processed in batches). A high-volume flash sale might use the hybrid (prevent overselling but stay resilient). Know your requirements.
Here's a practical flowchart to guide your decisions. Start at the top and follow the questions.
┌─────────────────────────────────────────────────────────────────────────────┐
│ EVENT vs. DIRECT CALL DECISION TREE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Q1: Do you need a RETURN VALUE to proceed? │
│ │ │
│ ├── YES ────────────> Use DIRECT CALL (or Command) │
│ │ │
│ └── NO │
│ │ │
│ V │
│ Q2: Must the action complete ATOMICALLY with current operation? │
│ │ │
│ ├── YES ────────────> Use DIRECT CALL (same transaction) │
│ │ │
│ └── NO │
│ │ │
│ V │
│ Q3: Might MULTIPLE INDEPENDENT parties need to react? │
│ │ │
│ ├── YES ────────────> Use EVENT (broadcast to many) │
│ │ │
│ └── NO (exactly one) │
│ │ │
│ V │
│ Q4: Is the caller the AUTHORITY telling receiver what to do? │
│ │ │
│ ├── YES ────────────> Use COMMAND (direct or via bus) │
│ │ │
│ └── NO (just announcing) │
│ │ │
│ V │
│ Q5: Will you want to ADD MORE REACTIONS later? │
│ │ │
│ ├── LIKELY ─────────> Use EVENT (extensibility) │
│ │ │
│ └── UNLIKELY │
│ │ │
│ V │
│ Q6: Are caller and callee in DIFFERENT MODULES/BOUNDED CONTEXTS? │
│ │ │
│ ├── YES ────────────> Use EVENT (loose coupling) │
│ │ │
│ └── NO (same module) │
│ │ │
│ V │
│ DEFAULT: Use DIRECT CALL (simplicity wins) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
If you're unsure, start with direct calls. You can always refactor to events later if coupling becomes a problem. It's easier to add indirection when you need it than to remove premature abstraction. Don't optimize for extensibility you might never need.
Understanding when NOT to use events is as important as knowing when to use them. Here are common anti-patterns.
await eventBus.publish(event); const response = await waitForEvent('ResponseEvent')RequestUserProfile → handler returns UserProfileProvidedInventoryHandler must run before ShippingHandler which must run before NotificationHandler12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// ===== ANTI-PATTERN: Synchronous Event Waiting =====// DON'T DO THIS class BadOrderService { async placeOrder(order: Order): Promise<Order> { await this.eventBus.publish({ type: "ValidateOrder", payload: order }); // WRONG: Waiting for response event defeats the purpose of events const validationResult = await this.waitForEvent("OrderValidated", 5000); if (!validationResult.isValid) { throw new ValidationError(validationResult.errors); } await this.orderRepository.save(order); return order; } private async waitForEvent(type: string, timeout: number): Promise<any> { // This is an anti-pattern factory // You've recreated request/response with extra complexity }} // ===== CORRECT: Use direct call for validation ===== class GoodOrderService { async placeOrder(order: Order): Promise<Order> { // CORRECT: Validation needs a response—use direct call const validationResult = await this.validator.validate(order); if (!validationResult.isValid) { throw new ValidationError(validationResult.errors); } await this.orderRepository.save(order); // CORRECT: Notifications are fire-and-forget—use events await this.eventBus.publish({ type: "OrderPlaced", payload: order }); return order; }} // ===== ANTI-PATTERN: Event-Based Query =====// DON'T DO THIS async function getUserProfile(userId: string): Promise<UserProfile> { // WRONG: This is a query, not an event await this.eventBus.publish({ type: "RequestUserProfile", payload: { userId } }); const response = await this.waitForEvent("UserProfileProvided", 5000); return response.profile;} // ===== CORRECT: Direct call for queries ===== async function getUserProfile(userId: string): Promise<UserProfile> { // CORRECT: Queries are synchronous—call the service directly return await this.userService.getProfile(userId);}Here are actionable guidelines you can apply in your codebase today.
OrderPlacedHandler, OnOrderPlaced, handleOrderPlacedIf you have a god-class that coordinates too much, refactor gradually: extract one handler at a time. Keep the direct call initially, add an event alongside, verify the handler works, then remove the direct call. Incremental migration is safer than big-bang rewrites.
Here's a practical decision matrix for common scenarios you'll encounter. Use this as a quick reference.
| Scenario | Event? | Rationale |
|---|---|---|
| Validate user input before processing | No | Need immediate result; direct call |
| Send confirmation email after order | Yes | Fire-and-forget; order doesn't wait for email |
| Reserve inventory for order (strict) | No | Critical path; customer needs failure feedback |
| Reserve inventory for order (tolerant) | Yes | Business accepts async; customer notified later |
| Log audit entry for compliance | Yes | Cross-cutting; shouldn't pollute business logic |
| Track analytics for order | Yes | Optional; analytics failure shouldn't affect order |
| Calculate order total | No | Need result; cohesive with order logic |
| Notify warehouse of new order | Yes | Different bounded context; temporal decoupling |
| Save entity to database | No | Need confirmation of save; single handler |
| Notify multiple third-party integrations | Yes | Multiple independent parties; extensible |
| Process payment | Depends | Critical: command. Async-tolerant: event + saga |
| Update recommendation engine with purchase | Yes | Non-critical; ML can process async |
| Generate invoice PDF | Yes | Async OK; PDF service can be separate |
| Enrich customer profile with data | Yes | Background processing; not on critical path |
We've completed the introduction to event-driven design. Let's consolidate everything we've learned across this module.
Key Decision Criteria:
Anti-patterns to avoid:
What's next:
In the next module, we'll dive deep into Domain Events—how to identify, design, and implement events that represent meaningful business occurrences. We'll explore event naming conventions, payload design, and the relationship between domain events and domain-driven design.
Congratulations! You now have a solid foundation in event-driven design. You understand what events are, how they differ from commands, their benefits and trade-offs, and most importantly—when to use them. You're ready to learn how to design events that represent your domain effectively.