Skip to main content

Command Palette

Search for a command to run...

Event sourcing (notes)

Updated
4 min read
Event sourcing (notes)
T

Just a guy who loves to write code and watch anime.

Event Sourcing turns events into the source of truth.

A bank account is the classic example. Instead of just storing the balance:

// Traditional approach (current state)
{
  "accountId": "123",
  "balance": 1000
}

// Event Sourcing (history of changes)
[
  {"type": "AccountOpened", "amount": 500, "timestamp": "2024-01-01"},
  {"type": "MoneyDeposited", "amount": 700, "timestamp": "2024-02-01"},
  {"type": "MoneyWithdrawn", "amount": 200, "timestamp": "2024-03-01"}
]

The current balance is just replaying these events. This seems like extra work, but it gives you more: perfect audit trails, time travel (see state at any point), and business insights.

Note: First, we try to store the change in the database. When that succeeds, we then create the event. An update event will not happen if the database update didn’t go through.

Events are facts. They're immutable. You can't change history. If you make a mistake, you add a correction event. Just like in accounting, you never erase. You add correction entries.

Snapshotting

Storage grows forever since you keep all events (we'll dig into partitioning later).

What if we want to restore state from events? Say you look at event with id 1000, how do you know what state to restore?

This is where snapshots come in. Think of snapshots like save points in a game. Without them, you'd need to replay from the start every time. With millions of events, that's slow and expensive. You literally need to loop through all the events to get the state for X date for example.

When to Create Snapshots:

// Common trigger points:
- Every N events (e.g., every 1000 events)
- Time-based (every hour)
- When state size changes a lot

// Example snapshot metadata
// For event 1000, we take a snapshot:
{
  "snapshotId": "snap_123",
  "eventCount": 1000,
  "timestamp": "2024-03-01T10:00:00Z",
  "lastEventId": "evt_1000",
  "state": {
    "balance": 1000,
    "lastTransaction": "2024-03-01",
    "status": "active"
  }
}

Snapshots are needed for:

  • Fast system recovery after crashes

  • Creating new read models quickly

  • Performance optimization

  • Reducing load during replay

Note: Snapshots aren't events. They're performance optimization. You could delete all snapshots and rebuild from events (slow but possible). Different services might need different snapshot frequencies based on their needs.

Remember: Keep multiple snapshots. If the latest is corrupted, you can fall back to an earlier one and replay fewer events. It's a trade-off between storage space and recovery speed.

End of introductory notes

When to use Event Sourcing:

  • Financial systems (need audit trails)

  • Legal/compliance heavy systems

  • Complex business processes

  • When history and decision tracking matter

When not to use Event Sourcing:

  • Simple CRUD applications

  • When only current state matters

  • High-volume data with no audit needs

  • When explaining the system to others is hard

Note: Event Sourcing isn't just storing events. It's modeling your domain as a series of meaningful business events. "UserAddressChanged" is an event. A database UPDATE is not.

Remember: Events are different from CDC events. CDC captures database changes. Event Sourcing captures business decisions. They solve different problems. Sometimes you use both. Event Sourcing for your domain, CDC for syncing data.

Digging deeper: Command vs Event

In Event Sourcing, not everything becomes an event. When a user does something, it starts as a command. Commands can fail. Events can't. Events are facts that have already happened.

Example of a registration flow:

// Command (can fail)
{
  "type": "RegisterUser",
  "username": "alice",
  "email": "alice@email.com"
}

// System checks: Is username taken? Is email valid?
// Only after DB write succeeds:

// Event (recorded fact)
{
  "type": "UserRegistered",
  "username": "alice",
  "email": "alice@email.com",
  "timestamp": "2024-03-01T10:00:00Z"
}

The flow is important:

  1. Command arrives

  2. Validate business rules

  3. Write to database

  4. Only then create event

  5. Consumers can't reject events

Think of it like a bank transfer:

  • Command: "Transfer $100" (might fail - insufficient funds)

  • Event: "Transferred $100" (already happened, can't fail)

Remember: Once an event exists, it's true forever. That's why validation happens at the command level, before the database write. There's no "undo" in Event Sourcing. Only new compensating events.

This matches how businesses work in real life. You can't "undo" a bank transfer. You make a new transfer back. The history of both transactions remains.

Digging deeper: Storage growth and partitioning

We can't store all events in one place. It would be too slow and expensive. We need to partition the data.

Common Partitioning Strategies:

// By Aggregate ID (most common)
partition_key = accountId
account_123: [event1, event2, event3]
account_456: [event1, event2]
account_789: [event1]

// By Time (for analytics)
2024_03: [events...]
2024_02: [events...]
2024_01: [events...]

// By Event Type (less common)
deposits: [events...]
withdrawals: [events...]

We also have storage tiers. Hot for example is more expensive than cold:

Hot Storage (Recent/Active)
- Last 30 days
- Fast access
- In-memory/SSD

Warm Storage (Medium-term)
- Last 90 days
- Regular HDDs
- Slightly slower access

Cold Storage (Historical)
- Older events
- Cheap storage (S3)
- Slower access, but complete

Note: Unlike CDC, we can't use log compaction. Every event matters. But we can move older events to cheaper storage since they're accessed less frequently.

Remember: Partition key choice is critical. Related events must stay together to maintain consistency. For a bank account, all its transactions need to be in the same partition to calculate balance correctly.