Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 60

Event Sourcing: Reconstructing State from Event Streams

Recommended resources

Hello! Welcome to your daily lesson on advanced architectural patterns.

In our last session, we mastered CQRS, learning how to separate read and write responsibilities to build highly scalable systems. We briefly introduced Event Sourcing as a powerful partner to CQRS, where the write-model is not a direct representation of state, but a historical log of everything that has happened.

Today, we dive deep into that concept. Your goal is to explain the event sourcing pattern and how to reconstruct application state from an event stream. Instead of asking "What is the current state?", Event Sourcing asks "What series of events led to this state?". This shift in perspective provides immense power for auditing, debugging, and business analytics, making it a key pattern in domains from finance to e-commerce.

We'll build our understanding across these five concepts:

  1. The Core Idea: Storing History, Not Just State
  2. State Reconstruction: Replaying the Event Stream
  3. Building with Aggregates and an Event Store
  4. The Performance Problem: Snapshots as an Optimization
  5. Elite Interview Deep Dive: Trade-offs & Schema Evolution

1. The Core Idea: Storing History, Not Just State (Easy)

In a traditional CRUD (Create, Read, Update, Delete) system, when you change data, you overwrite the old data. If a user's balance was $100 and they deposit $50, the database value simply changes from 100 to 150. The fact that a deposit occurred is lost; only the result remains. Event Sourcing fundamentally changes this.

  • Technical Explanation:
    Event Sourcing is a persistence pattern where all changes to an application's state are stored as a sequence of immutable events. The current state is not stored directly. Instead, it is derived by replaying these events. The event log itself becomes the single source of truth.

    • An Event is an object that represents something that has happened in the past (e.g., OrderPlaced, ItemRemovedFromCart, PaymentProcessed). Events are immutable and are named with a past-tense verb.
    • The Event Store is an append-only database that stores these events in chronological order for each entity.
Differences in Event Sourcing System Design
This diagram contrasts a traditional CRUD model with Event Sourcing. The CRUD system updates a record in place, losing history. The Event Sourcing system appends immutable events like 'Order Created' and 'Order Paid' to an Event Store, preserving the full history.
  • Metaphor for a 5-Year-Old:
    Imagine you're building a tower with Lego blocks.

    • CRUD is like someone only ever showing you a photo of the finished tower. You see it's 10 blocks tall, but you don't know how it was built. If they add a block, they just show you a new photo of an 11-block tower.
    • Event Sourcing is like having the instruction manual. It says: "1. Place a red block. 2. Place a blue block on top. 3. Place a yellow block on top..." The manual is the list of events. If you want to know what the tower looks like, you just follow the instructions from the beginning. You have the full history and can rebuild the tower at any time.
  • Python Code Snippet (Conceptual):
    This shows the fundamental difference. Instead of a single variable holding state, we have a list of events.

    # Traditional state-oriented approach
    account_balance = 0
    account_balance += 50  # Deposit
    account_balance -= 10  # Withdrawal
    # Final state is 40. We don't know how we got here.
    print(f"CRUD Final Balance: {account_balance}")
    
    # --- Event Sourcing Approach ---
    
    # The Event Store is just a list of events (facts)
    event_stream = [
        {"event_type": "AccountCreated", "initial_balance": 0},
        {"event_type": "FundsDeposited", "amount": 50},
        {"event_type": "FundsWithdrawn", "amount": 10},
    ]
    
    # To get the current state, we must process the stream
    current_balance = 0
    for event in event_stream:
        if event["event_type"] == "AccountCreated":
            current_balance = event["initial_balance"]
        elif event["event_type"] == "FundsDeposited":
            current_balance += event["amount"]
        elif event["event_type"] == "FundsWithdrawn":
            current_balance -= event["amount"]
            
    print(f"Event Sourcing Final Balance: {current_balance}")
    # Final state is 40, but we also have the full history in event_stream!
    


2. State Reconstruction: Replaying the Event Stream (Medium)

The core operation in Event Sourcing is reconstructing, or "rehydrating," an entity's state from its event stream. This process ensures that the state is always consistent with its history.

  • Technical Explanation:
    To get the current state of an entity (often called an Aggregate in Domain-Driven Design), we retrieve its complete, ordered event stream from the Event Store. We start with an empty object and apply each event to it one by one. Each event has a corresponding handler or apply method that knows how to modify the state based on the event's data.

    This reconstruction happens every time we need to process a command that depends on the current state. For example, to process a WithdrawFundsCommand, we must first reconstruct the account's state to check if the balance is sufficient.

Stop Overwriting State And Use Event Sourcing Instead

The following video from ArjanCodes provides an excellent, practical walkthrough of building a basic event-sourced system in Python. It clearly demonstrates the concept of replaying events to derive state.

Watch from the beginning to 12:52. Pay close attention to how the get_items method in the Inventory class doesn't just read a value; it iterates through the entire event list from the EventStore to calculate the current inventory counts. This is state reconstruction in action.

  • Metaphor for a 5-Year-Old:
    You have a notebook that tracks your Pokemon card collection. Instead of erasing and rewriting the total number of cards, you write down every transaction:

    • "Got Pikachu from a booster pack."
    • "Traded Squirtle for a Charmander."
    • "Gave a duplicate Pikachu to my friend."

    To know which cards you have right now, you can't just look at the last page. You have to go back to the first page and read every entry, updating your mental list as you go. "Okay, I have a Pikachu... now I trade Squirtle so he's gone and I have Charmander... now I give away one Pikachu...". That process of reading and updating is replaying the event stream.

  • Python Code Snippet (Reconstructing an Account):
    Let's formalize the previous example with a class responsible for state reconstruction.

    from dataclasses import dataclass
    
    # Define our events as structured classes
    @dataclass(frozen=True)
    class AccountCreated:
        account_id: str
    
    @dataclass(frozen=True)
    class FundsDeposited:
        amount: float
    
    @dataclass(frozen=True)
    class FundsWithdrawn:
        amount: float
    
    # The Aggregate holds state and applies events
    class BankAccount:
        def __init__(self):
            self.id = None
            self.balance = 0.0
    
        def apply(self, event):
            """Applies a single event to change state."""
            if isinstance(event, AccountCreated):
                self.id = event.account_id
            elif isinstance(event, FundsDeposited):
                self.balance += event.amount
            elif isinstance(event, FundsWithdrawn):
                self.balance -= event.amount
        
        @staticmethod
        def reconstruct(events: list):
            """Reconstructs the aggregate by replaying an entire event stream."""
            account = BankAccount()
            for event in events:
                account.apply(event)
            return account
    
    # --- Usage ---
    # Imagine this stream is fetched from an Event Store for account 'ACC123'
    event_stream_for_acc123 = [
        AccountCreated(account_id='ACC123'),
        FundsDeposited(amount=100.0),
        FundsWithdrawn(amount=25.0),
        FundsDeposited(amount=50.0),
    ]
    
    # Rehydrate the account object
    account = BankAccount.reconstruct(event_stream_for_acc123)
    
    print(f"Reconstructed Account ID: {account.id}")
    print(f"Reconstructed Balance: ${account.balance:.2f}") # Should be 125.00
    


3. Building with Aggregates and an Event Store (Medium-Hard)

To move beyond a simple list, a real implementation needs two key components: the Aggregate which encapsulates business logic, and the EventStore which handles persistence.

  • Technical Explanation:
    • Aggregate: An aggregate is a cluster of domain objects that can be treated as a single unit. It has a root entity, the Aggregate Root, which is the only object external clients are allowed to interact with. In Event Sourcing, the Aggregate Root is responsible for validating commands and, if successful, generating new events. It maintains a list of "uncommitted events".
    • Event Store: A specialized database optimized for appending events and retrieving streams of events for a given aggregate ID. When a command is processed, the application retrieves the aggregate's event stream, reconstructs it, executes the command logic, and if new events are generated, appends them to the store.

Tutorial - Part 1 - Getting started - Event Sourcing in Python

The eventsourcing library in Python provides a fantastic, high-level abstraction for this pattern. This tutorial shows how to convert a regular class into an event-sourced aggregate.

Read the sections 'Event-sourced aggregates' and 'Event-sourced applications'. Focus on how the @event decorator automates event creation and how the collect_events() method is used to gather new events. See how the application repository's get() method implicitly handles the reconstruction we just discussed, and its save() method persists the new events.

  • Metaphor for a 5-Year-Old:
    Imagine you're the captain of a ship (Aggregate). You have a logbook (Event Store).

    • Someone gives you an order: "Turn right 10 degrees" (Command).
    • You first check your ship's current status (by quickly re-reading the logbook to remember your current heading). You decide the command is valid.
    • You don't just turn the wheel. You write in your logbook: "14:00 - Turned right 10 degrees." (Event). The act of writing this in the log is the source of truth.
    • The ship's crew then reads the logbook and executes the maneuver. The state of the ship (its heading) is a result of the events in the log.
  • Python Code Snippet (Full Loop with Aggregate and Event Store):
    This conceptual code ties the pieces together.

    class EventStore:
        def __init__(self):
            # In reality, this would be a real database (e.g., PostgreSQL, EventStoreDB)
            # { "aggregate_id": [event1, event2, ...] }
            self._store = {}
    
        def get_events(self, aggregate_id: str) -> list:
            return self._store.get(aggregate_id, [])
    
        def save_events(self, aggregate_id: str, events: list):
            if aggregate_id not in self._store:
                self._store[aggregate_id] = []
            self._store[aggregate_id].extend(events)
    
    class BankAccountAggregate: # Now with command handling
        def __init__(self):
            self.id = None
            self.balance = 0.0
            self._uncommitted_events = []
        
        # ... apply and reconstruct methods are the same ...
    
        def create_account(self, account_id: str):
            # Business logic/validation
            if self.id is not None:
                raise ValueError("Account already created.")
            
            # Create and apply the event
            event = AccountCreated(account_id=account_id)
            self.apply(event)
            self._uncommitted_events.append(event)
        
        def deposit_funds(self, amount: float):
            if amount <= 0:
                raise ValueError("Deposit amount must be positive.")
            
            event = FundsDeposited(amount=amount)
            self.apply(event)
            self._uncommitted_events.append(event)
    
    # --- Application Service tying it all together ---
    event_store = EventStore()
    account_id = "ACC456"
    
    # 1. Create a new account
    new_account = BankAccountAggregate()
    new_account.create_account(account_id)
    new_account.deposit_funds(200.0)
    event_store.save_events(account_id, new_account._uncommitted_events)
    print("Account created and 200 deposited.")
    
    # 2. Later, perform another operation
    # First, reconstruct from history
    history = event_store.get_events(account_id)
    existing_account = BankAccountAggregate.reconstruct(history)
    
    # Second, execute new command
    existing_account.deposit_funds(75.0)
    
    # Third, save new events
    event_store.save_events(account_id, existing_account._uncommitted_events)
    print("Deposited another 75.")
    
    # 3. Verify final state by reconstructing again
    final_history = event_store.get_events(account_id)
    final_account = BankAccountAggregate.reconstruct(final_history)
    print(f"Final balance: ${final_account.balance:.2f}") # Should be 275.00
    


4. The Performance Problem: Snapshots as an Optimization (Hard)

A major challenge with Event Sourcing becomes apparent with long-lived aggregates. If a bank account has been active for 20 years with thousands of transactions, replaying every single event just to check its current balance is highly inefficient.

  • Technical Explanation:
    A snapshot is a pre-calculated state of an aggregate at a specific point in time (or more accurately, at a specific event version). Instead of replaying events from the very beginning, the reconstruction process can:

    1. Load the most recent snapshot for the aggregate.
    2. Instantiate the aggregate with the state from the snapshot.
    3. Retrieve and replay only the events that have occurred since the snapshot was taken.

    Snapshots are a pure performance optimization; they are disposable and can be regenerated from the event stream at any time. They are typically taken at a set interval (e.g., every 100 events).

Rebuilding State from Snapshot and Events
This diagram shows the optimized reconstruction process. Instead of starting from the beginning of the log, we start from the latest 'Snapshot' and only apply the subsequent 'Event' entries to arrive at the current state.

Event Sourcing: Rehydrating Aggregates with Snapshots

This video from CodeOpinion is entirely focused on solving the replay performance problem with snapshots. It gives a clear, practical explanation and C# code demonstration.

Watch from the beginning to 05:09. Focus on the core problem statement: what happens with long-lived streams? Then, observe how a separate snapshot stream is introduced. Understand how the logic changes to first look for a snapshot and then read only a partial event stream.

  • Metaphor for a 5-Year-Old:
    You're watching a long TV series with 20 seasons. To understand what's happening in the newest episode, you don't want to re-watch all 20 seasons from the start.

    • Replaying from scratch: Re-watching every single episode.
    • Using a snapshot: You watch a "Previously on..." recap clip that summarizes seasons 1-19 (the snapshot). Then, you just watch the new episodes from season 20 (the events after the snapshot). You get to the current plot much faster.
  • Python Code Snippet (Reconstruction with Snapshots):
    Let's modify our reconstruct logic.

    # Assume a SnapshotStore exists
    # snapshot_store.get_latest_snapshot(aggregate_id) -> (snapshot_state, last_event_version)
    # snapshot_store.save_snapshot(...)
    
    # In our EventStore, events would now have version numbers
    # event_store.get_events_after_version(aggregate_id, version) -> [event_v_plus_1, ...]
    
    class BankAccountWithSnapshots:
        # ...
        
        @staticmethod
        def reconstruct(snapshot_store, event_store, aggregate_id):
            account = BankAccountAggregate()
            
            # 1. Try to load the latest snapshot
            snapshot_state, last_version = snapshot_store.get_latest_snapshot(aggregate_id)
            
            if snapshot_state:
                # Hydrate from snapshot
                account.id = snapshot_state['id']
                account.balance = snapshot_state['balance']
                
                # 2. Get events SINCE the snapshot
                events_to_replay = event_store.get_events_after_version(aggregate_id, last_version)
            else:
                # 2b. No snapshot, get all events from the beginning
                events_to_replay = event_store.get_events(aggregate_id)
            
            # 3. Apply the remaining events
            for event in events_to_replay:
                account.apply(event)
            
            return account
    


5. Elite Interview Deep Dive: Trade-offs & Schema Evolution (Elite)

Event Sourcing is an advanced pattern. Knowing when to use it and how to handle its complexities is a sign of engineering maturity.

  • Technical Explanation: Trade-offs & Challenges
    • Pros:
      • Complete Audit Log: You have a perfect, immutable record of every state change, which is invaluable for debugging, auditing, and regulatory compliance.
      • Temporal Queries: You can determine the state of an aggregate at any point in time.
      • Powerful Analytics: The event stream is a rich source of data for business intelligence. You can build new views/projections of the data at any time by re-processing the log.
      • Decoupling: As we saw with CQRS, events are a natural way to communicate changes to other parts of the system.
    • Cons & Challenges:
      • High Complexity: The learning curve is steep. It's a very different way of thinking compared to CRUD.
      • Event Schema Evolution: This is a major challenge. What happens when you need to change an event? (e.g., an AddressAdded event now needs a country field). Since events are immutable, you can't just change the old ones. Common strategies include:
        • Versioning: Add a version field to events. Your code must know how to handle different versions.
        • Upcasting: On read, transform old event versions into the new format before they are applied to the aggregate.
      • Difficult Ad-hoc Querying: You can't just SELECT * FROM orders WHERE status='shipped'. You must build a separate read model for that (which leads us back to CQRS).
      • Storage: Can potentially require more storage than a state-based model, though this is often manageable with modern databases.

Event-Driven Architecture Patterns Deep Dive

This article provides a great overview of the complexities and implications of Event Sourcing, paying special attention to schema evolution and snapshotting.

Read the sections 'Implications and Complexities' and 'Use Cases and Trade-offs'. Focus on the 'Event Schema Evolution' part and study the simple Python EventUpcaster example. This is a critical concept for long-term maintenance of an event-sourced system.

Test your understanding! (Interview-Style Question)

You are designing a collaborative document editing platform, similar to Google Docs. Multiple users can edit the same document concurrently. A key requirement is to provide a "version history" feature that allows users to view and restore the document to any previous state.

Would you use Event Sourcing for the core document model? Justify your choice by explaining the pros and cons in this specific context. How would you handle the "schema evolution" problem if you later decide to add a new feature, like "comments"?

Show answer

Yes, Event Sourcing is an exceptionally good fit for this use case, and its principles are fundamental to how many collaborative systems are built (often using more advanced algorithms like CRDTs, which are themselves event-based).

Justification:

  • Pros in this Context:

    1. Natural Version History: The core requirement of "version history" is a native, built-in feature of Event Sourcing, not an add-on. The event stream is the version history. To restore the document to a specific point in time, we simply replay the events up to that timestamp or version.
    2. Auditability and Debugging: If a document becomes corrupted or users dispute a change, we have a perfect, immutable log of every single action (e.g., CharacterInserted, TextDeleted, FormattingApplied) taken by every user, with timestamps. This is invaluable for debugging synchronization issues.
    3. Collaboration: The stream of events from different users can be merged to reconstruct a consistent view of the document. An event like UserA_Inserted_'h'_at_pos_5 is an atomic fact that can be processed alongside UserB_Deleted_chars_10-12.
    4. Temporal Projections: We could easily build features like "show me all changes made by User X yesterday" by filtering the event stream.
  • Cons in this Context:

    1. Replay Performance: A very long and frequently edited document could have millions of events (e.g., every keystroke). Replaying this entire stream to open the document would be too slow. Snapshots would be absolutely essential here. We might create a snapshot of the full document state every 1,000 events or every 5 minutes.
    2. Complexity of Concurrency: While ES provides the events, handling concurrent edits correctly (e.g., what happens if two users edit the same word simultaneously?) requires complex logic on top, such as Operational Transformation (OT) or CRDTs to ensure all users converge on the same state. Event Sourcing is the foundation, but not the whole solution for concurrency.

Handling Schema Evolution (Adding Comments):

This is a classic and critical problem. Let's say we introduce a new CommentAdded event.

  1. The New Event: We would define a new CommentAdded event class, perhaps CommentAdded_v1, containing fields like comment_id, user_id, text, and the position in the document it's anchored to. New comments would generate this event.
  2. Handling Old History: The existing event stream doesn't have any CommentAdded events. That's fine. When replaying history from before the feature existed, no comments will be applied, which is correct.
  3. The 'Upcasting' Solution: Imagine a year later we enhance comments to support threaded replies. We might introduce CommentAdded_v2 which has an optional parent_comment_id field.
    • Our document reconstruction logic must now be aware of both versions.
    • We would create an upcaster: a function that takes an old event and transforms it into the new format before it's applied.
      def upcast_comment_event(event_data):
          if event_data['version'] == 1:
              # v1 didn't have parent_comment_id. Add it with a default value.
              event_data['parent_comment_id'] = None
              event_data['version'] = 2
          return event_data
      
    • When loading events from the store, we pass them through this upcasting logic. This ensures our Aggregate.apply() method only ever has to deal with the latest event version (v2), drastically simplifying the business logic. The upcaster isolates the messiness of handling legacy schemas.


Conclusion

Today we've gone deep into Event Sourcing, a pattern that treats history as the ultimate source of truth. By storing an immutable log of events, we gain incredible power for auditing, debugging, and analytics, but at the cost of increased architectural complexity.

Key Takeaways:

  • Event Sourcing persists the sequence of state-changing events, not the final state.
  • State is reconstructed by replaying events for a given aggregate, starting from an initial state.
  • Snapshots are a crucial performance optimization to avoid replaying very long event streams.
  • The primary benefits are a perfect audit trail and temporal query capabilities.
  • The main challenges are increased complexity and the need for a robust strategy for event schema evolution.

Next Lesson Preview

Now that we've covered two major architectural patterns for managing data and state (CQRS and Event Sourcing), we'll start shifting towards solving specific, practical system design challenges you'll face in interviews and production. Our next topic is rate limiting, a crucial mechanism for protecting your services from being overwhelmed by too many requests. We'll learn how to design a distributed rate limiting system using algorithms like the token bucket and sliding window counter.