Traditional databases store current state. Order total: $100. User balance: $500.

You don’t know how you got there. History is lost.

Event sourcing stores the events. State is derived.

Current State vs Event Log#

Traditional (state-based):

CREATE TABLE accounts (
    id INT,
    balance DECIMAL,
    updated_at TIMESTAMP
);

-- Only current state
SELECT balance FROM accounts WHERE id = 123;
-- Returns: 500

Balance is 500. But you don’t know:

  • Was it 600 yesterday?
  • Who made deposits?
  • Were there failed transactions?

Event Sourcing:

CREATE TABLE account_events (
    id INT,
    account_id INT,
    event_type VARCHAR,
    amount DECIMAL,
    timestamp TIMESTAMP
);

-- Events history
INSERT INTO account_events VALUES 
    (1, 123, 'AccountOpened', 0, '2026-01-01'),
    (2, 123, 'MoneyDeposited', 1000, '2026-01-02'),
    (3, 123, 'MoneyWithdrawn', 300, '2026-01-03'),
    (4, 123, 'MoneyWithdrawn', 200, '2026-01-04');

Current balance = sum of events = 1000 - 300 - 200 = 500.

Complete history preserved.

How It Works#

1. Store events, not state:

class BankAccount {
    private UUID accountId;
    private List<Event> uncommittedEvents = new ArrayList<>();
    
    // Commands produce events
    public void deposit(BigDecimal amount) {
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }
        
        // Don't update state directly
        // Create event instead
        MoneyDepositedEvent event = new MoneyDepositedEvent(
            accountId, 
            amount, 
            Instant.now()
        );
        
        applyEvent(event);
        uncommittedEvents.add(event);
    }
    
    public void withdraw(BigDecimal amount) {
        if (balance.compareTo(amount) < 0) {
            throw new IllegalArgumentException("Insufficient balance");
        }
        
        MoneyWithdrawnEvent event = new MoneyWithdrawnEvent(
            accountId,
            amount,
            Instant.now()
        );
        
        applyEvent(event);
        uncommittedEvents.add(event);
    }
}

2. Apply events to rebuild state:

class BankAccount {
    private BigDecimal balance = BigDecimal.ZERO;
    
    // Rebuild state by applying events
    private void applyEvent(Event event) {
        if (event instanceof AccountOpenedEvent) {
            this.balance = BigDecimal.ZERO;
        } else if (event instanceof MoneyDepositedEvent) {
            MoneyDepositedEvent e = (MoneyDepositedEvent) event;
            this.balance = this.balance.add(e.getAmount());
        } else if (event instanceof MoneyWithdrawnEvent) {
            MoneyWithdrawnEvent e = (MoneyWithdrawnEvent) event;
            this.balance = this.balance.subtract(e.getAmount());
        }
    }
    
    // Load account from event store
    public static BankAccount load(UUID accountId, EventStore store) {
        BankAccount account = new BankAccount(accountId);
        
        List<Event> events = store.getEvents(accountId);
        for (Event event : events) {
            account.applyEvent(event);
        }
        
        return account;
    }
}

3. Persist events to event store:

class EventStore {
    public void save(UUID aggregateId, List<Event> events) {
        for (Event event : events) {
            // Append-only, never update
            jdbcTemplate.update(
                "INSERT INTO events (aggregate_id, event_type, data, version, timestamp) VALUES (?, ?, ?, ?, ?)",
                aggregateId,
                event.getClass().getName(),
                toJson(event),
                event.getVersion(),
                event.getTimestamp()
            );
        }
    }
    
    public List<Event> getEvents(UUID aggregateId) {
        return jdbcTemplate.query(
            "SELECT * FROM events WHERE aggregate_id = ? ORDER BY version",
            new EventRowMapper(),
            aggregateId
        );
    }
}
%%{init: {'theme':'base', 'themeVariables': { 'primaryColor':'#000000','primaryTextColor':'#00ff00','primaryBorderColor':'#00ff00','lineColor':'#00ff00','secondaryColor':'#000000','tertiaryColor':'#000000','noteBkgColor':'#000000','noteBorderColor':'#00ff00','noteTextColor':'#00ff00'}}}%% sequenceDiagram autonumber participant C as Command participant A as Aggregate participant E as Event Store participant P as Projections C->>A: deposit(1000) A->>A: Validate A->>A: Create MoneyDepositedEvent A->>A: Apply event to state A->>E: Save event E-->>A: Persisted E->>P: Publish event P->>P: Update read model Note over P: Balance view
Transaction history
Analytics

Commands create events, events rebuild state, projections create read models.

Benefits#

1. Complete audit trail: Every change recorded. Who did what when. Regulatory compliance, debugging.

2. Time travel: Rebuild state at any point in time. What was balance on Jan 1st? Replay events until Jan 1st.

3. Multiple projections: Build different read models from same events. Current balance view, transaction history view, analytics view.

4. No data loss: Delete account? Events still exist. Can rebuild if needed.

5. Business insights: Events capture intent. “MoneyWithdrawn for coffee purchase” vs “MoneyWithdrawn for rent”. Rich domain data.

Challenges#

1. Event schema evolution: Event from 2020 has different fields than 2026. Need versioning, upcasting.

2. Performance: Loading aggregate = replaying all events. 10,000 events? Slow. Solution: snapshots.

3. Eventual consistency: Projections lag behind events. Read model might be stale.

4. Complexity: More code than CRUD. Events, aggregates, projections, snapshots. Steep learning curve.

5. Can’t delete data: Events are immutable. Can’t comply with “right to be forgotten” (GDPR). Workaround: encrypt PII, delete keys.

Snapshots#

Optimization: store periodic snapshots of state. Replay from snapshot instead of beginning.

// Snapshot every 100 events
if (events.size() % 100 == 0) {
    Snapshot snapshot = new Snapshot(
        accountId,
        currentBalance,
        events.size()  // version
    );
    snapshotStore.save(snapshot);
}

// Load with snapshot
Snapshot snapshot = snapshotStore.getLatest(accountId);
BankAccount account = new BankAccount(accountId, snapshot.getBalance());

// Replay only events after snapshot
List<Event> recentEvents = eventStore.getEvents(accountId, snapshot.getVersion());
for (Event event : recentEvents) {
    account.applyEvent(event);
}

When to Use Event Sourcing#

Good use cases:

  • Financial systems (audit trail critical)
  • Domain-driven design (rich domain events)
  • Temporal queries (time travel needed)
  • Multiple read models (CQRS pattern)

Overkill for:

  • Simple CRUD apps
  • When current state is sufficient
  • Performance-critical systems (event replay overhead)
  • Small teams (complexity burden)

What I’m Learning#

Event sourcing is elegant conceptually. Store what happened, derive current state. Perfect audit trail. Time travel for free.

Reality: complexity. Event schema evolution is hard. Performance needs snapshots. Projections add infrastructure. Not every domain needs this.

For financial systems, regulatory compliance, or complex domains where business events are first-class citizens, event sourcing makes sense. For typical web apps, it’s over-engineering.

The key insight: events are more truthful than state. State is a cache of events. But that truth comes with operational cost.

Have you used event sourcing? What domain was it?