Saltar al contenido

Event Sourcing en la práctica: patrones y trampas

Guía práctica de event sourcing: cuándo usarlo, cómo implementar event stores, construir proyecciones, manejar esquemas y evitar errores.

5 min de lectura
Cronología de event sourcing mostrando eventos añadidos a un stream y proyectados en modelos de lectura

El event sourcing almacena el estado como una secuencia de eventos en lugar de un estado actual mutable. En vez de actualizar una fila en la base de datos, añades un evento que describe lo que ocurrió. El estado actual se deriva reproduciendo los eventos. Esto te da una auditoría completa, la capacidad de reconstruir el estado en cualquier momento y consultas temporales que el CRUD tradicional no puede responder.

También introduce una complejidad considerable. El event sourcing no es adecuado para todos los dominios. Esta guía cubre cuándo funciona, cómo implementarlo y los errores que suelen sorprender a los equipos.

Cuándo tiene sentido el event sourcing

El event sourcing brilla en dominios donde el historial de cambios es tan valioso como el estado actual.

Good fits:
- Financial systems — every transaction must be auditable
- Inventory management — track every stock movement
- Booking/reservation systems — cancellations and modifications matter
- Collaborative editing — track every change by every user
- Compliance-heavy domains — regulatory audit trails required

Poor fits:
- Simple CRUD applications — blog posts, user profiles
- High-throughput writes with no audit needs — telemetry data
- Systems where current state is all that matters

La prueba: si tus interesados de negocio verían valor en responder «¿cuál era el estado de X en el momento T?» o «¿qué secuencia de acciones llevó a este estado?», vale la pena considerar el event sourcing.

El event store

Un event store es un registro de solo anexado donde cada stream representa el historial de una entidad. Los eventos son inmutables: una vez escritos, no cambian.

tstypescript
interface DomainEvent {
  eventId: string;
  streamId: string;
  eventType: string;
  data: Record<string, unknown>;
  metadata: {
    timestamp: string;
    userId: string;
    correlationId: string;
  };
  version: number;
}
 
// ❌ Mutable state — no history, no audit trail
await db.orders.update(
  { _id: orderId },
  { $set: { status: 'shipped', shippedAt: new Date() } }
);
// Previous state is gone forever
tstypescript
// ✅ Event sourcing — append events, derive state
const events: DomainEvent[] = [
  {
    eventId: 'evt-001',
    streamId: 'order-123',
    eventType: 'OrderPlaced',
    data: {
      customerId: 'cust-456',
      items: [{ productId: 'prod-1', quantity: 2, price: 29.99 }],
      total: 59.98,
    },
    metadata: { timestamp: '2021-08-01T10:00:00Z', userId: 'cust-456', correlationId: 'req-789' },
    version: 1,
  },
  {
    eventId: 'evt-002',
    streamId: 'order-123',
    eventType: 'PaymentProcessed',
    data: { paymentId: 'pay-001', amount: 59.98, method: 'card' },
    metadata: { timestamp: '2021-08-01T10:00:05Z', userId: 'system', correlationId: 'req-789' },
    version: 2,
  },
  {
    eventId: 'evt-003',
    streamId: 'order-123',
    eventType: 'OrderShipped',
    data: { trackingNumber: '1Z999AA10123456784', carrier: 'UPS' },
    metadata: { timestamp: '2021-08-01T14:30:00Z', userId: 'staff-001', correlationId: 'req-912' },
    version: 3,
  },
];

Cada evento captura qué ocurrió, cuándo y quién lo desencadenó. El campo version permite la concurrencia optimista: se detectan dos escrituras simultáneas en el mismo stream y se rechaza una.

Reconstruyendo el estado a partir de eventos

Un agregado carga su stream de eventos y aplica cada evento para reconstruir su estado actual.

tstypescript
interface OrderState {
  id: string;
  status: 'placed' | 'paid' | 'shipped' | 'delivered' | 'cancelled';
  items: Array<{ productId: string; quantity: number; price: number }>;
  total: number;
  trackingNumber?: string;
}
 
function applyEvent(state: OrderState, event: DomainEvent): OrderState {
  switch (event.eventType) {
    case 'OrderPlaced':
      return {
        id: event.streamId,
        status: 'placed',
        items: event.data.items as OrderState['items'],
        total: event.data.total as number,
      };
 
    case 'PaymentProcessed':
      return { ...state, status: 'paid' };
 
    case 'OrderShipped':
      return {
        ...state,
        status: 'shipped',
        trackingNumber: event.data.trackingNumber as string,
      };
 
    case 'OrderDelivered':
      return { ...state, status: 'delivered' };
 
    case 'OrderCancelled':
      return { ...state, status: 'cancelled' };
 
    default:
      return state;
  }
}
 
function rehydrate(events: DomainEvent[]): OrderState {
  return events.reduce(
    (state, event) => applyEvent(state, event),
    {} as OrderState
  );
}
 
// Usage:
const orderEvents = await eventStore.getStream('order-123');
const currentState = rehydrate(orderEvents);
// { id: 'order-123', status: 'shipped', total: 59.98, trackingNumber: '1Z...' }

La función applyEvent es una función pura: dados los mismos eventos, siempre produce el mismo estado. Esto facilita probarla y razonar sobre ella.

Proyecciones: modelos de lectura

Reproducir eventos en cada lectura es costoso. Las proyecciones construyen modelos de lectura optimizados suscribiéndose a eventos y manteniendo vistas desnormalizadas.

tstypescript
// Projection: Order summary view (optimized for listing orders)
interface OrderSummary {
  orderId: string;
  customerName: string;
  total: number;
  status: string;
  itemCount: number;
  lastUpdated: string;
}
 
class OrderSummaryProjection {
  constructor(private db: Database) {}
 
  async handle(event: DomainEvent): Promise<void> {
    switch (event.eventType) {
      case 'OrderPlaced':
        await this.db.orderSummaries.insert({
          orderId: event.streamId,
          customerName: event.data.customerName,
          total: event.data.total,
          status: 'placed',
          itemCount: (event.data.items as unknown[]).length,
          lastUpdated: event.metadata.timestamp,
        });
        break;
 
      case 'OrderShipped':
        await this.db.orderSummaries.update(
          { orderId: event.streamId },
          {
            $set: {
              status: 'shipped',
              lastUpdated: event.metadata.timestamp,
            },
          }
        );
        break;
 
      case 'OrderCancelled':
        await this.db.orderSummaries.update(
          { orderId: event.streamId },
          {
            $set: {
              status: 'cancelled',
              lastUpdated: event.metadata.timestamp,
            },
          }
        );
        break;
    }
  }
}
 
// Multiple projections from the same events:
// - OrderSummaryProjection → order list page
// - CustomerOrderHistoryProjection → customer profile page
// - RevenueReportProjection → analytics dashboard

Las proyecciones pueden reconstruirse desde cero reproduciendo todos los eventos. Esto significa que puedes añadir nuevos modelos de lectura de forma retroactiva: creas una proyección que cuente pedidos por región, reproduces los eventos desde el principio y la nueva vista se llena inmediatamente con datos históricos.

Evolución de esquemas

Los eventos son inmutables, pero tu modelo de dominio evoluciona. Las nuevas versiones de eventos deben coexistir con las antiguas.

tstypescript
// ❌ Breaking change — old events in the store can't be read
interface OrderPlacedV2 {
  eventType: 'OrderPlaced';
  data: {
    customerId: string;
    shippingAddress: Address;   // New required field
    items: OrderItem[];
    total: number;
  };
}
 
// ✅ Upcasting — transform old events to the current schema
function upcast(event: DomainEvent): DomainEvent {
  if (event.eventType === 'OrderPlaced' && !event.data.shippingAddress) {
    return {
      ...event,
      data: {
        ...event.data,
        shippingAddress: {
          street: 'Unknown',
          city: 'Unknown',
          country: 'Unknown',
        },
      },
    };
  }
  return event;
}
 
// Apply upcasting when reading events
async function getStream(streamId: string): Promise<DomainEvent[]> {
  const rawEvents = await eventStore.read(streamId);
  return rawEvents.map(upcast);
}

El upcasting transforma las formas antiguas de los eventos en la forma esperada actual en el momento de lectura. Los eventos almacenados permanecen inmutables: la transformación se aplica en memoria al cargar el stream.

Snapshots para streams largos

Los streams con miles de eventos se vuelven costosos de reproducir. Los snapshots almacenan un punto de control del estado actual en una versión específica.

tstypescript
interface Snapshot<T> {
  streamId: string;
  state: T;
  version: number;       // The event version this snapshot reflects
  createdAt: string;
}
 
async function loadAggregate(streamId: string): Promise<OrderState> {
  // Try loading from snapshot first
  const snapshot = await snapshotStore.get<OrderState>(streamId);
 
  let state: OrderState;
  let fromVersion: number;
 
  if (snapshot) {
    state = snapshot.state;
    fromVersion = snapshot.version + 1;
  } else {
    state = {} as OrderState;
    fromVersion = 1;
  }
 
  // Load only events after the snapshot
  const events = await eventStore.getStream(streamId, { fromVersion });
  const currentState = events.reduce(
    (s, event) => applyEvent(s, event),
    state
  );
 
  // Save new snapshot if enough events accumulated
  const totalEvents = (snapshot?.version ?? 0) + events.length;
  if (events.length > 100) {
    await snapshotStore.save({
      streamId,
      state: currentState,
      version: totalEvents,
      createdAt: new Date().toISOString(),
    });
  }
 
  return currentState;
}

Los snapshots son una optimización, no un requisito. El sistema debe funcionar correctamente sin ellos: solo reducen el número de eventos reproducidos por lectura.

Puntos clave

  1. El event sourcing almacena lo que ocurrió, no lo que es el estado actual: el estado se deriva reproduciendo eventos
  2. Úsalo en dominios con mucha auditoría donde el historial, las consultas temporales y los registros de auditoría tienen valor de negocio
  3. Las proyecciones construyen modelos de lectura optimizados a partir de streams de eventos; puedes añadir nuevas proyecciones de forma retroactiva
  4. Maneja la evolución de esquemas mediante upcasting: transforma los eventos antiguos en la forma actual en el momento de lectura
  5. Los snapshots optimizan streams largos: guarda el estado periódicamente para evitar reproducir miles de eventos
  6. No uses event sourcing por defecto: el coste de complejidad es real; úsalo donde los beneficios lo justifiquen
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX