Event Sourcing: Events as Source of Truth
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
);
}
}
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?