Loading learning content...
If domain events are the historical record of your system, then naming and structure are the vocabulary and grammar of that record. Poorly named events become cryptic references that future developers struggle to interpret. Well-named events read like a chronicle of business operations—clear, precise, and immediately understandable.
More than aesthetics, consistent event design enables:
This page establishes the naming conventions and structural patterns that make your domain events a reliable foundation for your architecture.
By the end of this page, you will master the naming conventions that make events self-documenting, understand the essential metadata every event needs, learn strategies for versioning and schema evolution, and recognize common anti-patterns in event design.
Event naming is deceptively important. A well-named event immediately communicates its meaning, while a poorly named event obscures intent and leads to misunderstandings. Follow these principles for consistent, clear event names.
OrderPlaced, not PlaceOrder. Past tense emphasizes that events are historical facts, not commands or requests.ShipmentDispatched is better than OrderFulfillmentProcessExecuted.OrderUpdated or EntityChanged. These lack semantic meaning. What specifically happened? OrderShipped, OrderCancelled, PriceAdjusted.Order + Placed = OrderPlaced. This makes the event self-documenting.OrderRecordInserted or OrderRowCreated.| Poor Name | Why It's Poor | Better Name |
|---|---|---|
| OrderUpdated | What update? Status? Items? Address? | OrderShipped, OrderItemRemoved, ShippingAddressChanged |
| OrderEvent | Tells nothing about what happened | OrderPlaced, OrderRefunded, OrderDelivered |
| PlaceOrder | Imperative mood (command, not event) | OrderPlaced |
| DoPayment | Command, not event; uses 'Do' prefix | PaymentReceived, PaymentProcessed |
| UserModified | Too vague; what was modified? | ProfileUpdated, PasswordChanged, EmailVerified |
| OrderStatusChanged | Hides the specific transition | OrderConfirmed, OrderShipped, OrderCancelled |
| Process | No subject, no specificity | PaymentProcessed, RefundProcessed |
| OrderCreatedEvent | 'Event' suffix is redundant | OrderPlaced (or OrderCreated) |
Grammatical Patterns:
Most well-named domain events follow one of these patterns:
OrderPlaced, PaymentReceived, AccountLockedOrderStatusChanged (if status is truly the concept), ProfilePhotoUpdatedShipmentDispatched, InvoiceSent, ContractSignedThe first pattern is most common and generally preferred for its clarity.
Read your events as a story: 'An OrderPlaced occurred for customer 123. Then a PaymentReceived was recorded. Finally, the order was ShipmentDispatched.' If this sounds natural to a domain expert, your naming is on track. If it sounds like database operations, reconsider.
One of the most important naming decisions is choosing the right level of granularity. Should you have one OrderUpdated event or multiple specific events like OrderShipped, OrderCancelled, OrderAddressChanged?
OrderUpdated, AccountModifiedOrderShipped, OrderCancelled, OrderAddressChangedRecommendation: Favor fine-grained events
Fine-grained events have significant advantages:
Explicit semantics: OrderCancelled is immediately clear; OrderUpdated with {status: 'CANCELLED'} requires interpretation.
Selective subscription: An email service might care about OrderShipped but not OrderAddressChanged. Fine-grained events enable precise subscriptions.
Simpler handlers: Each handler focuses on one occurrence type, reducing complexity.
Better for event sourcing: Specific events reconstruct state more clearly than generic 'something changed' events.
Cleaner audit trails: 'AccountLocked due to fraud detection' is more informative than 'Account updated'.
The exception: Sometimes a domain genuinely has a single 'thing that happened' with multiple effects. In such cases, one event is appropriate. But be suspicious if you're reaching for generic names—it often indicates missing domain understanding.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
// ❌ COARSE-GRAINED: Generic event hides meaningclass OrderUpdated { constructor( readonly eventId: string, readonly occurredAt: Date, readonly orderId: string, readonly changeType: string, // 'STATUS_CHANGE' | 'ITEM_ADDED' | 'ADDRESS_UPDATED' readonly previousValue: unknown, readonly newValue: unknown, ) {}} // Handler becomes complex:function handleOrderUpdated(event: OrderUpdated) { switch (event.changeType) { case 'STATUS_CHANGE': if (event.newValue === 'SHIPPED') { // handle shipping... } else if (event.newValue === 'CANCELLED') { // handle cancellation... } break; case 'ITEM_ADDED': // different handling... break; // ... more cases }} // ✅ FINE-GRAINED: Each event is explicitclass OrderShipped implements DomainEvent { readonly eventType = 'OrderShipped'; constructor( readonly eventId: string, readonly occurredAt: Date, readonly orderId: string, readonly shipmentId: string, readonly trackingNumber: string, readonly carrier: string, ) {}} class OrderCancelled implements DomainEvent { readonly eventType = 'OrderCancelled'; constructor( readonly eventId: string, readonly occurredAt: Date, readonly orderId: string, readonly reason: CancellationReason, readonly cancelledBy: string, ) {}} class OrderItemAdded implements DomainEvent { readonly eventType = 'OrderItemAdded'; constructor( readonly eventId: string, readonly occurredAt: Date, readonly orderId: string, readonly productId: string, readonly quantity: number, readonly unitPrice: Money, ) {}} // Handlers are simple and focused:function handleOrderShipped(event: OrderShipped) { notificationService.sendShippingEmail({ orderId: event.orderId, trackingNumber: event.trackingNumber, carrier: event.carrier, });} function handleOrderCancelled(event: OrderCancelled) { inventoryService.releaseReservedStock(event.orderId); paymentService.initiateRefund(event.orderId);}Every domain event needs certain metadata for identification, ordering, versioning, and traceability. A well-structured event separates these concerns into clear sections.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
/** * Base interface for all domain events with essential metadata. * This interface ensures consistency across all events in the system. */interface DomainEventMetadata { /** Unique identifier for this event instance */ readonly eventId: string; /** Type discriminator for routing and deserialization */ readonly eventType: string; /** When this event occurred in the domain */ readonly occurredAt: Date; /** Schema version for handling evolution */ readonly eventVersion: number; /** ID of the aggregate that raised this event */ readonly aggregateId: string; /** Type of the aggregate (for partitioning/routing) */ readonly aggregateType: string; /** Position in aggregate's event stream (optional but recommended) */ readonly sequenceNumber?: number; /** Links events in the same business transaction */ readonly correlationId?: string; /** ID of the command/event that caused this one */ readonly causationId?: string;} /** * Full domain event structure separating metadata from payload. */interface DomainEvent<TPayload> extends DomainEventMetadata { /** The business-specific data for this event */ readonly payload: TPayload;} /** * Payload for OrderPlaced event - the business-specific content */interface OrderPlacedPayload { readonly customerId: string; readonly orderNumber: string; readonly items: ReadonlyArray<OrderLineItem>; readonly totalAmount: Money; readonly shippingAddress: Address; readonly customerEmail: string;} /** * Factory function to create events with consistent metadata */function createOrderPlacedEvent( aggregateId: string, payload: OrderPlacedPayload, context: EventContext,): DomainEvent<OrderPlacedPayload> { return { // Event identity eventId: generateUUID(), eventType: 'OrderPlaced', eventVersion: 1, occurredAt: new Date(), // Aggregate context aggregateId, aggregateType: 'Order', sequenceNumber: context.nextSequenceNumber, // Tracing context correlationId: context.correlationId, causationId: context.commandId, // Business payload payload, };} /** * Alternative approach: Event class with metadata built-in */abstract class BaseDomainEvent implements DomainEventMetadata { readonly eventId: string; readonly occurredAt: Date; readonly correlationId?: string; readonly causationId?: string; abstract readonly eventType: string; abstract readonly eventVersion: number; abstract readonly aggregateId: string; abstract readonly aggregateType: string; constructor(context?: EventContext) { this.eventId = generateUUID(); this.occurredAt = new Date(); this.correlationId = context?.correlationId; this.causationId = context?.causationId; }} class OrderPlaced extends BaseDomainEvent { readonly eventType = 'OrderPlaced'; readonly eventVersion = 1; readonly aggregateType = 'Order'; constructor( readonly aggregateId: string, readonly customerId: string, readonly orderNumber: string, readonly items: ReadonlyArray<OrderLineItem>, readonly totalAmount: Money, readonly shippingAddress: Address, readonly customerEmail: string, context?: EventContext, ) { super(context); }}Correlation ID links all events in a business transaction (e.g., everything stemming from one API request). Causation ID shows the direct cause of this specific event (the command that triggered it, or another event in a saga). Both are valuable for debugging distributed systems.
In distributed systems, a single user action can trigger cascading events across multiple services. Tracking these chains is essential for debugging, auditing, and understanding system behavior.
Scenario: Customer places an order → Payment is processed → Inventory is reserved → Confirmation email is sent → Analytics are updated
When something goes wrong (email wasn't sent), you need to trace backward through the chain. Correlation and causation IDs make this possible.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
/** * Example: Tracing a purchase flow through events * * All events share the same correlationId (the purchase transaction). * Each event's causationId points to what directly triggered it. */ // 1. Customer places order (initiated by PlaceOrderCommand)const orderPlaced: DomainEvent = { eventId: 'evt-001', eventType: 'OrderPlaced', occurredAt: new Date('2024-01-15T10:00:00Z'), aggregateId: 'order-123', aggregateType: 'Order', correlationId: 'txn-abc', // The purchase transaction causationId: 'cmd-place-001', // PlaceOrderCommand // ... payload}; // 2. Payment service processes payment (triggered by OrderPlaced)const paymentReceived: DomainEvent = { eventId: 'evt-002', eventType: 'PaymentReceived', occurredAt: new Date('2024-01-15T10:00:05Z'), aggregateId: 'payment-456', aggregateType: 'Payment', correlationId: 'txn-abc', // Same transaction causationId: 'evt-001', // Caused by OrderPlaced // ... payload}; // 3. Inventory service reserves stock (triggered by OrderPlaced)const inventoryReserved: DomainEvent = { eventId: 'evt-003', eventType: 'InventoryReserved', occurredAt: new Date('2024-01-15T10:00:02Z'), aggregateId: 'product-789', aggregateType: 'Inventory', correlationId: 'txn-abc', // Same transaction causationId: 'evt-001', // Also caused by OrderPlaced // ... payload}; // 4. Email sent after payment confirmed (triggered by PaymentReceived)const confirmationEmailSent: DomainEvent = { eventId: 'evt-004', eventType: 'OrderConfirmationEmailSent', occurredAt: new Date('2024-01-15T10:00:07Z'), aggregateId: 'order-123', aggregateType: 'Notification', correlationId: 'txn-abc', // Same transaction causationId: 'evt-002', // Caused by PaymentReceived // ... payload}; /** * Building the causal chain for debugging: * * txn-abc (correlationId) links all events in this purchase * * Causal chain: * cmd-place-001 (PlaceOrderCommand) * └── evt-001 (OrderPlaced) * ├── evt-002 (PaymentReceived) * │ └── evt-004 (ConfirmationEmailSent) * └── evt-003 (InventoryReserved) */ // Query: Find all events in a transactionasync function findEventsByCorrelation(correlationId: string): Promise<DomainEvent[]> { return eventStore.query({ correlationId });} // Query: Build causal tree for debuggingasync function buildCausalTree(eventId: string): Promise<CausalTree> { const root = await eventStore.findById(eventId); const children = await eventStore.query({ causationId: eventId }); return { event: root, children: await Promise.all( children.map(child => buildCausalTree(child.eventId)) ), };}When a customer reports an issue, find the correlation ID from any event or log. Query all events with that correlation ID. You now have the complete story of that business transaction across all services.
Events are immutable, but event schemas evolve. New fields are added. Old fields become obsolete. Payload structures change. How do you handle this evolution while maintaining compatibility with historical events and diverse consumers?
OrderPlacedV2) when changes are breaking. Old and new can coexist.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
/** * Strategy 1: Weak Schema with Optional Fields * * Add new fields as optional. Consumers handle absence gracefully. */interface OrderPlacedV1 { orderId: string; customerId: string; items: OrderItem[]; total: Money;} // Later, we need customer email for notificationsinterface OrderPlacedV2 extends OrderPlacedV1 { customerEmail?: string; // Optional for backward compatibility} // Consumer handles gracefully:function handleOrderPlaced(event: OrderPlacedV2) { if (event.customerEmail) { sendConfirmation(event.customerEmail); } else { // Fallback: fetch email from customer service const email = await customerService.getEmail(event.customerId); sendConfirmation(email); }} /** * Strategy 2: Explicit Version Numbers * * Version is part of the event, enabling precise handling. */interface OrderPlaced_v1 { eventVersion: 1; orderId: string; customerId: string; total: Money;} interface OrderPlaced_v2 { eventVersion: 2; orderId: string; customerId: string; total: Money; customerEmail: string; // Now required orderNumber: string; // New field} type OrderPlaced = OrderPlaced_v1 | OrderPlaced_v2; function handleOrderPlaced(event: OrderPlaced) { switch (event.eventVersion) { case 1: // Handle v1 format return handleV1(event); case 2: // Handle v2 format with new fields return handleV2(event); default: throw new UnknownEventVersionError(event); }} /** * Strategy 3: Event Upcasting * * Transform old events to latest format on read. * Historical storage is unchanged; consumers see current schema. */interface EventUpcaster<TFrom, TTo> { fromVersion: number; toVersion: number; upcast(event: TFrom): TTo;} const orderPlacedUpcaster: EventUpcaster<OrderPlaced_v1, OrderPlaced_v2> = { fromVersion: 1, toVersion: 2, upcast(v1: OrderPlaced_v1): OrderPlaced_v2 { return { eventVersion: 2, orderId: v1.orderId, customerId: v1.customerId, total: v1.total, customerEmail: 'unknown@legacy.com', // Default for legacy orderNumber: generateLegacyOrderNumber(v1.orderId), }; },}; // Event store applies upcasters automatically:class UpcastingEventStore { private upcasters: Map<string, EventUpcaster<any, any>[]>; async loadEvents(aggregateId: string): Promise<DomainEvent[]> { const rawEvents = await this.storage.load(aggregateId); return rawEvents.map(event => this.upcast(event)); } private upcast(event: DomainEvent): DomainEvent { const chain = this.upcasters.get(event.eventType) || []; let current = event; for (const upcaster of chain) { if (current.eventVersion === upcaster.fromVersion) { current = upcaster.upcast(current); } } return current; }} /** * Strategy 4: New Event Types for Breaking Changes * * When changes are too significant, create a new event type. */// Original eventclass CustomerRegistered implements DomainEvent { readonly eventType = 'CustomerRegistered'; constructor( readonly customerId: string, readonly email: string, readonly name: string, ) {}} // Major restructuring required - create new eventclass CustomerRegisteredV2 implements DomainEvent { readonly eventType = 'CustomerRegisteredV2'; // New type constructor( readonly customerId: string, readonly email: string, readonly firstName: string, // Name split into parts readonly lastName: string, readonly phone?: string, // New optional field readonly preferences: CustomerPreferences, ) {}} // Both events can coexist; consumers subscribe to what they needChanging field types, removing required fields, or altering semantic meaning are breaking changes. Use upcasters or new event types. Never modify historical events in storage—this violates immutability and breaks event sourcing.
Understanding what not to do is as important as knowing best practices. These anti-patterns frequently appear in codebases and lead to maintenance headaches.
OrderEvent { action: 'PLACED' | 'SHIPPED' | 'CANCELLED' }. This hides meaning in the payload and complicates handling.CreateOrder, UpdatePayment, DeleteAccount. These are commands (things we want to happen), not events (things that happened). Use past tense.OrderTableRowInserted, CacheRefreshed, QueueMessagePublished. These expose implementation details. Events should be domain concepts.OrderPlacedEvent, PaymentReceivedEvent. The 'Event' suffix adds no value and clutters names. We know they're events by context.OrderHandled, CustomerProcessed. What does 'handled' or 'processed' mean? Be specific about what happened.OrderCreated, OrderUpdated, OrderDeleted. These mirror database operations, not domain occurrences. What business event does 'updated' represent?Registered, Approved, Completed. What was registered? What was approved? Always include the subject for clarity.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// ❌ MEGA-EVENT ANTI-PATTERNclass OrderEvent { constructor( readonly eventId: string, readonly orderId: string, readonly action: 'PLACED' | 'SHIPPED' | 'CANCELLED' | 'RETURNED', readonly data: unknown, // Polymorphic payload ) {}} // Every handler must check the action:function handleOrderEvent(event: OrderEvent) { switch (event.action) { case 'PLACED': // What type is data here? Need to cast... const placedData = event.data as PlacedData; // ... break; case 'SHIPPED': const shippedData = event.data as ShippedData; // ... break; // complexity grows with each action }} // ✅ CORRECT: Specific event typesclass OrderPlaced { /* ... */ }class OrderShipped { /* ... */ }class OrderCancelled { /* ... */ }class OrderReturned { /* ... */ } // ❌ COMMAND MASQUERADING AS EVENTclass CreateOrder { // Imperative = command constructor( readonly customerId: string, readonly items: Item[], ) {}} // ✅ CORRECT: Past tense = eventclass OrderPlaced { constructor( readonly orderId: string, readonly customerId: string, readonly items: Item[], ) {}} // ❌ CRUD EVENTS - mirror database, not domainclass OrderCreated { /* ... */ }class OrderUpdated { /* fields changed */ }class OrderDeleted { /* ... */ } // ✅ CORRECT: Domain events - describe business occurrencesclass OrderPlaced { /* ... */ }class OrderItemAdded { /* ... */ }class ShippingAddressChanged { /* ... */ }class OrderCancelled { /* with reason */ } // ❌ NO SUBJECTclass Approved { /* What was approved? */ } // ✅ CORRECT: Include subjectclass LoanApplicationApproved { /* ... */ }class ExpenseReportApproved { /* ... */ }Before finalizing any domain event, run through this checklist to ensure it meets quality standards:
In Event Storming workshops, domain experts write events on orange sticky notes. If they naturally write 'Order Placed', 'Payment Failed', 'Shipment Delayed'—you have good event names. If they struggle to express something concisely, the underlying domain model may need refinement.
Let's consolidate the key principles for event naming and structure:
OrderPlaced, PaymentReceived.OrderShipped over generic OrderUpdated.What's next:
Now that we know how to name and structure events, we'll explore the mechanics of raising and handling events—how events flow from their origin in aggregates through dispatchers to handlers, and the different patterns for managing this lifecycle.
You now have a comprehensive understanding of event naming conventions, structural patterns, versioning strategies, and anti-patterns to avoid. These principles will guide you in creating clear, maintainable, and evolvable domain events.