Skip to main content

Event Sourcing Concepts

Event sourcing is a pattern where state changes are stored as a sequence of immutable events. The current state is derived by replaying these events from the beginning of time.

Before You Start​

  • Familiarity with basic domain-driven design concepts
  • Understanding of domain modeling is helpful but not required
  • No packages needed — this is a conceptual overview

Fundamental Principles​

1. Events Are Facts​

Events represent things that have happened - they cannot be changed or deleted:

// Events use past tense - they describe completed actions
// Events extend DomainEvent which provides EventId, OccurredAt, Metadata,
// and declare the permanent name they are stored under with [MessageName]
[MessageName("Contoso.Orders.OrderCreated")]
public record OrderCreated(Guid OrderId, string CustomerId) : DomainEvent;

[MessageName("Contoso.Orders.OrderLineAdded")]
public record OrderLineAdded(Guid OrderId, string ProductId, int Quantity) : DomainEvent;

2. State Is Derived​

Current state is computed by replaying events in order:

// Initial state
var order = new Order(); // Empty state

// Replay events to build current state using LoadFromHistory
var events = new IDomainEvent[]
{
new OrderCreated(orderId, "CUST-123", t1),
new OrderLineAdded(orderId, "PROD-1", 2),
new OrderSubmitted(orderId, t2),
new OrderShipped(orderId, "TRACK-456")
};

order.LoadFromHistory(events);
// Final state: Status=Shipped, TrackingNumber="TRACK-456"

// LoadFromHistory (from IAggregateSnapshotSupport) calls ApplyEvent for each event

3. Append-Only Storage​

Events are only ever appended, never updated or deleted:

Event Stream: order-123
┌─────────────────────────────────────────────────────────────┐
│ Version 1: OrderCreated { CustomerId: "CUST-123" } │
│ Version 2: OrderLineAdded { ProductId: "PROD-1" } │
│ Version 3: OrderLineAdded { ProductId: "PROD-2" } │
│ Version 4: OrderSubmitted { SubmittedAt: "2024-01-15" } │
│ Version 5: OrderShipped { TrackingNumber: "TRACK-456" }│
└─────────────────────────────────────────────────────────────┘

Event Anatomy​

Every domain event should extend DomainEvent (from Excalibur.Dispatch.Abstractions) which provides standard properties. The [MessageName] attribute is required and declares the permanent name the event is stored and transmitted under -- the same name appears in the event store, the outbox MessageType, and the CloudEvents type. See Stable Message Names:

[MessageName("Contoso.Orders.OrderCreated")]
public record OrderCreated(Guid OrderId, string CustomerId) : DomainEvent;

// DomainEvent provides these properties automatically:
// - EventId: Auto-generated UUID v7 string (time-ordered)
// - OccurredAt: DateTimeOffset.UtcNow at construction time
// - Metadata: Optional cross-cutting concerns (null by default)
// - CorrelationId / CausationId: Optional causation tracking (null by default)
//
// The event carries only its own business data. The aggregate id is passed to
// the event store on append/load, and the stream version is store-assigned and
// surfaced on the envelope during replay (never read from the event payload).

Event Naming Conventions​

ConventionExampleUse When
Past tenseOrderCreatedStandard - describes what happened
Noun + Past participlePaymentReceivedDescribes received/completed actions
Domain termInventoryReservedUses ubiquitous language

Event Streams​

Events are organized into streams, typically one per aggregate instance:

Streams:
├── order-001
│ ├── OrderCreated (v1)
│ ├── OrderLineAdded (v2)
│ └── OrderSubmitted (v3)
├── order-002
│ ├── OrderCreated (v1)
│ └── OrderCancelled (v2)
└── customer-123
├── CustomerRegistered (v1)
├── AddressUpdated (v2)
└── PreferencesChanged (v3)

Stream Identity​

In Excalibur, a stream is identified by two values — AggregateType and AggregateId — passed as separate parameters to the event store:

// IEventStore uses both values to identify a stream:
ValueTask<IReadOnlyList<StoredEvent>> LoadAsync(
string aggregateId, // e.g., "order-123"
string aggregateType, // e.g., "OrderAggregate"
CancellationToken cancellationToken);

In SQL Server, these are stored as separate indexed columns (WHERE AggregateId = @id AND AggregateType = @type). Other providers use the same composite key (CosmosDB partition key, DynamoDB hash key, Firestore collection path).

Default Stream Naming​

AggregateType defaults to the class name via GetType().Name:

// In AggregateRoot<TKey>:
public virtual string AggregateType => GetType().Name;
// e.g., OrderAggregate → "OrderAggregate"

The EventSourcedRepository reads this automatically when loading or saving:

var aggregate = aggregateFactory(aggregateId);
var aggregateType = aggregate.AggregateType; // "OrderAggregate"
var storedEvents = await eventStore.LoadAsync(aggregateId, aggregateType, ...);

Customizing Stream Names​

Override the AggregateType property on your aggregate to control the stream name:

public class OrderAggregate : AggregateRoot<Guid>
{
// Use a shorter, stable name instead of the class name
public override string AggregateType => "Order";
}

public class CustomerAggregate : AggregateRoot<Guid>
{
// Add a bounded context prefix
public override string AggregateType => "Sales.Customer";
}
Use stable names

If you rename your aggregate class, the default GetType().Name changes — breaking existing streams. Override AggregateType with a fixed string to decouple storage from class names.

Optimistic Concurrency​

Event sourcing uses version numbers for concurrency control:

// Load aggregate - note current version
var order = await repository.GetByIdAsync(orderId);
// order.Version = 5

// Make changes
order.AddLine("PROD-3", 1, 25.00m);

// Save with expected version
await eventStore.AppendAsync(
aggregateId: orderId,
events: order.GetUncommittedEvents(),
expectedVersion: 5); // Must match current version

// If another process appended events, this throws ConcurrencyException

Handling Concurrency Conflicts​

try
{
await repository.SaveAsync(order);
}
catch (ConcurrencyException ex)
{
// Option 1: Reload and retry
var reloaded = await repository.GetByIdAsync(orderId);
reloaded.AddLine("PROD-3", 1, 25.00m);
await repository.SaveAsync(reloaded);

// Option 2: Return conflict to caller
throw new ConflictException("Order was modified");
}

Temporal Queries​

Event sourcing enables powerful temporal queries:

State at a Point in Time​

// Load events up to a specific time
var events = await eventStore.LoadAsync(
orderId,
toTimestamp: new DateTime(2024, 1, 15));

// Replay to get state as of that date
var historicalOrder = new Order();
historicalOrder.LoadFromHistory(events);

State at a Specific Version​

// Load only first N events
var events = await eventStore.LoadAsync(
orderId,
toVersion: 3);

// State after 3 events
var order = new Order();
order.LoadFromHistory(events);

Event Timeline​

// Get all events for audit
var events = await eventStore.LoadAsync(orderId);

foreach (var e in events)
{
Console.WriteLine($"{e.Version}: {e.EventType} at {e.Timestamp}");
}
// EventType is the declared [MessageName], not the CLR type name:
// 1: Contoso.Orders.OrderCreated at 2024-01-10 10:00:00
// 2: Contoso.Orders.OrderLineAdded at 2024-01-10 10:05:00
// 3: Contoso.Orders.OrderSubmitted at 2024-01-10 10:10:00

Event Versioning​

As your domain evolves, events may need to change. Handle this with upcasters:

Schema Evolution​

// V1: Original event
public record OrderCreatedV1(Guid OrderId, string CustomerId);

// V2: Added field with default
public record OrderCreated(
Guid OrderId,
string CustomerId,
string Currency = "USD"); // New field with default

Upcasting​

public class OrderCreatedUpcaster : IMessageUpcaster<OrderCreatedV1, OrderCreated>
{
public OrderCreated Upcast(OrderCreatedV1 source)
{
return new OrderCreated(
source.OrderId,
source.CustomerId,
Currency: "USD"); // Default for historical events
}
}

Common Patterns​

Idempotency​

Handle duplicate event delivery:

public class OrderLineAddedHandler
{
public async Task Handle(OrderLineAdded @event, CancellationToken ct)
{
// Check if already processed using event ID
if (await _processed.ContainsAsync(@event.EventId, ct))
return;

await ProcessEvent(@event, ct);
await _processed.AddAsync(@event.EventId, ct);
}
}

Event Metadata​

Attach contextual information:

public class EventMetadata
{
public string UserId { get; set; }
public string CorrelationId { get; set; }
public string CausationId { get; set; }
public DateTime Timestamp { get; set; }
public Dictionary<string, string> Custom { get; set; }
}

Domain Events vs Integration Events​

Domain EventIntegration Event
Internal to bounded contextCrosses context boundaries
Contains domain detailsContains only necessary data
Not versioned for consumersShould be versioned
Can change freelyMust maintain compatibility
// Domain event (internal)
public record OrderLineAdded(
Guid OrderId,
string ProductId,
int Quantity,
decimal UnitPrice,
Guid LineId); // Internal detail

// Integration event (published externally)
public record OrderLineAddedIntegrationEvent(
Guid OrderId,
string ProductId,
int Quantity); // Only essential data

Best Practices​

1. Keep Events Small​

Include only what's needed:

// Good: Focused event
public record OrderShipped(
Guid OrderId,
string TrackingNumber,
DateTime ShippedAt);

// Avoid: Including too much
public record OrderShipped(
Guid OrderId,
string TrackingNumber,
Order FullOrder, // Don't include full objects
List<OrderLine> AllLines); // Redundant data

2. Use Domain Language​

Events should use ubiquitous language:

// Good: Domain language
public record InventoryReserved(Guid OrderId, string ProductId, int Quantity);
public record PaymentAuthorized(Guid OrderId, decimal Amount);

// Avoid: Technical language
public record InventoryTableUpdated(...);
public record PaymentRecordInserted(...);

3. Events Are Immutable​

Never modify events after creation:

public record OrderCreated
{
public Guid OrderId { get; init; } // init-only
public string CustomerId { get; init; } // init-only

// No setters, no mutable collections
}

Next Steps​

See Also​