Loading learning content...
In the physical world, significant events happen constantly. A customer places an order. A payment is processed. An account is locked. A shipment is dispatched. These aren't just state changes—they're moments that matter to the business.\n\nTraditional object-oriented design focuses primarily on entities and their current state. But something crucial gets lost in this approach: the history of how that state came to be. When a customer's account shows "locked," we know the current state, but we don't inherently know when it was locked, why it was locked, or what sequence of events led to this moment.\n\nDomain Events capture these significant moments as first-class objects in your domain model. They represent something that happened—past tense, immutable, and carrying all the context needed to understand what occurred.
By the end of this page, you will understand what domain events are, why they're essential to sophisticated domain modeling, how they differ from technical infrastructure events, and the fundamental characteristics that make them powerful tools for capturing business meaning.
Consider a simple e-commerce order entity. In a traditional approach, the order has properties like status, total, items, and shippingAddress. When something happens—say, the order is shipped—we update the status:\n\ntypescript\norder.status = OrderStatus.SHIPPED;\norder.shippedAt = new Date();\n\n\nThis works for basic use cases, but it creates several problems as systems grow in complexity:
The fundamental issue: Traditional state-centric modeling treats everything as a snapshot. But business processes are temporal—they unfold over time as sequences of events. Ignoring this temporal dimension creates impedance mismatch between how the business thinks and how the software models.
Listen carefully to how domain experts describe their processes. They rarely say 'the order has status shipped.' They say 'the order was shipped.' They speak in events, in things that happened. Domain events let your code speak the same language.
A Domain Event is an immutable representation of something significant that happened in the domain. The key word here is happened—past tense. Domain events are facts about the past that cannot be changed.\n\nFormal Definition:\n\n> A Domain Event is a record of a business-significant occurrence within a bounded context. It captures the fact that something happened, along with all contextual information needed to understand the occurrence, expressed in the ubiquitous language of the domain.\n\nLet's break down this definition:
| Characteristic | Meaning | Implication |
|---|---|---|
| Record | A persistent, storable representation | Can be saved, transmitted, replayed, and analyzed |
| Business-significant | Meaningful to domain experts | Not technical infrastructure; captures business semantics |
| Occurrence | Something that happened | Past tense, immutable, represents a fact |
| Bounded Context | Constrained to a specific domain boundary | Meaning is precise within its context |
| Contextual Information | All relevant data at time of occurrence | Self-contained; no need to query other systems |
| Ubiquitous Language | Uses domain terminology | Named and structured as domain experts would describe it |
Examples of Domain Events:\n\n- OrderPlaced — A customer completed their order\n- PaymentReceived — Money was credited to our account\n- ShipmentDispatched — Physical goods left our warehouse\n- AccountLocked — A user account was suspended\n- InventoryDepleted — Stock level reached zero\n- PriceAdjusted — A product's price was changed\n- MembershipUpgraded — A customer moved to a higher tier\n\nNotice the naming pattern: PastTenseVerb or NounVerbed. This grammatical convention reinforces that events represent things that have already happened.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
/** * Base interface for all domain events. * Every event captures when it occurred and provides a type discriminator. */interface DomainEvent { /** Unique identifier for this event instance */ readonly eventId: string; /** When this event occurred in the domain */ readonly occurredAt: Date; /** Type discriminator for event handling */ readonly eventType: string; /** Version for event schema evolution */ readonly eventVersion: number;} /** * OrderPlaced Domain Event * * Captures the business-significant occurrence of a customer * completing an order. Contains all context needed to understand * and process this occurrence. */class OrderPlaced implements DomainEvent { readonly eventType = 'OrderPlaced'; readonly eventVersion = 1; constructor( readonly eventId: string, readonly occurredAt: Date, readonly orderId: string, readonly customerId: string, readonly items: ReadonlyArray<OrderLineItem>, readonly totalAmount: Money, readonly shippingAddress: Address, readonly placedBy: UserId, ) { // All fields are readonly - event is immutable from construction }} /** * AccountLocked Domain Event * * Captures why and when an account was locked, preserving * the business context that led to this state change. */class AccountLocked implements DomainEvent { readonly eventType = 'AccountLocked'; readonly eventVersion = 1; constructor( readonly eventId: string, readonly occurredAt: Date, readonly accountId: string, readonly reason: LockReason, readonly lockedBy: ActorId, readonly expiresAt: Date | null, readonly failedAttempts: number | null, ) {}} /** Enumeration of business reasons for account locking */enum LockReason { FAILED_LOGIN_ATTEMPTS = 'FAILED_LOGIN_ATTEMPTS', SUSPECTED_FRAUD = 'SUSPECTED_FRAUD', TERMS_VIOLATION = 'TERMS_VIOLATION', ADMINISTRATIVE_HOLD = 'ADMINISTRATIVE_HOLD', PAYMENT_DISPUTE = 'PAYMENT_DISPUTE',}A critical distinction that often confuses developers new to DDD is the difference between domain events and technical/infrastructure events. While they may share similar mechanisms, their purposes and designs are fundamentally different.
Examples of Technical Events That Are NOT Domain Events:\n\n- EntitySaved — Generic persistence notification\n- CacheInvalidated — Infrastructure concern\n- DatabaseRowUpdated — Technical storage detail\n- HttpRequestReceived — Infrastructure layer event\n- QueueMessageProcessed — Messaging infrastructure\n\nWhy this distinction matters:\n\nTechnical events often sneak into domain models when developers conflate persistence with domain behavior. If you fire an event whenever any entity is saved, you haven't captured domain meaning—you've captured infrastructure mechanics. A domain expert doesn't care that a database row was updated; they care that an order was shipped.
Ask yourself: Would a domain expert recognize and care about this event? If a warehouse manager would say 'Yes, when an order is shipped, we need to update inventory'—that's a domain event. If only developers care about it, it's a technical event that belongs in infrastructure, not the domain model.
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// ❌ WRONG: Technical event masquerading as domain eventclass EntityUpdated { constructor( readonly entityType: string, // Generic, not domain-specific readonly entityId: string, readonly changedFields: string[], // Technical detail readonly previousValues: object, // ORM-level concern readonly newValues: object, ) {}} // ✅ RIGHT: True domain event with business meaningclass OrderShipped implements DomainEvent { readonly eventType = 'OrderShipped'; readonly eventVersion = 1; constructor( readonly eventId: string, readonly occurredAt: Date, readonly orderId: string, readonly shipmentId: string, readonly carrier: ShippingCarrier, readonly trackingNumber: string, readonly estimatedDelivery: Date, readonly shippedItems: ReadonlyArray<ShippedItem>, readonly warehouseId: string, ) {}} // ❌ WRONG: Using technical event for domain behaviororderRepository.onEntitySaved((entity) => { if (entity.status === 'SHIPPED') { emailService.sendShippingNotification(entity); }}); // ✅ RIGHT: Using domain event for domain behavioreventBus.subscribe('OrderShipped', (event: OrderShipped) => { customerNotificationService.notifyShipment({ orderId: event.orderId, trackingNumber: event.trackingNumber, carrier: event.carrier, estimatedDelivery: event.estimatedDelivery, });});Well-designed domain events share several essential characteristics that make them powerful tools for domain modeling. Understanding these characteristics is crucial for effective implementation.
The Immutability Principle in Depth:\n\nImmutability is perhaps the most important characteristic. Consider what it means for events to be immutable facts:\n\n1. Audit Integrity — The historical record cannot be tampered with\n2. Reproducibility — Replaying events always produces the same result\n3. Concurrency Safety — Immutable objects can be freely shared between threads\n4. Event Sourcing Enablement — Aggregate state can be reconstructed from events\n5. Debugging Clarity — You can trace exactly what happened and when\n\nIf you find yourself wanting to modify an event after creation, you're likely doing something wrong. Instead, create a new event that represents the correction or compensation.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
/** * Immutability patterns for domain events. * * Key techniques: * 1. All fields are readonly * 2. Arrays are ReadonlyArray * 3. Nested objects are also immutable * 4. No setters or mutation methods */class PaymentReceived implements DomainEvent { readonly eventType = 'PaymentReceived'; readonly eventVersion = 1; constructor( readonly eventId: string, readonly occurredAt: Date, readonly orderId: string, readonly paymentId: string, readonly amount: Money, // Money is also immutable readonly paymentMethod: PaymentMethod, readonly transactionId: string, readonly metadata: Readonly<PaymentMetadata>, ) { // Freeze to prevent runtime modifications Object.freeze(this); }} /** * Value objects used in events should also be immutable. */class Money { constructor( readonly amount: number, readonly currency: Currency, ) { Object.freeze(this); } // Operations return new instances, never mutate add(other: Money): Money { if (this.currency !== other.currency) { throw new Error('Cannot add different currencies'); } return new Money(this.amount + other.amount, this.currency); }} /** * How to handle corrections: Compensating Events * * Instead of modifying PaymentReceived, we create new events * that represent what actually happened. */class PaymentRefunded implements DomainEvent { readonly eventType = 'PaymentRefunded'; readonly eventVersion = 1; constructor( readonly eventId: string, readonly occurredAt: Date, readonly originalPaymentId: string, // Reference to original readonly refundedAmount: Money, readonly reason: RefundReason, readonly refundId: string, ) { Object.freeze(this); }}One of the most important design decisions for domain events is determining how much context to include. The principle of self-containment says: an event should carry all information needed to understand what happened, without requiring recipients to query external systems for basic processing.\n\nThis doesn't mean events should contain entire database snapshots. It means they should contain sufficient context for their intended use cases.
{ orderId: '123' })The practical trade-off:\n\nRicher events are larger, which has storage and bandwidth implications. The right balance depends on your use cases:\n\n- Internal domain events (within a bounded context): Can be leaner since handlers have access to the same data\n- Integration events (between bounded contexts): Should be richer since consumers are decoupled\n- Event-sourced events: Must contain all state changes for reconstruction\n\nA useful heuristic: If an event handler's first action is always to query for more data, your event is probably too thin.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
// ❌ TOO THIN: Forces every handler to query for detailsclass OrderPlacedThin { constructor( readonly eventId: string, readonly occurredAt: Date, readonly orderId: string, // Only an ID ) {}} // Handler has to do this every time:async function handleOrderPlacedThin(event: OrderPlacedThin) { // Problem: Every handler must fetch the order const order = await orderRepository.findById(event.orderId); if (!order) { // What if order was deleted? Event becomes unprocessable throw new Error('Order not found'); } // Now we can actually do something await emailService.sendConfirmation(order.customerId, order);} // ✅ JUST RIGHT: Contains context needed for common processingclass OrderPlacedRich implements DomainEvent { readonly eventType = 'OrderPlaced'; readonly eventVersion = 1; constructor( readonly eventId: string, readonly occurredAt: Date, // Core identifiers readonly orderId: string, readonly customerId: string, readonly orderNumber: string, // Human-readable // Business context for the occurrence readonly items: ReadonlyArray<{ productId: string; productName: string; // Snapshot at time of order quantity: number; unitPrice: Money; }>, readonly totalAmount: Money, readonly currency: Currency, // Shipping context readonly shippingAddress: Address, readonly shippingMethod: string, readonly estimatedDelivery: Date, // Customer context (at time of order) readonly customerEmail: string, readonly customerTier: CustomerTier, readonly isFirstOrder: boolean, ) {}} // Handler can process autonomously:async function handleOrderPlacedRich(event: OrderPlacedRich) { // All context available - no external queries needed if (event.isFirstOrder) { await marketingService.triggerWelcomeSequence(event.customerEmail); } await emailService.sendConfirmation({ email: event.customerEmail, orderNumber: event.orderNumber, items: event.items, total: event.totalAmount, deliveryEstimate: event.estimatedDelivery, }); await analyticsService.trackOrder({ customerId: event.customerId, tier: event.customerTier, orderValue: event.totalAmount, });}Notice that OrderPlacedRich contains productName as a snapshot. Even if the product is renamed later, the event preserves what the customer actually ordered. This is intentional—the event captures history, not current state. The customer ordered 'Blue Widget', even if we later rename it to 'Azure Widget'.
Domain events are typically raised by aggregates as they perform operations. An aggregate is the natural origin point for domain events because:\n\n1. Aggregates enforce business invariants and validate operations\n2. Aggregates are consistency boundaries—if the operation is valid, the event represents a valid state transition\n3. Aggregates encapsulate the domain logic that determines when events occur\n\nThe event becomes a record that the aggregate transitioned from one valid state to another.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
/** * Base class for aggregates that raise domain events. * Events are collected during operations and dispatched after persistence. */abstract class AggregateRoot { private _domainEvents: DomainEvent[] = []; protected addDomainEvent(event: DomainEvent): void { this._domainEvents.push(event); } getDomainEvents(): ReadonlyArray<DomainEvent> { return [...this._domainEvents]; } clearDomainEvents(): void { this._domainEvents = []; }} /** * Order aggregate that raises domain events as it transitions states. */class Order extends AggregateRoot { private _id: OrderId; private _customerId: CustomerId; private _status: OrderStatus; private _items: OrderItem[]; private _shippingAddress: Address; private constructor(/* ... */) { super(); // ... initialization } /** * Factory method that creates an order and raises OrderPlaced event. * The event captures the moment of order creation. */ static place( id: OrderId, customerId: CustomerId, items: OrderItem[], shippingAddress: Address, customerContext: CustomerContext, ): Order { // Validate business rules if (items.length === 0) { throw new EmptyOrderError(); } const order = new Order(id, customerId, items, shippingAddress); order._status = OrderStatus.PLACED; // Raise domain event with full context order.addDomainEvent(new OrderPlaced( generateEventId(), new Date(), id.value, customerId.value, order.generateOrderNumber(), items.map(item => ({ productId: item.productId.value, productName: item.productName, quantity: item.quantity, unitPrice: item.unitPrice, })), order.calculateTotal(), Currency.USD, shippingAddress, customerContext.email, customerContext.tier, customerContext.orderCount === 0, )); return order; } /** * Ships the order - raises OrderShipped event. */ ship(shipmentDetails: ShipmentDetails): void { // Validate current state allows shipping if (this._status !== OrderStatus.PAID) { throw new InvalidOrderTransitionError( this._status, OrderStatus.SHIPPED, ); } this._status = OrderStatus.SHIPPED; this.addDomainEvent(new OrderShipped( generateEventId(), new Date(), this._id.value, shipmentDetails.shipmentId, shipmentDetails.carrier, shipmentDetails.trackingNumber, shipmentDetails.estimatedDelivery, this._items.map(item => ({ productId: item.productId.value, quantity: item.quantity, })), shipmentDetails.warehouseId, )); } /** * Cancels the order - raises OrderCancelled event. */ cancel(reason: CancellationReason, cancelledBy: ActorId): void { if (!this.canBeCancelled()) { throw new OrderCannotBeCancelledError(this._id, this._status); } this._status = OrderStatus.CANCELLED; this.addDomainEvent(new OrderCancelled( generateEventId(), new Date(), this._id.value, reason, cancelledBy.value, this._status, // Previous status for context )); }}Notice that domain events are added only after the operation succeeds. If validation fails and an exception is thrown, no event is recorded. Events represent successful state transitions, not attempted operations.
Domain events unlock several powerful capabilities that are difficult or impossible to achieve with state-only modeling:
The Decoupling Power:\n\nConsider how an OrderPlaced event decouples concerns:\n\n- Inventory Service listens and reserves stock\n- Email Service listens and sends confirmation\n- Analytics Service listens and updates dashboards\n- Fraud Detection listens and scores the order\n- Loyalty Program listens and awards points\n\nThe Order aggregate knows nothing about any of these services. It simply announces that an order was placed. Each service subscribes to events independently and can be deployed, scaled, and modified without affecting the Order service.
When a new team needs to react to orders (perhaps a new machine learning fraud model), they simply subscribe to OrderPlaced. No changes to the Order service required. This inversion of dependencies is one of the most powerful benefits of event-driven architecture.
We've covered the foundational concepts of domain events. Let's consolidate the key takeaways:
What's next:\n\nNow that we understand what domain events are, we'll explore how to design them effectively. The next page covers event naming conventions and structure—the patterns that make events clear, consistent, and evolvable across your domain.
You now understand the fundamental nature of domain events—immutable records of business-significant occurrences expressed in ubiquitous language. This foundation prepares you for the practical design decisions covered in the following pages.