Understanding Event Sourcing
Event sourcing ensures that every change to the state of an application is captured as an event object. Instead of storing current state, we store the sequence of events that led to that state.
typescript
// Traditional: Store current state
user = {
id: 1,
name: "John",
email: "john@example.com"
}
// Event Sourcing: Store events
events = [
{ type: "USER_CREATED", data: { name: "John" } },
{ type: "EMAIL_UPDATED", data: { email: "john@example.com" } }
]Benefits
Complete Audit Trail
typescript
// Every change is recorded
const events = [
{ type: "ORDER_CREATED", timestamp: "2025-01-01T10:00:00Z" },
{ type: "PAYMENT_RECEIVED", timestamp: "2025-01-01T10:05:00Z" },
{ type: "ORDER_SHIPPED", timestamp: "2025-01-01T14:00:00Z" }
];
// Full history, time travel possibleTemporal Queries
typescript
// Rebuild state at any point in time
function getStateAt(date: Date) {
return events
.filter(e => new Date(e.timestamp) < date)
.reduce(reducer, initialState);
}
// What was the state yesterday?
const stateYesterday = getStateAt(new Date("2025-01-01"));Implementation
Event Store
typescript
interface Event {
type: string;
data: any;
timestamp: Date;
version: number;
}
class EventStore {
async saveEvents(aggregateId: string, events: Event[]) {
// Append events to log
await db.insert('events', {
aggregateId,
events,
timestamp: new Date()
});
}
async getEvents(aggregateId: string): Promise<Event[]> {
return db.query(
'SELECT * FROM events WHERE aggregate_id = ? ORDER BY version',
[aggregateId]
);
}
}Aggregate
typescript
class User {
private events: Event[] = [];
private version = 0;
static create(name: string): User {
const user = new User();
user.applyEvent({
type: "USER_CREATED",
data: { name },
version: 0,
timestamp: new Date()
});
return user;
}
updateEmail(email: string) {
this.applyEvent({
type: "EMAIL_UPDATED",
data: { email },
version: this.version + 1,
timestamp: new Date()
});
}
private applyEvent(event: Event) {
this.events.push(event);
this.version = event.version;
switch (event.type) {
case "USER_CREATED":
this.name = event.data.name;
break;
case "EMAIL_UPDATED":
this.email = event.data.email;
break;
}
}
getUncommittedEvents(): Event[] {
return this.events;
}
static loadFromHistory(events: Event[]): User {
const user = new User();
events.forEach(e => user.applyEvent(e));
return user;
}
}Command Handler
typescript
class CommandHandler {
constructor(private eventStore: EventStore) {}
async handle(command: Command) {
// Load current state
const events = await this.eventStore.getEvents(command.aggregateId);
const aggregate = User.loadFromHistory(events);
// Execute command
switch (command.type) {
case "CREATE_USER":
aggregate.updateEmail(command.email);
break;
}
// Save new events
await this.eventStore.saveEvents(
command.aggregateId,
aggregate.getUncommittedEvents()
);
}
}Projections
Read Models
typescript
// Build read models from events
class UserListProjection {
async handle(event: Event) {
switch (event.type) {
case "USER_CREATED":
await db.insert('user_list', {
id: event.aggregateId,
name: event.data.name
});
break;
case "EMAIL_UPDATED":
await db.update(
'user_list',
{ email: event.data.email },
{ id: event.aggregateId }
);
break;
}
}
}Best Practices
- 1Use immutable events
- 2Version events for conflict detection
- 3Create multiple projections
- 4Implement snapshotting for performance
- 5Handle event schema evolution
Challenges
- 1Event versioning
- 2Snapshot management
- 3Query complexity
- 4Debugging event streams
- 5Learning curve
Conclusion
Event sourcing provides complete audit trail, temporal queries, and flexible read models. Use it for complex domains with state changes.
Embrace event-driven architecture with event sourcing!