Skip to main content

Domain Events

Domain events represent facts that have happened in your domain. They are immutable records of state changes.

Before You Start

Defining Events

Using the Base Record

The DomainEvent abstract record provides auto-generated defaults for EventId (UUID v7), OccurredAt, EventType, and Metadata. A domain event carries only its own business data — it does not carry an aggregate id or a stream version. The aggregate id is supplied when the event is persisted (as a parameter to the event store), and the stream version is assigned by the store at append time (see Event Store).

public sealed record OrderCreated(
Guid OrderId,
string CustomerId,
decimal TotalAmount,
IReadOnlyList<OrderLineItem> Items) : DomainEvent;

public record OrderLineItem(
string ProductId,
int Quantity,
decimal UnitPrice);

Event Naming Conventions

ConventionExampleGuideline
Past tenseOrderCreated, PaymentReceivedEvents are facts that happened
SpecificOrderShippedToCustomerNot generic StateChanged
Domain languageInvoiceIssuedMatch ubiquitous language

Rich Event Data

Include all data needed to understand what happened:

// Good - self-contained event
public sealed record OrderShipped(
Guid OrderId,
string TrackingNumber,
string Carrier,
Address ShippingAddress,
DateTime EstimatedDelivery,
IReadOnlyList<ShippedItem> Items) : DomainEvent;

// Bad - lacks context
public sealed record OrderShipped(
Guid OrderId,
string TrackingNumber) : DomainEvent;

Event Properties

Standard Properties

Every domain event includes:

public interface IDomainEvent : IDispatchEvent
{
// Unique identifier for this event instance
string EventId { get; }

// When the event occurred (UTC)
DateTimeOffset OccurredAt { get; }

// Type name for serialization and routing
string EventType { get; }

// Optional metadata for cross-cutting concerns
IDictionary<string, object>? Metadata { get; }

// Correlation ID for tracking a chain of related operations (read from Metadata)
string? CorrelationId { get; }

// Causation ID identifying the command or event that caused this event (read from Metadata)
string? CausationId { get; }
}
Stream identity is not on the event

An event no longer carries an AggregateId or a Version. The aggregate id is passed to the event store as a parameter when appending or loading, and the stream version is assigned by the store and surfaced on the persisted envelope (StoredEvent.Version / HistoricEvent.Version) during replay — never read from the event payload. This keeps the messaging contract free of persistence concerns.

Metadata

Add cross-cutting concerns without polluting event data:

// When raising events, add metadata using fluent API
var @event = new OrderCreated(orderId, customerId, amount, items)
.WithMetadata("UserId", currentUserId)
.WithMetadata("TenantId", tenantId)
.WithCorrelationId(correlationId)
.WithCausationId(causationId)
.WithMetadata("IpAddress", clientIp);

Correlation and Causation

Track event chains:

public static class EventMetadataKeys
{
public const string CorrelationId = "CorrelationId";
public const string CausationId = "CausationId";
public const string UserId = "UserId";
}

// First event in chain
var orderCreated = new OrderCreated(...)
{
Metadata = new Dictionary<string, object>
{
[EventMetadataKeys.CorrelationId] = Guid.NewGuid().ToString(),
[EventMetadataKeys.CausationId] = commandId
}
};

// Subsequent event carries same correlation, caused by previous event
var paymentReceived = new PaymentReceived(...)
{
Metadata = new Dictionary<string, object>
{
[EventMetadataKeys.CorrelationId] = orderCreated.Metadata[EventMetadataKeys.CorrelationId],
[EventMetadataKeys.CausationId] = orderCreated.EventId
}
};

Event Categories

Domain Events vs Integration Events

// Domain Event - internal to bounded context
// Contains rich domain data, extends DomainEvent
public sealed record OrderCreated(
Guid OrderId,
string CustomerId,
decimal TotalAmount,
IReadOnlyList<OrderLineItem> Items,
DiscountApplied? Discount = null) : DomainEvent;

// Integration Event - published to other bounded contexts
// Contains only what others need to know (no base class required)
public record OrderCreatedIntegrationEvent(
Guid OrderId,
string CustomerId,
decimal TotalAmount,
DateTimeOffset CreatedAt) : IIntegrationEvent;

Event Transformation

Transform domain events to integration events using IDispatcher. Use IMessageContextAccessor to access the current context and CreateChildContext() to propagate correlation metadata:

public class OrderCreatedPublisher : IEventHandler<OrderCreated>
{
private readonly IDispatcher _dispatcher;
private readonly IMessageContextAccessor _contextAccessor;

public OrderCreatedPublisher(
IDispatcher dispatcher,
IMessageContextAccessor contextAccessor)
{
_dispatcher = dispatcher;
_contextAccessor = contextAccessor;
}

public async Task HandleAsync(OrderCreated @event, CancellationToken ct)
{
var integrationEvent = new OrderCreatedIntegrationEvent(
@event.OrderId,
@event.CustomerId,
@event.TotalAmount,
@event.OccurredAt);

// Called from within a handler, DispatchAsync derives a child context from
// the current handler's message and automatically propagates:
// - CorrelationId (for distributed tracing)
// - CausationId (set to parent's MessageId)
// - TenantId, UserId, SessionId, WorkflowId
// - TraceParent/tracestate (OpenTelemetry)
await _dispatcher.DispatchAsync(integrationEvent, ct);
}
}
Context Propagation

CreateChildContext() ensures correlation chains flow through your system:

  • CorrelationId groups all messages in a business transaction
  • CausationId links each message to its direct cause
  • TraceParent integrates with OpenTelemetry distributed tracing

Event Validation

Immutable Construction

Events should be valid at construction:

public sealed record OrderCreated : DomainEvent
{
public Guid OrderId { get; }
public string CustomerId { get; }
public decimal TotalAmount { get; }

public OrderCreated(Guid orderId, string customerId, decimal totalAmount)
{
// Validate at construction
if (orderId == Guid.Empty)
throw new ArgumentException("OrderId required", nameof(orderId));
if (string.IsNullOrWhiteSpace(customerId))
throw new ArgumentException("CustomerId required", nameof(customerId));
if (totalAmount < 0)
throw new ArgumentException("TotalAmount cannot be negative", nameof(totalAmount));

OrderId = orderId;
CustomerId = customerId;
TotalAmount = totalAmount;
}
}

Using Init-Only Properties

Combine init-only properties with the required base constructor:

public sealed record OrderCreated : DomainEvent
{
public required Guid OrderId { get; init; }
public required string CustomerId { get; init; }
public required decimal TotalAmount { get; init; }
}

// Usage - compiler enforces required properties
var @event = new OrderCreated
{
OrderId = orderId,
CustomerId = customerId,
TotalAmount = amount
};

Serialization

Default Serialization

Events are serialized using the configured serializer. JSON (System.Text.Json) is the default and works with any POCO event type -- no attributes needed.

// Register event sourcing
services.AddExcalibur(excalibur => excalibur.AddEventSourcing());

// Default: JSON (System.Text.Json) -- works with any POCO event type.
// For binary serialization, install the provider package and call a single method:

// MemoryPack for maximum .NET performance
services.AddMemoryPackSerializer();

// Or MessagePack for cross-language support
services.AddMessagePackSerializer();
No serializer-specific attributes needed

Consumer event types do not need [MemoryPackable], [MessagePackObject], or any other serializer-specific attributes. Only the internal envelope wrapper uses these attributes. Your domain events remain plain POCOs regardless of which serializer you choose.

Custom Type Names

The default EventType returns the class name (e.g., "OrderCreated"). To customize the type name for serialization, hide the base property with new:

public sealed record OrderCreated(Guid OrderId, string CustomerId) : DomainEvent
{
// Override the virtual EventType property to customize the serialization name
public override string EventType => "order.created.v1";
}

Handling Unknown Properties

Configure JSON serializer to handle schema evolution:

services.AddJsonSerialization(options =>
{
options.SerializerOptions.PropertyNameCaseInsensitive = true;
options.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip;
});

Best Practices

Do

  • Name events in past tense
  • Include all relevant data in the event
  • Keep events immutable
  • Use metadata for cross-cutting concerns
  • Version events when schemas change

Don't

  • Include entity references (only IDs)
  • Store derived or computed values
  • Include sensitive data without encryption
  • Use generic event names like DataChanged
  • Modify events after they're raised

Next Steps

See Also