Back to blog
backendevent-sourcingmicroservicesarchitecture

Backend Engineering: Introduction to Event Sourcing

Event sourcing keeps coming up in system design interviews, and for good reason — it solves real problems that CRUD-based systems struggle with. If you've ever needed a full audit trail, wanted to…

Backend Engineering: Introduction to Event Sourcing

Event sourcing keeps coming up in system design interviews, and for good reason — it solves real problems that CRUD-based systems struggle with. If you've ever needed a full audit trail, wanted to replay history to debug a production issue, or tried to sync multiple services without losing data, event sourcing is worth understanding deeply.

Why Event Sourcing Matters

Traditional applications store *current state*. You have a users table, and when someone updates their email, you overwrite the old value. Done. Simple. But you've just lost information — you no longer know what the email *was*, when it changed, or why.

Event sourcing flips this model. Instead of storing the current state of an entity, you store a sequence of events that led to that state. The current state becomes a derived value — something you compute by replaying those events.

This matters for several reasons:

  • Audit trails are free. Every change is an event with a timestamp and context. No extra logging infrastructure needed.
  • Debugging becomes time travel. You can replay events up to any point in time to understand exactly what happened.
  • Microservices integration gets easier. Events are a natural integration point — other services can subscribe and react without tight coupling.
  • You can rebuild read models. If your reporting database gets corrupted, you replay events and reconstruct it.
  • How Event Sourcing Works

    The core idea: never delete or update records, only append new events.

    Let's say you're building a bank account system. In a traditional system, you'd have:

    accounts (id, owner, balance, updated_at)

    In event sourcing, you'd have an event store:

    events (id, aggregate_id, event_type, payload, created_at)

    And your events might look like this:

    { "event_type": "AccountOpened",   "aggregate_id": "acc_123", "payload": { "owner": "Alice", "initial_balance": 0 } }
    { "event_type": "MoneyDeposited",  "aggregate_id": "acc_123", "payload": { "amount": 500 } }
    { "event_type": "MoneyWithdrawn",  "aggregate_id": "acc_123", "payload": { "amount": 200 } }

    To get the current balance, you replay these events in order. The final state is 500 - 200 = 300.

    Here's a simplified Python example of how this looks in code:

    from dataclasses import dataclass, field
    from typing import List

    @dataclass class AccountEvent: event_type: str payload: dict

    @dataclass class BankAccount: account_id: str owner: str = "" balance: float = 0.0 events: List[AccountEvent] = field(default_factory=list)

    def apply(self, event: AccountEvent): if event.event_type == "AccountOpened": self.owner = event.payload["owner"] self.balance = event.payload["initial_balance"] elif event.event_type == "MoneyDeposited": self.balance += event.payload["amount"] elif event.event_type == "MoneyWithdrawn": if event.payload["amount"] > self.balance: raise ValueError("Insufficient funds") self.balance -= event.payload["amount"]

    def deposit(self, amount: float): event = AccountEvent("MoneyDeposited", {"amount": amount}) self.apply(event) self.events.append(event) # Persist this

    def withdraw(self, amount: float): event = AccountEvent("MoneyWithdrawn", {"amount": amount}) self.apply(event) self.events.append(event) # Persist this

    Reconstruct from stored events

    def load_account(account_id: str, stored_events: List[AccountEvent]) -> BankAccount: account = BankAccount(account_id=account_id) for event in stored_events: account.apply(event) return account

    The key pattern here: commands produce events, events mutate state. The deposit method doesn't directly change the balance — it creates an event, applies it, and queues it for persistence.

    The CQRS Connection

    Event sourcing almost always travels with CQRS (Command Query Responsibility Segregation). The idea is simple: separate the write model (commands) from the read model (queries).

    Your event store is optimized for writes — it's an append-only log. But querying it directly (e.g., "give me all accounts with balance > $1000") would be painfully slow. So you maintain separate projections — read-optimized views built by consuming your event stream.

    # A simple projection that maintains a balance summary table
    def project_account_summary(event: AccountEvent, read_db):
        if event.event_type == "AccountOpened":
            read_db.insert("account_summary", {
                "account_id": event.aggregate_id,
                "owner": event.payload["owner"],
                "balance": event.payload["initial_balance"]
            })
        elif event.event_type == "MoneyDeposited":
            read_db.increment("account_summary", event.aggregate_id, "balance", event.payload["amount"])
        elif event.event_type == "MoneyWithdrawn":
            read_db.decrement("account_summary", event.aggregate_id, "balance", event.payload["amount"])

    If you ever need a new view of your data — say, a fraud detection report — you just write a new projection and replay all historical events through it. That's genuinely powerful.

    The Drawbacks (Be Honest About These)

    Event sourcing isn't a silver bullet. Here's what you're signing up for:

    Eventual consistency. Your projections lag behind your event store. A user might deposit money and not see the updated balance immediately. This is often acceptable, but your team needs to understand it.

    Complexity. You're adding infrastructure — an event store, projection workers, potentially a message broker. Simple CRUD apps don't need this overhead.

    Schema evolution is hard. Events are immutable. If you stored an event with a bug or a field name you later regret, you can't just run an ALTER TABLE. You need versioning strategies — upcasting old events, versioned event types, or migration events.

    Snapshots become necessary. If an account has 10,000 events, replaying all of them on every request is slow. You'll need to periodically snapshot the current state and only replay events after the snapshot.

    def load_account_with_snapshot(account_id: str, snapshot_store, event_store) -> BankAccount:
        snapshot = snapshot_store.get_latest(account_id)
        if snapshot:
            account = snapshot.state  # Pre-built state
            events_after = event_store.get_events_after(account_id, snapshot.version)
        else:
            account = BankAccount(account_id=account_id)
            events_after = event_store.get_all_events(account_id)

    for event in events_after: account.apply(event) return account

    Practical Tips for Getting Started

    Start with a bounded context that truly benefits. Don't rewrite your entire system. Find a domain where audit history, temporal queries, or event-driven integration are genuinely valuable — order management, financial transactions, inventory changes.

    Pick the right event store. [EventStoreDB](https://www.eventstore.com/) is purpose-built for this. Kafka works well if you're already using it. PostgreSQL with an append-only events table works fine for smaller scale — don't over-engineer early.

    Name events in past tense. OrderPlaced, PaymentFailed, ItemShipped. Events describe things that *happened*, not commands or states.

    Version your events from day one. Add a schema_version field to every event payload. Future you will be grateful.

    Don't expose your event store directly to consumers. Publish events to a message bus (Kafka, RabbitMQ, SNS) and let downstream services build their own projections. This keeps your write model clean.

    Next Steps

    Here's how to go from reading to understanding:

  • Build a toy project. Implement a simple to-do app or shopping cart using event sourcing. The act of writing apply() methods and projections will cement the concepts faster than any article.
  • Read the source material. Martin Fowler's [Event Sourcing article](https://martinfowler.com/eaa/EventSourcing.html) and Greg Young's talks on CQRS/ES are the canonical references.
  • Explore EventStoreDB. Spin it up locally with Docker and experiment with their client libraries. Seeing a real event store in action changes how you think about it.
  • Practice explaining the tradeoffs. In interviews, the question isn't just "what is event sourcing?" — it's "when would you use it and what are the costs?" Being able to articulate the consistency and complexity tradeoffs is what separates candidates.
  • Event sourcing is one of those patterns that feels complex until it clicks — and then you start seeing exactly where it fits (and where it doesn't). Get your hands dirty with a small implementation and you'll be ahead of most candidates who only know the theory.