Arquitectura 10 min de lectura

Event Sourcing y CQRS

Publicado el 20 de enero de 2026

Event Sourcing y CQRS

En la mayoría de aplicaciones tradicionales, guardas el estado actual de tus datos. Si un usuario tiene $1000 en su cuenta, guardas ese valor. Si luego hace una transacción de $100, actualizas el valor a $900. El historial de transacciones se pierde, o se guarda por separado.

Pero ¿qué pasa si necesitas saber exactamente qué pasó, cuándo pasó, y por qué pasó? ¿Qué pasa si necesitas poder reconstruir el estado en cualquier punto del tiempo? ¿Qué pasa si necesitas un historial inmutable de todos los cambios?

Event Sourcing y CQRS son patrones arquitectónicos que resuelven estos problemas. Son especialmente útiles para sistemas financieros, e-commerce, y cualquier aplicación que necesita auditoría completa y trazabilidad.

En este artículo, explicaré qué son Event Sourcing y CQRS, cuándo usarlos, y cómo implementarlos en la práctica.

El problema con el modelo tradicional

En el modelo tradicional, guardas el estado actual:

// Modelo tradicional
class Account {
    constructor(id, balance) {
        this.id = id;
        this.balance = balance; // Solo el estado actual
    }
    
    deposit(amount) {
        this.balance += amount;
        // El historial se pierde
    }
    
    withdraw(amount) {
        this.balance -= amount;
        // El historial se pierde
    }
}

Problemas:

  • No puedes ver el historial de cambios
  • No puedes reconstruir el estado en un punto del tiempo
  • No puedes auditar qué pasó
  • Si hay un error, no puedes revertir fácilmente

¿Qué es Event Sourcing?

Event Sourcing es un patrón donde, en lugar de guardar el estado actual, guardas una secuencia de eventos que representan todos los cambios que han ocurrido.

Concepto básico

// En lugar de guardar: balance = 900
// Guardas eventos:
// 1. AccountCreated(id: "123", initialBalance: 1000)
// 2. MoneyDeposited(accountId: "123", amount: 500)
// 3. MoneyWithdrawn(accountId: "123", amount: 600)
// 4. MoneyWithdrawn(accountId: "123", amount: 100)

// El estado actual se calcula aplicando todos los eventos
// balance = 1000 + 500 - 600 - 100 = 800

Ventajas de Event Sourcing

  1. Historial completo: Tienes un registro de todos los cambios
  2. Auditoría: Puedes ver exactamente qué pasó y cuándo
  3. Time travel: Puedes reconstruir el estado en cualquier punto del tiempo
  4. Debugging: Puedes reproducir eventos para entender problemas
  5. Flexibilidad: Puedes crear nuevas vistas del estado sin cambiar los eventos

Desventajas de Event Sourcing

  1. Complejidad: Más complejo que el modelo tradicional
  2. Performance: Reconstruir el estado puede ser lento si hay muchos eventos
  3. Storage: Puede requerir más almacenamiento
  4. Curva de aprendizaje: Requiere entender conceptos nuevos

Implementación básica de Event Sourcing

Definir eventos

// Eventos son objetos inmutables que representan algo que pasó
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;
    }
}

Almacenar eventos

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);
    }
}

Reconstruir estado desde eventos

class Account {
    constructor(accountId, eventStore) {
        this.accountId = accountId;
        this.eventStore = eventStore;
        this.balance = 0;
        
        // Reconstruir estado desde eventos
        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 para optimización

Si hay muchos eventos, reconstruir el estado puede ser lento. Los snapshots ayudan:

class Account {
    // ... código anterior ...
    
    // Cada 100 eventos, guardar un 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);
        // Solo aplicar eventos después del snapshot
        const eventsAfterSnapshot = events.slice(snapshot.version);
        for (const event of eventsAfterSnapshot) {
            this.applyEvent(event);
        }
    }
}

¿Qué es CQRS?

CQRS (Command Query Responsibility Segregation) es un patrón que separa las operaciones de lectura (queries) de las operaciones de escritura (commands).

Concepto básico

En lugar de usar el mismo modelo para leer y escribir:

// ❌ Modelo tradicional: mismo modelo para leer y escribir
class User {
    updateEmail(newEmail) { /* ... */ }
    getProfile() { /* ... */ }
}

Separas en modelos diferentes:

// ✅ CQRS: modelos separados para leer y escribir
class UserCommand {
    updateEmail(newEmail) { /* ... */ }
}

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

Ventajas de CQRS

  1. Optimización independiente: Puedes optimizar lecturas y escrituras por separado
  2. Escalabilidad: Puedes escalar lecturas y escrituras independientemente
  3. Flexibilidad: Puedes tener múltiples modelos de lectura para diferentes casos de uso
  4. Simplicidad: Los modelos de lectura pueden ser más simples (solo datos, sin lógica)

Desventajas de CQRS

  1. Complejidad: Más complejo que el modelo tradicional
  2. Consistencia eventual: Los modelos de lectura pueden estar ligeramente desactualizados
  3. Sincronización: Necesitas mantener sincronizados los modelos de lectura y escritura

CQRS con Event Sourcing

CQRS y Event Sourcing funcionan muy bien juntos:

// Command side: Escribe eventos
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) {
        // Validar antes de escribir
        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: Lee desde proyecciones optimizadas
class AccountQueryService {
    constructor(readModel) {
        this.readModel = readModel; // Base de datos optimizada para lecturas
    }
    
    async getBalance(accountId) {
        return await this.readModel.getBalance(accountId);
    }
    
    async getTransactionHistory(accountId) {
        return await this.readModel.getTransactions(accountId);
    }
}

Proyecciones (Projections)

Las proyecciones son vistas del estado creadas desde eventos:

class AccountProjection {
    constructor() {
        this.accounts = new Map(); // Read model optimizado
    }
    
    // Procesar eventos para mantener la proyección actualizada
    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;
        }
    }
}

Cuándo usar Event Sourcing y CQRS

✅ Usa Event Sourcing cuando:

  1. Necesitas auditoría completa: Sistemas financieros, e-commerce, sistemas legales
  2. Necesitas time travel: Poder ver el estado en cualquier punto del tiempo
  3. Necesitas trazabilidad: Saber exactamente qué pasó y por qué
  4. Necesitas flexibilidad: Crear nuevas vistas del estado sin cambiar eventos
  5. Necesitas debugging avanzado: Reproducir eventos para entender problemas

✅ Usa CQRS cuando:

  1. Lecturas y escrituras tienen diferentes necesidades: Muchas más lecturas que escrituras
  2. Necesitas escalar independientemente: Escalar lecturas y escrituras por separado
  3. Necesitas optimizar por separado: Diferentes optimizaciones para lecturas y escrituras
  4. Tienes múltiples vistas: Diferentes formas de ver los mismos datos

❌ No uses cuando:

  1. Aplicación simple: Si no necesitas estas características, la complejidad no vale la pena
  2. Equipo pequeño: Requiere más conocimiento y mantenimiento
  3. Consistencia inmediata crítica: CQRS tiene consistencia eventual

Ejemplo práctico: Sistema de E-commerce

// Eventos
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) {
        // Leer desde proyección optimizada
        return await readModel.getUserOrders(userId);
    }
    
    async getOrderStatus(orderId) {
        // Leer desde proyección
        return await readModel.getOrderStatus(orderId);
    }
}

// Proyección
class OrderProjection {
    handleEvent(event) {
        switch (event.type) {
            case 'OrderCreated':
                // Actualizar read model
                break;
            case 'PaymentProcessed':
                // Actualizar read model
                break;
            case 'OrderShipped':
                // Actualizar read model
                break;
        }
    }
}

Mejores prácticas

1. Eventos inmutables

Los eventos nunca deben cambiar una vez creados. Son el registro histórico.

2. Versionado de eventos

Si necesitas cambiar la estructura de un evento, crea una nueva versión:

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

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

3. Validación en commands

Valida antes de crear eventos:

async withdraw(accountId, amount) {
    // Validar primero
    const account = this.rebuildAccount(accountId);
    if (account.balance < amount) {
        throw new Error('Insufficient funds');
    }
    
    // Solo crear evento si es válido
    const event = new MoneyWithdrawn(accountId, amount, new Date());
    await this.eventStore.append(accountId, event);
}

4. Snapshots para performance

Usa snapshots cuando hay muchos eventos para evitar reconstruir desde cero.

5. Proyecciones eventualmente consistentes

Las proyecciones pueden estar ligeramente desactualizadas. Asegúrate de que tu aplicación pueda manejar esto.

Mi perspectiva personal

Event Sourcing y CQRS son patrones poderosos para sistemas que necesitan:

  • Historial completo e inmutable
  • Auditoría y trazabilidad
  • Time travel (reconstruir estado en cualquier punto)
  • Optimización independiente de lecturas y escrituras

No son para todas las aplicaciones. Agregan complejidad, pero cuando los necesitas, son invaluables.

He trabajado con sistemas que necesitaban auditoría completa, y Event Sourcing fue la diferencia entre poder rastrear exactamente qué pasó y tener que adivinar. He trabajado con sistemas donde CQRS permitió escalar lecturas y escrituras independientemente, resolviendo problemas de rendimiento que el modelo tradicional no podía resolver.

Para sistemas financieros, e-commerce con requisitos de auditoría, o cualquier aplicación donde necesitas saber exactamente qué pasó y cuándo, Event Sourcing y CQRS pueden ser la diferencia entre un sistema que puede auditar y uno que no puede.

La clave es entender cuándo estos patrones tienen sentido y cuándo la complejidad adicional no vale la pena. Para la mayoría de aplicaciones, el modelo tradicional es suficiente. Pero cuando necesitas estas características, Event Sourcing y CQRS son herramientas poderosas que pueden hacer la diferencia.