Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to our course on distributed systems architecture.
In our last lesson, we explored the challenges of eventual consistency and the various strategies, like client-side polling or server-side push notifications, to manage the user experience around stale data. We concluded that at the heart of our event-driven system lies a specialized database responsible for persisting our facts—the event store.
Today, we will formalize this critical component. This lesson is designed to meet the learning outcome: Define the API contract for a generic event store service, including methods for appending events and retrieving event streams. We will establish a clear, technology-agnostic blueprint for how our application services will interact with the event persistence layer.
1. The Event Store: A Conceptual Model
Before defining an API, let's solidify our understanding of what an event store is. At its core, an event store is a database optimized for a specific task: storing a sequence of events in an append-only fashion.
Building Your First Event Store from Scratch: A Developer's ...
To frame this, let's read the introduction to the article 'Building Your First Event Store from Scratch'. It provides an excellent analogy and defines the fundamental concepts.
Please read the sections 'What is an Event Store and Why Should You Care?' and 'Essential Concepts You Must Understand'. Focus on the analogy of an accounting ledger and the definitions of Events and Streams.
As the article highlights, the key concepts are:
- Append-Only Log: Events are only ever added; they are never changed or deleted. This creates an immutable, auditable history of everything that has happened in the system.
- Events: Immutable facts about something that occurred in the past (e.g.,
TradeExecuted,PaymentSettled). - Streams: A sequence of events related to a single entity or aggregate. For instance, all events for a specific FX trade (ID
FX-12345) would constitute a single stream. The stream ID is simply the aggregate's unique identifier.
2. Defining the Core API Contract
An API contract is an interface that defines how different software components communicate. For our event store, this contract needs to support two fundamental operations: writing events and reading them.
Let's start with the simplest possible definition of this contract.
Event Sourcing DIY 02 - Events, Event Store, Mailbox Processor
The video 'Event Sourcing DIY 02' provides a very clear and minimal definition of an event store's API. Let's watch the section where the presenter outlines this basic contract.
Watch the segment from 06:29 to 07:40. The presenter defines the two essential functions: get and append.
The video proposes a simple contract:
get(): Returns all events in the store.append(events): Adds a list of events to the store.
This is a good starting point, but for a robust, multi-entity system, we need to refine it. We need to specify which stream to read from or write to. Furthermore, we need to handle concurrent operations safely.
A more complete and professional contract would look like this:
- Append Events: A method to save new events to a specific stream.
- Get Events: A method to retrieve the history of events for a specific stream.
The following article provides a C# interface that captures this refined contract perfectly. It also introduces a critical parameter for ensuring data integrity.
Building Your First Event Store from Scratch: A Developer's ...
Now, let's examine a more detailed API definition from the 'Building Your First Event Store from Scratch' article. This introduces a crucial concept for handling concurrency.
Read the section 'Step 2: Implementing the Event Store Interface'. Focus on the definition of the IEventStore interface and the explanation of its methods, particularly the expectedVersion parameter in SaveEventsAsync.
Based on these resources, we can define our generic event store contract. In Python, we can represent this contract using an Abstract Base Class (ABC), which serves as a blueprint for any concrete implementation (e.g., one using PostgreSQL, another using a specialized database like EventStoreDB).
Here is what that contract looks like in Python:
from abc import ABC, abstractmethod
from typing import List, Any
# A placeholder for your domain event classes
class DomainEvent:
pass
class ConcurrencyException(Exception):
"""Custom exception raised when a write conflict is detected."""
pass
class IEventStore(ABC):
"""
Defines the contract for a generic event store service.
"""
@abstractmethod
def append_events(
self,
stream_id: str,
events: List[DomainEvent],
expected_version: int
) -> None:
"""
Appends a list of events to a stream atomically.
Args:
stream_id: The unique identifier of the aggregate/stream.
events: The list of domain events to append.
expected_version: The version of the stream that the caller expects
to be writing to.
Raises:
ConcurrencyException: If the `expected_version` does not match the
current version of the stream in the store.
"""
pass
@abstractmethod
def get_events(
self,
stream_id: str,
from_version: int = 0
) -> List[DomainEvent]:
"""
Retrieves the list of events for a given stream.
Args:
stream_id: The unique identifier of the aggregate/stream to read.
from_version: The version from which to start reading events.
Defaults to 0 to read the entire stream.
Returns:
A list of domain events from the specified stream.
"""
pass
Key Parameters Explained:
stream_id: This is the identity of the aggregate root, for example, atrade_idorpayment_id. It correlates all events for that specific entity.events: A list of events to be appended. The operation must be atomic; either all events are saved, or none are.expected_version: This is the cornerstone of optimistic concurrency control. When a service wants to append new events, it states the version of the aggregate it last read. The event store checks this against the stream's actual current version. If they don't match, it means another process has modified the aggregate in the meantime, and the operation is rejected with aConcurrencyException. This prevents "lost update" anomalies. We will implement this mechanism in our next lesson.from_version: This allows for efficient state reconstruction. Instead of always reading the entire event history, we can read from a specific point, which is essential for implementing snapshots (a topic for a later lesson).
3. An Abstract View: Separating Read and Write Interfaces
The contract we defined combines read and write operations. However, following the Command Query Responsibility Segregation (CQRS) principle, it's often beneficial to separate these interfaces. A component that only needs to read events (like a projection) shouldn't be given the ability to write them.
The documentation for the Eventuous library provides a great example of this design principle.
The 'Eventuous' documentation formalizes the separation of read and write concerns at the event store level. This is a powerful design pattern.
Read the introductory section ('Event store') and the 'Operations' table. Notice how the IEventStore interface is composed of IEventReader and IEventWriter, and how each operation is assigned to one of these more granular interfaces.
By defining separate IEventReader and IEventWriter interfaces, we can apply the Principle of Least Privilege.
- Command Handlers would depend on
IEventWriterto append new events. - Projections and Query Services would depend on
IEventReaderto build read models.
This makes the system's dependencies clearer and more secure. The Eventuous documentation also lists other useful, albeit secondary, operations like StreamExists or DeleteStream, which can be part of a more comprehensive event store API.
4. The API in Practice
So far, we've discussed a generic, abstract contract. To see how this translates into a real-world library, let's look at an example using the Marten library for .NET, which uses PostgreSQL as a backing store.
Production Ready Event Sourcing in .NET
The video 'Production Ready Event Sourcing in .NET' demonstrates how a high-level library like Marten implements these concepts.
Watch the sections on appending events (06:07 - 07:25) and retrieving/aggregating them (07:25 - 09:40). Compare the library's methods (StartNewStream, Append, AggregateStreamAsync) to our generic append_events and get_events contract.
In the video, you can see:
session.events.StartNewStream(...)andsession.events.Append(...)are the library's equivalent of ourappend_eventsmethod. The library manages thestream_idand optimistic concurrency checks within its session object.session.events.AggregateStreamAsync(...)is a higher-level convenience method. It performs theget_eventsoperation internally and then automatically applies the events to an aggregate object to reconstruct its current state, saving you from writing that boilerplate code manually.
This demonstrates how our fundamental API contract serves as the foundation upon which more user-friendly, higher-level abstractions are built.
Conclusion
In this lesson, we have formally defined the API contract for a generic event store, the persistence backbone of our event-sourced system.
Key Takeaways:
- An event store is an append-only database that stores events in streams, where each stream represents the history of a single aggregate.
- The core API contract consists of two primary methods: one for appending events to a stream and one for retrieving events from a stream.
- A robust
append_eventsmethod must include anexpected_versionparameter to enable optimistic concurrency control, preventing data corruption from race conditions. - Designing the API with separate read and write interfaces (
IEventReader,IEventWriter) aligns with CQRS principles and improves system design.
Preview of the Next Lesson:
We have defined the what—the methods and parameters of our event store contract. Most importantly, we've identified the expected_version parameter as the key to data integrity. In our next lesson, we will focus on the how: we will implement optimistic concurrency control in an event store service by using aggregate version numbers to detect write conflicts.