Loading learning content...
Designing domain events is not merely a technical exercise—it's an art that requires deep understanding of the business domain, careful consideration of consumers' needs, and foresight about how events will evolve over time. A well-designed event captures the essence of a business occurrence, provides sufficient context for consumers to react appropriately, and remains stable as the system evolves.
Poorly designed events create confusion, tight coupling, and maintenance nightmares. They either carry too little information (forcing consumers to query back), too much information (leaking implementation details), or the wrong information (requiring constant refactoring). This page equips you with the principles and techniques to design events that stand the test of time.
By the end of this page, you will master the techniques for identifying domain events from business requirements, structuring events with appropriate granularity, ensuring events are self-contained and meaningful, and designing for evolution. You'll gain the judgment to create events that serve both immediate and future needs.
The first challenge in event design is identifying which events to create. Not every state change deserves an event. Domain events should capture business-significant occurrences—moments that domain experts would recognize and care about.
Techniques for Identifying Events:
1. Listen to Domain Experts
Domain experts naturally speak in events. Listen for phrases like:
Each of these phrases signals a potential domain event.
2. Analyze Business Processes
Walk through complete business workflows end-to-end. At each step, ask: "What significant thing just happened?" Each answer is a potential event.
3. Look for State Transitions
Examine your entities and aggregates. When do they change state? State transitions often correspond to domain events. An order moving from placed to paid to shipped represents three distinct events.
4. Consider External Triggers
What external inputs cause reactions in your system? A payment gateway callback, a delivery confirmation, an inventory alert—these are often events.
| Domain Expert Statement | Identified Event | Why It Matters |
|---|---|---|
| "When a new customer signs up..." | CustomerRegistered | Triggers welcome flow, updates metrics, possibly notifies sales |
| "After the payment goes through..." | PaymentSucceeded | Enables order fulfillment, updates financial records |
| "If an item is out of stock..." | InventoryDepleted | Prevents overselling, triggers restocking, notifies operations |
| "Once the package is delivered..." | OrderDelivered | Closes order lifecycle, triggers review request, updates delivery stats |
| "When a user abandons their cart..." | CartAbandoned | Triggers remarketing, provides conversion insights |
For each potential event, ask: 'So what? What happens next?' If you can't identify any meaningful reactions or interested parties, you may not need that event. Events exist to inform other parts of the system—if nothing cares, the event is noise.
One of the most critical decisions in event design is granularity—how specific or general should your events be? This decision affects coupling, flexibility, and system complexity.
The Granularity Spectrum:
At one extreme, you could have a single generic event: EntityChanged. At the other extreme, you could have thousands of ultra-specific events: CustomerEmailUpdatedFromGmailToYahoo. Both extremes are problematic.
OrderChanged — Changed how? What do I do?OrderShippingAddressStreetChanged — Too narrowThe Sweet Spot: Business-Meaningful Granularity
Events should be granular enough to convey clear intent, but general enough to remain stable as implementation details change. The test is: Does this event represent something the business would recognize and name?
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// ❌ Too coarse: What changed? What should I do?interface OrderUpdated { eventType: 'OrderUpdated'; orderId: string; changes: Record<string, unknown>; // No semantic meaning} // ❌ Too fine: Implementation detail leakageinterface OrderShippingAddressCityChanged { eventType: 'OrderShippingAddressCityChanged'; orderId: string; oldCity: string; newCity: string;} // ✅ Just right: Business-meaningful granularityinterface OrderShippingAddressUpdated { eventType: 'OrderShippingAddressUpdated'; orderId: string; previousAddress: Address; // Full context newAddress: Address; reason: 'customer_request' | 'address_correction' | 'delivery_issue';} // ✅ Just right: Clear intent, right level of detailinterface OrderPlaced { eventType: 'OrderPlaced'; orderId: string; customerId: string; items: OrderItem[]; totalAmount: Money; shippingAddress: Address;} // ✅ Just right: State transition with business meaninginterface OrderCancelled { eventType: 'OrderCancelled'; orderId: string; cancellationReason: CancellationReason; cancelledBy: 'customer' | 'system' | 'admin'; refundAmount: Money;} // Types that support the eventsinterface Address { street: string; city: string; state: string; postalCode: string; country: string;} interface Money { amount: number; currency: string;} type CancellationReason = | 'customer_changed_mind' | 'payment_failed' | 'out_of_stock' | 'fraud_detected' | 'delivery_impossible';A useful heuristic: If you could explain the event to a domain expert in one sentence without mentioning technical details, you likely have the right granularity. 'An order was cancelled because the customer changed their mind' is clear; 'The order entity's status field was updated from ACTIVE to CANCELLED' is too technical.
A critical principle of event design is self-containment: an event should carry all the information a consumer needs to react appropriately, without requiring them to query back to the source.
Why Self-Containment Matters:
Temporal Decoupling: The source system might be unavailable when the consumer processes the event. If the event lacks data, the consumer fails.
State Consistency: By the time a consumer processes an event, the source data may have changed. The event captures a snapshot of state at the time of occurrence.
Performance: Querying back adds latency and load. Self-contained events eliminate unnecessary network calls.
Scalability: In distributed systems, back-queries create dependencies that limit independent scaling.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
// ❌ Not self-contained: Forces consumer to query backinterface OrderPlacedMinimal { eventType: 'OrderPlaced'; orderId: string; // Consumer must fetch order details customerId: string; // Consumer must fetch customer // What items? What price? What address? WHERE ARE THEY?} // Handler is forced to make synchronous queriesclass PoorNotificationHandler { async handle(event: OrderPlacedMinimal): Promise<void> { // These queries might fail, might return changed data, // or might be slow under load const order = await this.orderService.getById(event.orderId); const customer = await this.customerService.getById(event.customerId); await this.emailService.send({ to: customer.email, // What if customer was deleted? subject: 'Order Confirmation', body: this.buildOrderEmail(order) // What if order changed? }); }} // ✅ Self-contained: Everything needed for notificationinterface OrderPlacedComplete { eventType: 'OrderPlaced'; eventId: string; occurredAt: Date; // Order details - snapshot at time of placement orderId: string; orderNumber: string; // Human-readable order number // Customer details - snapshot at time of placement customerId: string; customerEmail: string; // Denormalized for immediate use customerName: string; // Denormalized for personalization // Order content - complete picture items: Array<{ productId: string; productName: string; // Snapshot of name at time of order quantity: number; unitPrice: number; totalPrice: number; }>; subtotal: number; shippingCost: number; tax: number; totalAmount: number; currency: string; // Shipping - complete address for confirmation shippingAddress: { recipientName: string; street: string; city: string; state: string; postalCode: string; country: string; }; estimatedDeliveryDate: string;} // Handler is self-sufficient and resilientclass GoodNotificationHandler { async handle(event: OrderPlacedComplete): Promise<void> { // No queries needed - event has everything await this.emailService.send({ to: event.customerEmail, subject: `Order ${event.orderNumber} Confirmed`, body: this.buildEmailFromEvent(event) }); } private buildEmailFromEvent(event: OrderPlacedComplete): string { // All data comes from the event return ` Hi ${event.customerName}, Thank you for your order #${event.orderNumber}! Items: ${event.items.map(i => `- ${i.productName} x${i.quantity}: ${event.currency} ${i.totalPrice}`).join('')} Total: ${event.currency} ${event.totalAmount} Shipping to: ${event.shippingAddress.recipientName} ${event.shippingAddress.street} ${event.shippingAddress.city}, ${event.shippingAddress.state} ${event.shippingAddress.postalCode} Estimated delivery: ${event.estimatedDeliveryDate} `; }}Self-containment doesn't mean including everything possible. Include what consumers need, not everything that exists. An OrderPlaced event for notification purposes doesn't need the customer's entire purchase history. Consider different event types for different consumer needs if payload would become unwieldy.
The payload is the heart of an event—it carries the data that gives the event meaning. Thoughtful payload design ensures events are useful, efficient, and evolvable.
Essential Payload Elements:
Every domain event should include certain metadata regardless of its specific purpose:
OrderPlaced, PaymentFailed.Order, Customer). Useful for routing and filtering.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
// Base interface for all domain eventsinterface DomainEventBase { // Identity readonly eventId: string; // UUID for this event instance readonly eventType: string; // 'OrderPlaced', 'PaymentReceived', etc. readonly version: number; // Schema version for evolution // Timing readonly occurredAt: Date; // When it happened // Source readonly aggregateId: string; // ID of the source aggregate readonly aggregateType: string; // 'Order', 'Customer', etc. readonly aggregateVersion: number; // Aggregate version for optimistic concurrency // Correlation readonly correlationId: string; // Links related events in a flow readonly causationId: string; // What triggered this event} // Concrete event with specific payloadinterface OrderPlacedEvent extends DomainEventBase { readonly eventType: 'OrderPlaced'; readonly version: 1; readonly aggregateType: 'Order'; // Business payload readonly payload: { readonly customerId: string; readonly customerEmail: string; readonly items: ReadonlyArray<OrderItemData>; readonly pricing: OrderPricingData; readonly shipping: ShippingData; };} interface OrderItemData { readonly productId: string; readonly productName: string; readonly sku: string; readonly quantity: number; readonly unitPrice: number; readonly discount: number;} interface OrderPricingData { readonly subtotal: number; readonly discountTotal: number; readonly shippingCost: number; readonly taxAmount: number; readonly total: number; readonly currency: string;} interface ShippingData { readonly method: 'standard' | 'express' | 'overnight'; readonly address: AddressData; readonly estimatedDeliveryDate: string;} interface AddressData { readonly recipientName: string; readonly line1: string; readonly line2?: string; readonly city: string; readonly state: string; readonly postalCode: string; readonly country: string; readonly phone?: string;} // Factory function to create well-formed eventsfunction createOrderPlacedEvent( order: Order, customer: Customer, correlationId: string, causationId: string): OrderPlacedEvent { return { eventId: generateUUID(), eventType: 'OrderPlaced', version: 1, occurredAt: new Date(), aggregateId: order.id, aggregateType: 'Order', aggregateVersion: order.version, correlationId, causationId, payload: { customerId: customer.id, customerEmail: customer.email, items: order.items.map(item => ({ productId: item.product.id, productName: item.product.name, sku: item.product.sku, quantity: item.quantity, unitPrice: item.unitPrice, discount: item.discount })), pricing: { subtotal: order.subtotal, discountTotal: order.discountTotal, shippingCost: order.shippingCost, taxAmount: order.taxAmount, total: order.total, currency: order.currency }, shipping: { method: order.shippingMethod, address: { recipientName: order.shippingAddress.name, line1: order.shippingAddress.line1, line2: order.shippingAddress.line2, city: order.shippingAddress.city, state: order.shippingAddress.state, postalCode: order.shippingAddress.postalCode, country: order.shippingAddress.country, phone: order.shippingAddress.phone }, estimatedDeliveryDate: order.estimatedDeliveryDate.toISOString() } } };}Payload Design Principles:
Use Primitive Types Where Possible: Strings, numbers, booleans serialize cleanly across all systems. Avoid language-specific types in integration events.
Denormalize for Consumer Convenience: Include data consumers need directly. If every consumer needs the customer email, include it rather than just the customer ID.
Snapshot State, Don't Reference It: The event captures reality at the moment it occurred. Include the product name that was displayed to the customer, not just the product ID.
Be Consistent Across Events: If one event uses customerId, don't use customer_id in another. Establish naming conventions and enforce them.
Consider Locale and Format: Dates should use ISO 8601. Money should include currency. Times should include timezone. This prevents ambiguity.
Just as important as knowing what to include is knowing what to exclude from events. Including the wrong data creates coupling, security risks, and maintenance burdens.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// ❌ BAD: Event with problematic inclusionsinterface BadOrderPlacedEvent { // Implementation details leaking _databaseRowId: number; // Internal DB ID _ormVersion: number; // ORM metadata _createdByClass: string; // Implementation detail // Sensitive data exposure customerCreditCard: string; // NEVER in events! customerSocialSecurity: string; // NEVER in events! customerPassword: string; // NEVER in events! // Constantly changing derived data customerLifetimeSpend: number; // Changes with every order customerLoyaltyTier: string; // Changes over time // Large binary data productImages: Buffer[]; // Events should be small invoicePdf: string; // Base64 encoded? No! // Circular reference mess customer: { id: string; orders: Order[]; // Circular! And huge! };} // ✅ GOOD: Clean event with appropriate datainterface GoodOrderPlacedEvent { // Standard metadata eventId: string; eventType: 'OrderPlaced'; occurredAt: Date; // Relevant IDs only orderId: string; customerId: string; // Denormalized data needed by consumers customerEmail: string; customerName: string; // Snapshot of order state at placement items: Array<{ productId: string; productName: string; quantity: number; price: number; }>; totalAmount: number; currency: string; // References for large data invoiceUrl: string; // URL to fetch invoice, not the invoice itself // Masked sensitive data if absolutely needed paymentMethodLast4: string; // '**** **** **** 4242' paymentMethodType: 'visa' | 'mastercard' | 'amex';}Remember that events may be stored for replay, audit, or debugging. Anything in an event could exist forever. A credit card number in an event from 5 years ago is a compliance nightmare. Design events with the assumption that they're permanent and potentially widely visible.
Several patterns emerge in well-designed event systems. Understanding these patterns helps you structure events consistently and make informed design decisions.
Notification Events carry minimal payload—just enough to inform consumers that something happened. Consumers query for additional details if needed.
Characteristics:
Use When:
1234567891011121314151617
// Notification event: Minimal payloadinterface OrderPlacedNotification { eventType: 'OrderPlaced'; eventId: string; occurredAt: Date; orderId: string; customerId: string; // That's it - consumers query for more} // Consumer fetches what it needsclass ReportingHandler { async handle(event: OrderPlacedNotification) { const order = await this.orderApi.getOrder(event.orderId); await this.reportingService.recordOrder(order); }}Great event design requires empathy for consumers. You're not just recording what happened—you're creating a contract that consumers depend on. Consider their needs as part of your design process.
Consumer-Centric Design Questions:
The Unknown Consumer Challenge:
In truly decoupled systems, you may not know all current or future consumers. Design events with this uncertainty in mind:
Include More Than Less: It's easier for consumers to ignore fields than to be blocked by missing data.
Follow Conventions: Consistent naming and structure helps new consumers understand events quickly.
Provide Good Documentation: Document what each field means and when it's populated.
Version Explicitly: Make it clear when event structure changes so consumers can adapt.
Avoid Breaking Changes: Adding fields is safe; removing or renaming is dangerous.
Think of your event as an API contract. Just as you wouldn't randomly change a REST API response, you shouldn't randomly change event structure. Each event type is a contract with all its consumers, known and unknown. Treat it with the same care you'd give any public API.
Designing domain events well is crucial for building robust event-driven systems. Let's consolidate the key principles we've covered:
What's Next:
Now that you understand how to design events structurally, the next page focuses specifically on event naming and structure—the conventions and best practices that make events discoverable, understandable, and consistent across your system.
You now have a comprehensive framework for designing domain events. You can identify meaningful events, structure them appropriately, and design for both immediate consumers and future evolution. Next, we'll dive deeper into naming conventions and structural consistency.