Architecture 10 min read

Event Sourcing and CQRS

Published on January 20, 2026

Event Sourcing and CQRS

In most traditional applications, you store the current state of your data. If a user has $1000 in their account, you save that value. If they later make a $100 transaction, you update the value to $900. The transaction history is lost, or stored separately.

But what if you need to know exactly what happened, when it happened, and why it happened? What if you need to be able to reconstruct the state at any point in time? What if you need an immutable history of all changes?

Event Sourcing and CQRS are architectural patterns that solve these problems. They are especially useful for financial systems, e-commerce, and any application that requires complete auditing and traceability.

In this article, I will explain what Event Sourcing and CQRS are, when to use them, and how to implement them in practice.

The problem with the traditional model

In the traditional model, you store the current state:

// Traditional model
class Account {
    constructor(id, balance) {
        this.id = id;
        this.balance = balance; // Only the current state
    }
    
    deposit(amount) {
        this.balance += amount;
        // History is lost
    }
    
    withdraw(amount) {
        this.balance -= amount;
        // History is lost
    }
}

Problems:

  • You cannot see the change history
  • You cannot reconstruct the state at a point in time
  • You cannot audit what happened
  • If there is an error, you cannot easily revert

What is Event Sourcing?

Event Sourcing is a pattern where, instead of storing the current state, you store a sequence of events that represent all the changes that have occurred.

Basic concept

// Instead of storing: balance = 900
// You store events:
// 1. AccountCreated(id: "123", initialBalance: 1000)
// 2. MoneyDeposited(accountId: "123", amount: 500)
// 3. MoneyWithdrawn(accountId: "123", amount: 600)
// 4. MoneyWithdrawn(accountId: "123", amount: 100)

// The current state is calculated by applying all events
// balance = 1000 + 500 - 600 - 100 = 800

Advantages of Event Sourcing

  1. Complete history: You have a record of all changes
  2. Auditing: You can see exactly what happened and when
  3. Time travel: You can reconstruct the state at any point in time
  4. Debugging: You can replay events to understand problems
  5. Flexibility: You can create new views of the state without changing the events

Disadvantages of Event Sourcing

  1. Complexity: More complex than the traditional model
  2. Performance: Reconstructing the state can be slow if there are many events
  3. Storage: It can require more storage
  4. Learning curve: Requires understanding new concepts

Basic implementation of Event Sourcing

Defining events

// Events are immutable objects representing something that happened
class AccountCreated {
    constructor(accountId, initialBalance, timestamp) {
        this.type = 'AccountCreated';
        this.accountId = accountId;
        this.initialBalance = initialBalance;
        this.timestamp = timestamp;
    }
}

class MoneyDeposited {
    constructor(accountId, amount, timestamp) {
        this.type = 'MoneyDeposited';
        this.accountId = accountId;
        this.amount = amount;
        this.timestamp = timestamp;
    }
}

class MoneyWithdrawn {
    constructor(accountId, amount, timestamp) {
        this.type = 'MoneyWithdrawn';
        this.accountId = accountId;
        this.amount = amount;
        this.timestamp = timestamp;
    }
}

Storing events

class EventStore {
    constructor() {
        this.events = [];
    }
    
    append(streamId, event) {
        this.events.push({
            streamId,
            event,
            version: this.events.length + 1
        });
    }
    
    getEvents(streamId) {
        return this.events
            .filter(e => e.streamId === streamId)
            .map(e => e.event);
    }
}

Reconstructing state from events

class Account {
    constructor(accountId, eventStore) {
        this.accountId = accountId;
        this.eventStore = eventStore;
        this.balance = 0;
        
        // Reconstruct state from events
        this.replayEvents();
    }
    
    replayEvents() {
        const events = this.eventStore.getEvents(this.accountId);
        
        for (const event of events) {
            this.applyEvent(event);
        }
    }
    
    applyEvent(event) {
        switch (event.type) {
            case 'AccountCreated':
                this.balance = event.initialBalance;
                break;
            case 'MoneyDeposited':
                this.balance += event.amount;
                break;
            case 'MoneyWithdrawn':
                this.balance -= event.amount;
                break;
        }
    }
    
    deposit(amount) {
        const event = new MoneyDeposited(
            this.accountId,
            amount,
            new Date()
        );
        this.eventStore.append(this.accountId, event);
        this.applyEvent(event);
    }
    
    withdraw(amount) {
        if (this.balance < amount) {
            throw new Error('Insufficient funds');
        }
        
        const event = new MoneyWithdrawn(
            this.accountId,
            amount,
            new Date()
        );
        this.eventStore.append(this.accountId, event);
        this.applyEvent(event);
    }
}

Snapshots for optimization

If there are many events, reconstructing the state can be slow. Snapshots help:

class Account {
    // ... previous code ...
    
    // Every 100 events, save a snapshot
    shouldCreateSnapshot() {
        const events = this.eventStore.getEvents(this.accountId);
        return events.length % 100 === 0;
    }
    
    createSnapshot() {
        return {
            accountId: this.accountId,
            balance: this.balance,
            version: this.eventStore.getEvents(this.accountId).length
        };
    }
    
    loadFromSnapshot(snapshot) {
        this.balance = snapshot.balance;
        const events = this.eventStore.getEvents(this.accountId);
        // Only apply events after the snapshot
        const eventsAfterSnapshot = events.slice(snapshot.version);
        for (const event of eventsAfterSnapshot) {
            this.applyEvent(event);
        }
    }
}

What is CQRS?

CQRS (Command Query Responsibility Segregation) is a pattern that separates read operations (queries) from write operations (commands).

Basic concept

Instead of using the same model for reading and writing:

// ❌ Traditional model: same model for reading and writing
class User {
    updateEmail(newEmail) { /* ... */ }
    getProfile() { /* ... */ }
}

You separate them into different models:

// ✅ CQRS: separate models for reading and writing
class UserCommand {
    updateEmail(newEmail) { /* ... */ }
}

class UserQuery {
    getProfile() { /* ... */ }
}

Advantages of CQRS

  1. Independent optimization: You can optimize reads and writes separately
  2. Scalability: You can scale reads and writes independently
  3. Flexibility: You can have multiple read models for different use cases
  4. Simplicity: Read models can be simpler (only data, no logic)

Disadvantages of CQRS

  1. Complexity: More complex than the traditional model
  2. Eventual consistency: Read models may be slightly outdated
  3. Synchronization: You need to keep read and write models synchronized

CQRS with Event Sourcing

CQRS and Event Sourcing work very well together:

// Command side: Writes events
class AccountCommandService {
    constructor(eventStore) {
        this.eventStore = eventStore;
    }
    
    async createAccount(accountId, initialBalance) {
        const event = new AccountCreated(accountId, initialBalance, new Date());
        await this.eventStore.append(accountId, event);
    }
    
    async deposit(accountId, amount) {
        const event = new MoneyDeposited(accountId, amount, new Date());
        await this.eventStore.append(accountId, event);
    }
    
    async withdraw(accountId, amount) {
        // Validate before writing
        const account = this.rebuildAccount(accountId);
        if (account.balance < amount) {
            throw new Error('Insufficient funds');
        }
        
        const event = new MoneyWithdrawn(accountId, amount, new Date());
        await this.eventStore.append(accountId, event);
    }
    
    rebuildAccount(accountId) {
        const events = this.eventStore.getEvents(accountId);
        const account = new Account(accountId);
        for (const event of events) {
            account.applyEvent(event);
        }
        return account;
    }
}
+
// Query side: Reads from optimized projections
class AccountQueryService {
    constructor(readModel) {
        this.readModel = readModel; // Database optimized for reads
    }
    
    async getBalance(accountId) {
        return await this.readModel.getBalance(accountId);
    }
    
    async getTransactionHistory(accountId) {
        return await this.readModel.getTransactions(accountId);
    }
}

Projections

Projections are state views created from events:

class AccountProjection {
    constructor() {
        this.accounts = new Map(); // Optimized read model
    }
    
    // Process events to keep the projection updated
    handleEvent(event) {
        switch (event.type) {
            case 'AccountCreated':
                this.accounts.set(event.accountId, {
                    accountId: event.accountId,
                    balance: event.initialBalance,
                    transactions: []
                });
                break;
            case 'MoneyDeposited':
                const account = this.accounts.get(event.accountId);
                account.balance += event.amount;
                account.transactions.push({
                    type: 'deposit',
                    amount: event.amount,
                    timestamp: event.timestamp
                });
                break;
            case 'MoneyWithdrawn':
                const account2 = this.accounts.get(event.accountId);
                account2.balance -= event.amount;
                account2.transactions.push({
                    type: 'withdrawal',
                    amount: event.amount,
                    timestamp: event.timestamp
                });
                break;
        }
    }
}

When to use Event Sourcing and CQRS

✅ Use Event Sourcing when:

  1. You need complete auditing: Financial systems, e-commerce, legal systems
  2. You need time travel: Being able to see the state at any point in time
  3. You need traceability: Knowing exactly what happened and why
  4. You need flexibility: Creating new state views without changing events
  5. You need advanced debugging: Replaying events to understand problems

✅ Use CQRS when:

  1. Reads and writes have different needs: Much more reads than writes
  2. You need independent scaling: Scale reads and writes separately
  3. You need to optimize separately: Different optimizations for reads and writes
  4. You have multiple views: Different ways of seeing the same data

❌ Do not use when:

  1. Simple application: If you don’t need these features, the complexity is not worth it
  2. Small team: Requires more knowledge and maintenance
  3. Immediate consistency is critical: CQRS has eventual consistency

Practical example: E-commerce System

// Events
class OrderCreated {
    constructor(orderId, userId, items, total) {
        this.type = 'OrderCreated';
        this.orderId = orderId;
        this.userId = userId;
        this.items = items;
        this.total = total;
        this.timestamp = new Date();
    }
}

class PaymentProcessed {
    constructor(orderId, amount, method) {
        this.type = 'PaymentProcessed';
        this.orderId = orderId;
        this.amount = amount;
        this.method = method;
        this.timestamp = new Date();
    }
}

class OrderShipped {
    constructor(orderId, trackingNumber) {
        this.type = 'OrderShipped';
        this.orderId = orderId;
        this.trackingNumber = trackingNumber;
        this.timestamp = new Date();
    }
}

// Command side
class OrderCommandService {
    async createOrder(orderId, userId, items, total) {
        const event = new OrderCreated(orderId, userId, items, total);
        await eventStore.append(orderId, event);
    }
    
    async processPayment(orderId, amount, method) {
        const event = new PaymentProcessed(orderId, amount, method);
        await eventStore.append(orderId, event);
    }
}

// Query side
class OrderQueryService {
    async getOrderHistory(userId) {
        // Read from optimized projection
        return await readModel.getUserOrders(userId);
    }
    
    async getOrderStatus(orderId) {
        // Read from projection
        return await readModel.getOrderStatus(orderId);
    }
}

// Projection
class OrderProjection {
    handleEvent(event) {
        switch (event.type) {
            case 'OrderCreated':
                // Update read model
                break;
            case 'PaymentProcessed':
                // Update read model
                break;
            case 'OrderShipped':
                // Update read model
                break;
        }
    }
}

Best practices

1. Immutable events

Events should never change once created. They are the historical record.

2. Event versioning

If you need to change the structure of an event, create a new version:

class MoneyDepositedV1 {
    constructor(accountId, amount) {
        this.version = 1;
        // ...
    }
}

class MoneyDepositedV2 {
    constructor(accountId, amount, reason) {
        this.version = 2;
        // ...
    }
}

3. Validation in commands

Validate before creating events:

async withdraw(accountId, amount) {
    // Validate first
    const account = this.rebuildAccount(accountId);
    if (account.balance < amount) {
        throw new Error('Insufficient funds');
    }
    
    // Only create event if valid
    const event = new MoneyWithdrawn(accountId, amount, new Date());
    await this.eventStore.append(accountId, event);
}

4. Snapshots for performance

Use snapshots when there are many events to avoid reconstructing from scratch.

5. Eventually consistent projections

Projections can be slightly outdated. Ensure your application can handle this.

My personal perspective

Event Sourcing and CQRS are powerful patterns for systems that need:

  • Complete and immutable history
  • Auditing and traceability
  • Time travel (reconstructing state at any point)
  • Independent optimization of reads and writes

They are not for every application. They add complexity, but when you need them, they are invaluable.

I have worked with systems that required complete auditing, and Event Sourcing was the difference between being able to trace exactly what happened and having to guess. I have worked with systems where CQRS allowed scaling reads and writes independently, solving performance problems that the traditional model could not resolve.

For financial systems, e-commerce with auditing requirements, or any application where you need to know exactly what happened and when, Event Sourcing and CQRS can be the difference between a system that can audit and one that cannot.

The key is to understand when these patterns make sense and when the extra complexity is not worth it. For most applications, the traditional model is enough. But when you need these characteristics, Event Sourcing and CQRS are powerful tools that can make the difference.