Beyond State Overwriting: Implementing Event Sourcing in Python
Overview of Event Sourcing
Most applications function by overwriting state. When a player picks up a sword in a game, a database record changes from two to three. This approach is efficient but destructive; it discards the history of how that state was reached.
By replaying these events from the beginning, you can reconstruct the state at any point in time. This provides an inherent audit log, simplifies debugging by allowing "time travel," and enables the creation of multiple "projections" or views of the same data without altering the source of truth. It is the same fundamental logic that powers

Prerequisites
To follow this implementation, you should have a solid grasp of
Key Libraries & Tools
- enum: Used to define distinct, readable event types like
ITEM_ADDEDandITEM_REMOVED. - dataclasses: Provides a concise way to create event objects, specifically using
frozen=Trueto ensure immutability. - collections: A specialized dictionary subclass for counting hashable objects, used here to aggregate inventory totals.
- functools: Implements a simple memoization strategy to avoid replaying the entire event history on every read request.
- Flox: A tool for creating reproducible development environments, ensuring consistent package management across different machines.
Code Walkthrough: Building the Core System
Step 1: Defining Immutable Events
We start by defining what an event looks like. It must contain the type of action and the data associated with it.
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum, auto
class EventType(Enum):
ITEM_ADDED = auto()
ITEM_REMOVED = auto()
@dataclass(frozen=True)
class Event:
type: EventType
data: str
timestamp: datetime = field(default_factory=datetime.now)
The frozen=True parameter is vital. Events represent the past; they cannot be changed once they occur. We use a default_factory for the timestamp to ensure each event is accurately placed in the timeline.
Step 2: The Event Store and Caching
The
from functools import cache
from collections import Counter
class Inventory:
def __init__(self, store):
self.store = store
@cache
def get_items(self):
counts = Counter()
for event in self.store.get_all_events():
if event.type == EventType.ITEM_ADDED:
counts[event.data] += 1
elif event.type == EventType.ITEM_REMOVED:
counts[event.data] -= 1
return {k: v for k, v in counts.items() if v > 0}
def _invalidate_cache(self):
self.get_items.cache_clear()
When we add an item, we append an event to the store and trigger _invalidate_cache(). The next time get_items() is called, it recalculates and recaches the state.
Step 3: Advanced Projections
Projections allow us to ask different questions of our data. For example, we can determine which items were collected most frequently, regardless of whether they are still in the inventory.
def get_most_collected(store):
events = store.get_all_events()
added_items = [e.data for e in events if e.type == EventType.ITEM_ADDED]
return Counter(added_items).most_common(3)
Syntax Notes
This implementation relies on Generic Type Variables (TypeVar) when evolving the system to handle complex objects rather than just strings. Using typing.Generic[T] allows the Event and EventStore classes to remain flexible, supporting any data structure while maintaining type safety. The use of the decorator pattern via @cache demonstrates a clean way to separate performance concerns from business logic.
Practical Examples
- Financial Systems: Storing every transaction (credit/debit) instead of just the balance to provide a perfect audit trail.
- E-commerce: Tracking how long items sit in a cart before being removed to analyze user hesitation.
- Gaming: Building a replay system by storing player inputs as events to recreate the match exactly.
Tips & Gotchas
- Schema Evolution: If you change the structure of your
Itemobject later, your old events might break. You must plan for "upcasting" (transforming old events into the new format) or versioning your event schemas. - Snapshotting: For systems with millions of events, replaying from zero is too slow even with local caching. Periodically save a "snapshot" of the state so you only have to replay events from the last snapshot forward.
- Avoid for CRUD: If your application only requires basic create, read, update, and delete operations without any need for history, event sourcing will introduce unnecessary complexity.