EventStoreDB can be the bedrock of your CQRS and Event Sourcing architecture, but it’s not just a fancy database; it’s a state machine that remembers everything.
Let’s watch it in action. Imagine a simple Order aggregate. When an OrderCreated event is appended, EventStoreDB stores it. If we then append an ItemAddedToOrder event, EventStoreDB doesn’t overwrite the previous state; it appends the new event, creating a new version of the Order stream.
// C# Example
var connection = EventStoreConnection.Create(
"esdb://localhost:2113?tls=false",
new ConnectionSettingsBuilder().KeepReconnecting().Build());
await connection.ConnectAsync();
var orderStream = $"order-{Guid.NewGuid()}";
var orderCreated = new OrderCreatedEvent { OrderId = orderStream, Item = "Laptop", Quantity = 1 };
var itemAdded = new ItemAddedToOrderEvent { OrderId = orderStream, Item = "Mouse", Quantity = 2 };
await connection.AppendToStreamAsync(
orderStream,
ExpectedVersion.NoStream,
new EventData(Guid.NewGuid(), nameof(OrderCreatedEvent), JsonSerializer.SerializeToUtf8Bytes(orderCreated)));
await connection.AppendToStreamAsync(
orderStream,
ExpectedVersion.Any, // Or ExpectedVersion.FromStreamNumber(0) if you know the exact version
new EventData(Guid.NewGuid(), nameof(ItemAddedToOrderEvent), JsonSerializer.SerializeToUtf8Bytes(itemAdded)));
// To read the stream
var streamResult = connection.ReadStreamAsync(orderStream, StreamPosition.Start, 10);
var events = await streamResult.ToListAsync();
foreach (var resolvedEvent in events)
{
// Deserialize and process event
Console.WriteLine($"Event: {resolvedEvent.Event.EventType}");
}
This stream of events is the single source of truth. Every command that modifies the Order results in one or more events being appended. The current state of the Order is then derived by replaying these events. This is the "Event Sourcing" part.
The "CQRS" (Command Query Responsibility Segregation) aspect comes into play because we now have two distinct models: the write model (which appends events) and the read model (which consumes events to build optimized queryable views). When an ItemAddedToOrderEvent is appended to EventStoreDB, a separate projection process (often running in a background service or using EventStoreDB’s built-in projections) would listen for this event and update a denormalized read model, like a SQL database or a document store, that’s optimized for querying orders by item or customer.
Here’s how you control it:
- Streams: The fundamental unit. A stream is a sequence of events, typically representing a single aggregate instance (e.g.,
order-123,customer-abc). You append events to a stream. - Events: Immutable facts about something that happened. They are the append-only log.
- Expected Version: Crucial for concurrency control. When appending events, you specify the version of the stream you expect. If the actual stream version doesn’t match, EventStoreDB rejects the append, preventing lost updates.
ExpectedVersion.NoStreamfor new streams,ExpectedVersion.Anyfor existing streams where you don’t care about the exact version (though this is less safe), orExpectedVersion.FromStreamNumber(n)to guarantee you’re appending to a specific version. - Projections: These are how you build your read models. You can write custom code (e.g., using
EventStoreClientto subscribe to streams) or leverage EventStoreDB’s built-in JavaScript-based projections. These projections listen to streams and transform events into queryable data.
The most surprising thing about EventStoreDB’s internal mechanics is its use of a log-structured merge-tree (LSM-tree) approach, but with a twist. While it appends events to a log, it also maintains an in-memory index for fast stream lookups. When you read a stream, it efficiently retrieves the relevant log segments and reconstructs the event sequence. The ExpectedVersion check isn’t just a database-level constraint; it’s fundamental to maintaining the integrity of your event log as a series of ordered, immutable facts.
When you configure EventStoreDB, especially regarding maxAppendSize and fragmented stream settings, you’re subtly influencing how event data is batched and written to disk. The default maxAppendSize of 1MB means EventStoreDB will group multiple incoming events into a single chunk on disk up to that size. If a single event exceeds this, it will be written as its own fragment. This impacts read performance because larger fragments might require more I/O to retrieve a single event compared to smaller, more numerous ones.
The next concept you’ll grapple with is how to handle complex business logic that might result in multiple events from a single command, and how to ensure atomicity across these appends.