Loading learning content...
Consider a historical fact: "On July 20, 1969, Apollo 11 landed on the Moon." This statement describes something that happened. We can debate its significance, we can discover new details, we can even question whether it occurred—but we cannot change what happened. The past is immutable.
Domain events embody this same principle. An event represents a fact about something that occurred in the past. Once an order is placed, that fact cannot be un-placed. The customer placed the order at that moment, with those items, at those prices. We might later cancel the order (a new event), refund the payment (another event), or correct an error (yet another event)—but the original OrderPlaced event remains forever unchanged.
Immutability is not a constraint—it's a guarantee. It provides the foundation for reliable event sourcing, auditing, debugging, and system resilience.
By the end of this page, you will deeply understand why immutability matters for events, how to implement immutable event objects in various programming languages, what happens when you need to 'correct' an event, and the architectural benefits that flow from treating events as unchangeable facts.
Immutability is not an arbitrary design choice—it's a fundamental property that enables critical capabilities in event-driven systems.
1. Reliability and Trust:
If events can change after publication, how can consumers trust them? A handler might process an OrderPlaced event, make decisions based on its data, and then... the event changes? What happens to those decisions? Immutability guarantees that once you receive an event, its data is authoritative for that moment in time.
2. Auditability and Compliance:
Many industries require complete audit trails: finance, healthcare, legal, government. If events can be modified, the audit trail is compromised. Immutable events provide a provable, tamper-evident record of exactly what happened and when.
3. Event Sourcing:
In event-sourced systems, the event log is the source of truth—application state is derived by replaying events. If events could change, replaying them would produce different results at different times. Immutability ensures that replaying events always produces the same state.
4. Debugging and Analysis:
When investigating production issues, you need to know exactly what happened. If events could change, your post-mortem analysis might examine different data than what caused the issue. Immutability preserves the exact context of historical behavior.
5. Concurrent Processing Safety:
Immutable objects are inherently thread-safe. Multiple handlers can process the same event simultaneously without race conditions, locks, or defensive copies.
In domain-driven and event-sourced systems, event immutability is not optional—it's a foundational principle. Systems that allow event mutation aren't truly event-driven; they're just using events as a communication mechanism without the guarantees that make events valuable.
JavaScript objects are mutable by default, so enforcing immutability requires deliberate effort. TypeScript adds type-level immutability markers, but runtime enforcement still needs attention.
Techniques for Immutable Events:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
// TECHNIQUE 1: Readonly modifiers (compile-time only)interface OrderPlacedEvent { readonly eventId: string; readonly eventType: 'OrderPlaced'; readonly occurredAt: Date; readonly orderId: string; readonly customerId: string; readonly items: ReadonlyArray<{ readonly productId: string; readonly quantity: number; readonly unitPrice: number; }>; readonly totalAmount: number;} // TypeScript prevents: event.orderId = 'new-id'; // Error!// But this is compile-time only—runtime mutation still possible // TECHNIQUE 2: Object.freeze for runtime immutabilityfunction createOrderPlacedEvent(data: { orderId: string; customerId: string; items: Array<{ productId: string; quantity: number; unitPrice: number }>; totalAmount: number;}): OrderPlacedEvent { const event: OrderPlacedEvent = { eventId: crypto.randomUUID(), eventType: 'OrderPlaced', occurredAt: new Date(), orderId: data.orderId, customerId: data.customerId, items: data.items.map(item => Object.freeze({ ...item })), totalAmount: data.totalAmount }; // Freeze the entire event object and nested objects return Object.freeze(event);} // Attempting mutation now throws in strict mode or silently fails:const event = createOrderPlacedEvent({ orderId: 'order-123', customerId: 'cust-456', items: [{ productId: 'prod-1', quantity: 2, unitPrice: 29.99 }], totalAmount: 59.98}); // event.orderId = 'changed'; // Throws in strict mode!// event.items.push({...}); // Also throws! // TECHNIQUE 3: Deep freeze utility for nested objectsfunction deepFreeze<T extends object>(obj: T): Readonly<T> { // Get all property names const propNames = Object.getOwnPropertyNames(obj); // Freeze each nested object before freezing parent for (const name of propNames) { const value = (obj as Record<string, unknown>)[name]; if (value && typeof value === 'object') { deepFreeze(value as object); } } return Object.freeze(obj);} // TECHNIQUE 4: Immer for working with immutable dataimport { produce } from 'immer'; // When you need to "modify" an event (create a new version):const originalEvent = createOrderPlacedEvent({...}); // This creates a NEW event, leaving original untouchedconst correctedEvent = produce(originalEvent, draft => { // Immer allows mutation syntax but produces new immutable object draft.totalAmount = 69.98; // Fixed calculation error}); console.log(originalEvent.totalAmount); // 59.98 (unchanged)console.log(correctedEvent.totalAmount); // 69.98 (new object) // TECHNIQUE 5: Using class with private constructor and factoryclass ImmutableOrderPlaced { private constructor( public readonly eventId: string, public readonly eventType: 'OrderPlaced', public readonly occurredAt: Date, public readonly orderId: string, public readonly customerId: string, public readonly items: ReadonlyArray<ImmutableOrderItem>, public readonly totalAmount: number ) { Object.freeze(this); } static create(data: OrderPlacedData): ImmutableOrderPlaced { const items = data.items.map(item => new ImmutableOrderItem(item.productId, item.quantity, item.unitPrice) ); return new ImmutableOrderPlaced( crypto.randomUUID(), 'OrderPlaced', new Date(), data.orderId, data.customerId, Object.freeze(items), data.totalAmount ); }} class ImmutableOrderItem { constructor( public readonly productId: string, public readonly quantity: number, public readonly unitPrice: number ) { Object.freeze(this); }}TypeScript provides ReadonlyArray<T>, ReadonlyMap<K, V>, and ReadonlySet<T> types that prevent mutation at compile time. Always use these for collections in events. For runtime enforcement, combine with Object.freeze() or use immutable.js/immer libraries.
Java offers several approaches to immutability, from manual final-field classes to modern records introduced in Java 14+.
Techniques for Immutable Events in Java:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
// TECHNIQUE 1: Java Records (Java 14+) - Recommended// Records are immutable by default: all fields are final and private import java.time.Instant;import java.util.List;import java.util.UUID; // Record automatically provides constructor, getters, equals, hashCode, toStringpublic record OrderPlacedEvent( UUID eventId, String eventType, Instant occurredAt, UUID orderId, UUID customerId, List<OrderItem> items, // Make sure this is immutable too! Money totalAmount) { // Custom compact constructor for validation and defensive copying public OrderPlacedEvent { // Defensive copy: wrap in unmodifiable list items = List.copyOf(items); // Creates immutable copy // Validation if (orderId == null) throw new IllegalArgumentException("orderId required"); if (items.isEmpty()) throw new IllegalArgumentException("items cannot be empty"); } // Factory method for cleaner creation public static OrderPlacedEvent create( UUID orderId, UUID customerId, List<OrderItem> items, Money totalAmount ) { return new OrderPlacedEvent( UUID.randomUUID(), "OrderPlaced", Instant.now(), orderId, customerId, items, totalAmount ); }} // Nested records are also immutablepublic record OrderItem( UUID productId, String productName, int quantity, Money unitPrice) {} public record Money( long amountInCents, String currency) {} // TECHNIQUE 2: Traditional immutable class (pre-Java 14)import java.util.Collections; public final class OrderPlacedEventClassic { private final UUID eventId; private final String eventType; private final Instant occurredAt; private final UUID orderId; private final UUID customerId; private final List<OrderItem> items; private final Money totalAmount; // Private constructor - force use of factory private OrderPlacedEventClassic( UUID eventId, String eventType, Instant occurredAt, UUID orderId, UUID customerId, List<OrderItem> items, Money totalAmount ) { this.eventId = eventId; this.eventType = eventType; this.occurredAt = occurredAt; this.orderId = orderId; this.customerId = customerId; // Defensive copy - never store mutable reference this.items = Collections.unmodifiableList(new ArrayList<>(items)); this.totalAmount = totalAmount; } // Factory method public static OrderPlacedEventClassic create( UUID orderId, UUID customerId, List<OrderItem> items, Money totalAmount ) { return new OrderPlacedEventClassic( UUID.randomUUID(), "OrderPlaced", Instant.now(), orderId, customerId, items, totalAmount ); } // Getters only - no setters public UUID getEventId() { return eventId; } public String getEventType() { return eventType; } public Instant getOccurredAt() { return occurredAt; } public UUID getOrderId() { return orderId; } public UUID getCustomerId() { return customerId; } // Return defensive copy of mutable types public List<OrderItem> getItems() { return Collections.unmodifiableList(new ArrayList<>(items)); } public Money getTotalAmount() { return totalAmount; } // equals, hashCode, toString implementations...} // TECHNIQUE 3: Using Lombok for less boilerplateimport lombok.Value;import lombok.With; @Value // Makes all fields private final, generates getters, equals, hashCodepublic class OrderPlacedEventLombok { UUID eventId; String eventType; Instant occurredAt; UUID orderId; UUID customerId; @With // Generates withItems() that returns new instance List<OrderItem> items; Money totalAmount; // Still need defensive copying in constructor public OrderPlacedEventLombok( UUID eventId, String eventType, Instant occurredAt, UUID orderId, UUID customerId, List<OrderItem> items, Money totalAmount ) { this.eventId = eventId; this.eventType = eventType; this.occurredAt = occurredAt; this.orderId = orderId; this.customerId = customerId; this.items = List.copyOf(items); this.totalAmount = totalAmount; }}Java records (Java 14+) are specifically designed for immutable data carriers. They reduce boilerplate dramatically while enforcing immutability. For event classes, records are almost always the right choice. Use List.copyOf(), Set.copyOf(), and Map.copyOf() in compact constructors for defensive copying.
Python's dynamic nature makes true immutability challenging, but several patterns and libraries provide strong guarantees.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
"""Techniques for immutable events in Python""" from dataclasses import dataclass, fieldfrom datetime import datetimefrom typing import Tuple, FrozenSetfrom uuid import UUID, uuid4from attrs import frozen, define, Factory # TECHNIQUE 1: Frozen dataclasses (Python 3.7+)@dataclass(frozen=True) # frozen=True makes it immutableclass OrderPlacedEvent: """Frozen dataclass - attempts to modify raise FrozenInstanceError.""" event_id: UUID = field(default_factory=uuid4) event_type: str = "OrderPlaced" occurred_at: datetime = field(default_factory=datetime.utcnow) order_id: UUID = None customer_id: UUID = None # Use tuple instead of list for immutability items: Tuple["OrderItem", ...] = field(default_factory=tuple) total_amount: float = 0.0 currency: str = "USD" @dataclass(frozen=True)class OrderItem: """Frozen dataclass for order items.""" product_id: UUID product_name: str quantity: int unit_price: float # Usageevent = OrderPlacedEvent( order_id=uuid4(), customer_id=uuid4(), items=( OrderItem(uuid4(), "Widget", 2, 29.99), OrderItem(uuid4(), "Gadget", 1, 49.99), ), total_amount=109.97) # event.order_id = uuid4() # Raises: FrozenInstanceError!# event.items.append(...) # Tuple doesn't have append! # TECHNIQUE 2: Using attrs library (@frozen decorator)@frozen # Same as @define(frozen=True)class OrderPlacedEventAttrs: """attrs frozen class with automatic validation.""" event_id: UUID = Factory(uuid4) event_type: str = "OrderPlaced" occurred_at: datetime = Factory(datetime.utcnow) order_id: UUID = None customer_id: UUID = None items: Tuple["OrderItemAttrs", ...] = () total_amount: float = 0.0 @frozenclass OrderItemAttrs: product_id: UUID product_name: str quantity: int unit_price: float # TECHNIQUE 3: Named tuples (simple immutable containers)from typing import NamedTuple class SimpleOrderEvent(NamedTuple): """NamedTuple is inherently immutable.""" event_id: str event_type: str order_id: str customer_id: str total_amount: float # TECHNIQUE 4: Pydantic with frozen config (for validation + immutability)from pydantic import BaseModel, Fieldfrom pydantic.config import ConfigDict class OrderPlacedEventPydantic(BaseModel): """Pydantic model with frozen config.""" model_config = ConfigDict(frozen=True) event_id: UUID = Field(default_factory=uuid4) event_type: str = "OrderPlaced" occurred_at: datetime = Field(default_factory=datetime.utcnow) order_id: UUID customer_id: UUID items: Tuple[dict, ...] = () # Tuple for immutability total_amount: float # Pydantic provides automatic validation # TECHNIQUE 5: Creating a "modified" copy (evolution pattern)@dataclass(frozen=True)class ImmutableEvent: event_id: UUID value: str def with_value(self, new_value: str) -> "ImmutableEvent": """Return a new event with the modified value.""" from dataclasses import replace return replace(self, value=new_value) # Creates new instance # Usageoriginal = ImmutableEvent(uuid4(), "original")modified = original.with_value("modified") print(original.value) # "original" - unchangedprint(modified.value) # "modified" - new instanceIn Python, frozen dataclasses prevent reassigning attributes but don't prevent mutating mutable containers within them. Always use tuple instead of list, frozenset instead of set, and avoid mutable objects as values. A frozen dataclass containing a list is NOT truly immutable.
Events are serialized for transmission and storage. Maintaining immutability guarantees through serialization/deserialization cycles requires careful handling.
Serialization Concerns:
JSON Parsing Creates New Objects: When you deserialize JSON, you get new mutable objects by default. You must re-freeze them.
Date Handling: Dates serialize to strings and back. Ensure consistent parsing.
Schema Evolution: New fields in events need default handling during deserialization.
Binary Formats: Protobuf, Avro, and others have their own immutability semantics.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// Maintaining immutability through serialization import { z } from 'zod'; // Define schema for validation and parsingconst OrderItemSchema = z.object({ productId: z.string(), productName: z.string(), quantity: z.number().positive(), unitPrice: z.number().nonnegative()}).readonly(); // Mark as readonly const OrderPlacedEventSchema = z.object({ eventId: z.string().uuid(), eventType: z.literal('OrderPlaced'), occurredAt: z.string().datetime(), orderId: z.string(), customerId: z.string(), items: z.array(OrderItemSchema).readonly(), totalAmount: z.number(), currency: z.string()}).readonly(); type OrderPlacedEvent = z.infer<typeof OrderPlacedEventSchema>; // Serialize event to JSONfunction serializeEvent(event: OrderPlacedEvent): string { return JSON.stringify(event);} // Deserialize with immutability restorationfunction deserializeEvent(json: string): OrderPlacedEvent { const parsed = JSON.parse(json); // Validate and parse with Zod const validated = OrderPlacedEventSchema.parse(parsed); // Deep freeze to restore runtime immutability return deepFreeze(validated);} function deepFreeze<T extends object>(obj: T): Readonly<T> { Object.getOwnPropertyNames(obj).forEach(name => { const value = (obj as Record<string, unknown>)[name]; if (value && typeof value === 'object') { deepFreeze(value as object); } }); return Object.freeze(obj);} // Usageconst event: OrderPlacedEvent = deepFreeze({ eventId: crypto.randomUUID(), eventType: 'OrderPlaced', occurredAt: new Date().toISOString(), orderId: 'order-123', customerId: 'cust-456', items: [ { productId: 'prod-1', productName: 'Widget', quantity: 2, unitPrice: 29.99 } ], totalAmount: 59.98, currency: 'USD'}); const json = serializeEvent(event);const restored = deserializeEvent(json); // restored is now immutable again// restored.orderId = 'changed'; // Throws!"But what if we recorded wrong data? What if we need to fix an event?"
This is a common concern, and the answer is fundamental to understanding event-driven systems: You don't change events. You emit compensating events.
The Compensation Pattern:
When something needs to be "fixed," you don't modify the original event. Instead, you emit a new event that represents the correction. The event log becomes:
OrderPlaced with original (incorrect) dataOrderCorrected with the fixThis approach preserves history while enabling corrections.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
// Scenario: An order was placed with wrong pricing// Solution: Emit compensating events // Original event (immutable, never changed)interface OrderPlaced { eventType: 'OrderPlaced'; eventId: string; occurredAt: Date; orderId: string; items: Array<{ productId: string; quantity: number; unitPrice: number; // Oops, this was wrong! }>; totalAmount: number; // 59.98 - but should have been 49.98} // Compensating event: Doesn't modify OrderPlaced, records the correctioninterface OrderPricingCorrected { eventType: 'OrderPricingCorrected'; eventId: string; occurredAt: Date; orderId: string; reason: string; // Original (erroneous) values originalTotal: number; originalItems: Array<{ productId: string; originalPrice: number }>; // Corrected values correctedTotal: number; correctedItems: Array<{ productId: string; correctedPrice: number }>; // Who made the correction correctedBy: string;} // Event log now contains both eventsconst eventLog = [ { eventType: 'OrderPlaced', eventId: 'evt-001', occurredAt: new Date('2024-01-15T10:00:00Z'), orderId: 'order-123', items: [{ productId: 'prod-1', quantity: 2, unitPrice: 29.99 }], totalAmount: 59.98 // Wrong! }, { eventType: 'OrderPricingCorrected', eventId: 'evt-002', occurredAt: new Date('2024-01-15T10:05:00Z'), orderId: 'order-123', reason: 'Promotional discount was not applied', originalTotal: 59.98, originalItems: [{ productId: 'prod-1', originalPrice: 29.99 }], correctedTotal: 49.98, correctedItems: [{ productId: 'prod-1', correctedPrice: 24.99 }], correctedBy: 'admin-user-1' }] as const; // Current state is computed by applying all eventsfunction computeOrderState(events: DomainEvent[]): OrderState { let state: OrderState = { orderId: '', items: [], totalAmount: 0 }; for (const event of events) { switch (event.eventType) { case 'OrderPlaced': state = { orderId: event.orderId, items: event.items, totalAmount: event.totalAmount }; break; case 'OrderPricingCorrected': // Apply correction on top of previous state state = { ...state, items: state.items.map(item => { const correction = event.correctedItems.find( c => c.productId === item.productId ); return correction ? { ...item, unitPrice: correction.correctedPrice } : item; }), totalAmount: event.correctedTotal }; break; } } return state;} // The computed state reflects the correction// But the original events are preserved for audit!Types of Compensating Events:
| Scenario | Original Event | Compensating Event | Effect |
|---|---|---|---|
| Wrong price applied | OrderPlaced | OrderPricingCorrected | Recalculates totals |
| Order needs cancellation | OrderPlaced | OrderCancelled | Marks order as void |
| Data entry error | CustomerRegistered | CustomerProfileCorrected | Fixes customer data |
| Reversal needed | PaymentCaptured | PaymentRefunded | Reverses the payment |
| Fraudulent transaction | OrderShipped | OrderRecalled | Triggers return process |
Think of your event log like an accountant's ledger. Accountants never erase entries—they add correcting entries. This produces a complete, auditable history. The same principle applies to domain events: append corrections, never modify originals.
Immutability has several important technical implications for how you design and operate event-driven systems.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
// Benefits of immutability in action // 1. Thread-safe parallel processingasync function processEventsInParallel(events: readonly OrderPlaced[]) { // Safe! Each handler gets same immutable event await Promise.all(events.map(event => Promise.all([ inventoryHandler.handle(event), notificationHandler.handle(event), analyticsHandler.handle(event) ]) ));} // 2. Safe cachingclass EventCache { private cache = new Map<string, OrderPlaced>(); get(eventId: string): OrderPlaced | undefined { // Safe to return directly - event is immutable return this.cache.get(eventId); } set(event: OrderPlaced): void { // No need for defensive copy - event won't change this.cache.set(event.eventId, event); }} // 3. Computed properties with cachingclass OrderPlacedWithComputed { private readonly _event: OrderPlaced; private _cachedTotal?: number; constructor(event: OrderPlaced) { this._event = Object.freeze(event); } get computedTotal(): number { // Safe to cache - underlying data never changes if (this._cachedTotal === undefined) { this._cachedTotal = this._event.items.reduce( (sum, item) => sum + item.quantity * item.unitPrice, 0 ); } return this._cachedTotal; }} // 4. Safe use as map keysconst eventHandlerResults = new Map<OrderPlaced, HandlerResult>(); function trackResult(event: OrderPlaced, result: HandlerResult) { // Safe as key because hashCode is stable (event never changes) eventHandlerResults.set(event, result);} // 5. Easy testingdescribe('OrderHandler', () => { it('should process order correctly', async () => { // Just create the event - no mock setup needed const event: OrderPlaced = Object.freeze({ eventId: 'test-evt-1', eventType: 'OrderPlaced', occurredAt: new Date(), orderId: 'order-1', customerId: 'cust-1', items: [{ productId: 'p1', quantity: 1, unitPrice: 10 }], totalAmount: 10 }); // Handler can't accidentally mutate test data await handler.handle(event); // Assertions can rely on event being unchanged expect(event.totalAmount).toBe(10); });});Immutability is not a constraint—it's a superpower. It transforms events from mutable messages into reliable facts that form the foundation of trustworthy systems. Let's consolidate the key principles:
Module Complete:
You have now completed the Domain Events module. You understand:
With this knowledge, you can model domain events that accurately capture business occurrences, communicate effectively across system boundaries, and serve as the reliable foundation for event-driven architectures.
Congratulations! You've mastered Domain Events—from their DDD origins through design principles, naming conventions, and immutability guarantees. These concepts form the building blocks for event-driven systems. The next module will explore Event Handlers—the consumers that react to these events and bring your event-driven architecture to life.