Node.js Backend Development Bootcamp · Lección

Instantáneas, versionado y evolución del esquema de eventos

Acelere la rehidratación mediante instantáneas y evolucione los esquemas de eventos sin romper los datos históricos.

Lección 4 de 413 pasos

Instantáneas, versionado y evolución del esquema de eventos es una lección gratuita de Node.js Backend Development Bootcamp en CoddyKit. Esta es la lección 4 de 4. Puedes leer la lección completa abajo gratuitamente — luego la practicas en el navegador con un editor de código integrado y un tutor de IA 24/7. Forma parte de la ruta de aprendizaje de Node.js Backend Development Bootcamp, y tu progreso se sincroniza en la web y la app de CoddyKit. El curso de Node.js Backend Development Bootcamp incluye 4 lecciones en total.

Partes de esta lección aún no han sido traducidas y se muestran en inglés.

Why Rehydration Gets Slow

In Event Sourcing, the current state of an aggregate is rebuilt by replaying every event from the start of its stream. This is called rehydration.

For a fresh bank account with 12 events, replaying them is instant. But a long-lived aggregate (an account open for 5 years, a shopping cart with thousands of edits) can accumulate tens of thousands of events. Loading and folding all of them on every command becomes the bottleneck.

  • I/O cost: reading thousands of rows from the event store
  • CPU cost: folding each event through the reducer
  • Latency: every write must first rehydrate before validating the next command

The standard fix is a snapshot: a periodic, serialized copy of the aggregate state so you only replay events after it.

Rehydration as a Fold

Before optimizing, make rehydration explicit. An aggregate's state is a left fold over its events: state = events.reduce(apply, initialState).

Here is a minimal account aggregate. The apply function is a pure reducer — given the previous state and one event, it returns the next state. Notice it never mutates; it returns new objects so it stays easy to reason about and test.

function apply(state, event) {
  switch (event.type) {
    case 'AccountOpened':
      return { ...state, balance: 0, version: state.version + 1 };
    case 'MoneyDeposited':
      return { ...state, balance: state.balance + event.amount, version: state.version + 1 };
    case 'MoneyWithdrawn':
      return { ...state, balance: state.balance - event.amount, version: state.version + 1 };
    default:
      return state;
  }
}

function rehydrate(events) {
  const initial = { balance: 0, version: 0 };
  return events.reduce(apply, initial);
}

const events = [
  { type: 'AccountOpened' },
  { type: 'MoneyDeposited', amount: 100 },
  { type: 'MoneyWithdrawn', amount: 30 },
];

console.log(rehydrate(events)); // { balance: 70, version: 3 }

What a Snapshot Actually Is

A snapshot is a saved tuple of (aggregateId, version, serializedState). The version is the event sequence number the state reflects — this is the critical part.

To rehydrate with a snapshot:

  • Load the latest snapshot for the aggregate
  • Seed the fold with the snapshot state instead of initialState
  • Replay only events with version > snapshot.version

Key rule: a snapshot is a derived cache, never the source of truth. The event stream remains authoritative. If a snapshot is lost or corrupted, you can always rebuild it by replaying from zero.

Rehydrating From a Snapshot

This version loads from a snapshot when one exists, then folds only the newer events. The snapshot already encodes version, so we know exactly where to resume.

In production the snapshot and the events come from your store; here they are passed in so the logic runs standalone.

function apply(state, event) {
  switch (event.type) {
    case 'MoneyDeposited':
      return { ...state, balance: state.balance + event.amount, version: event.version };
    case 'MoneyWithdrawn':
      return { ...state, balance: state.balance - event.amount, version: event.version };
    default:
      return { ...state, version: event.version };
  }
}

function loadFromSnapshot(snapshot, allEvents) {
  const start = snapshot ? snapshot.state : { balance: 0, version: 0 };
  const fromVersion = snapshot ? snapshot.version : 0;
  const newer = allEvents.filter((e) => e.version > fromVersion);
  return newer.reduce(apply, start);
}

const snapshot = { version: 3, state: { balance: 70, version: 3 } };
const events = [
  { type: 'MoneyDeposited', amount: 100, version: 1 },
  { type: 'MoneyWithdrawn', amount: 30, version: 3 },
  { type: 'MoneyDeposited', amount: 50, version: 4 },
];

console.log(loadFromSnapshot(snapshot, events)); // { balance: 120, version: 4 }

When to Take a Snapshot

You don't snapshot on every event — that defeats the purpose. Common strategies:

  • Every N events: e.g. snapshot whenever version % 100 === 0. Simple and predictable.
  • By replay cost: snapshot when the gap between current version and last snapshot exceeds a threshold.
  • Async / background: a separate worker subscribes to the stream and writes snapshots off the hot path, so writes never wait on snapshotting.

Tune N empirically. Too low wastes storage and write throughput; too high means long replays. For many systems N = 50–200 is a reasonable starting point.

const SNAPSHOT_EVERY = 100;

function shouldSnapshot(currentVersion, lastSnapshotVersion) {
  // Snapshot when we've crossed another multiple of N
  const since = currentVersion - lastSnapshotVersion;
  return since >= SNAPSHOT_EVERY;
}

console.log(shouldSnapshot(100, 0));   // true
console.log(shouldSnapshot(140, 100)); // false
console.log(shouldSnapshot(205, 100)); // true

The Hidden Trap: Snapshots Are Versioned Too

Here is the subtle danger. A snapshot stores the shape of your state at the moment it was written. If you later change how state is structured (rename a field, add a required property, change a type), old snapshots become incompatible.

Imagine you stored { balance: 70 }, then refactored to { balanceCents: 7000 }. A six-month-old snapshot still has balance, and your new apply code expects balanceCents. Rehydration silently produces wrong state.

Rule: tag every snapshot with a schemaVersion. On load, if the snapshot's schema version doesn't match the current code, discard it and replay from the events (which are upcasted — covered next). Events are forever; snapshots are disposable.

Guarding Snapshot Schema Version

This guard checks the snapshot's schemaVersion before trusting it. If it's stale, we fall back to a full replay. Because events are the source of truth, falling back is always safe — it's just slower for that one load, and the next snapshot will be written in the new shape.

const CURRENT_SCHEMA = 2;

function chooseStartState(snapshot) {
  if (snapshot && snapshot.schemaVersion === CURRENT_SCHEMA) {
    return { state: snapshot.state, fromVersion: snapshot.version };
  }
  // Stale or missing snapshot: replay everything
  return { state: { balanceCents: 0, version: 0 }, fromVersion: 0 };
}

const stale = { schemaVersion: 1, version: 300, state: { balance: 70 } };
const fresh = { schemaVersion: 2, version: 300, state: { balanceCents: 7000, version: 300 } };

console.log(chooseStartState(stale).fromVersion); // 0 -> full replay
console.log(chooseStartState(fresh).fromVersion); // 300 -> fast path

Evolving Event Schemas: The Real Constraint

Events are immutable historical facts. You can never go back and edit a MoneyDeposited event from two years ago — it really happened in that shape. Yet your code must keep evolving.

So when business needs change, you cannot rewrite history. Instead you have a small set of disciplined techniques:

  • Weak schema / additive change: only ever add optional fields; never remove or repurpose existing ones.
  • Upcasting: transform old event versions into the current shape at read time.
  • New event type: when a change is too big, introduce a new event type and stop emitting the old one.

The golden rule: old events must remain readable forever. Every consumer reads through an upcaster, so the rest of your code only ever sees the latest shape.

Upcasting Old Events at Read Time

Upcasting means: as each event is read from the store, run it through a function that migrates older versions up to the current shape before it reaches apply. Each event carries an eventVersion so the upcaster knows what to do.

Example evolution: v1 deposits had a single amount in dollars. v2 splits money into currency + integer amountCents. Old v1 events get upcasted on the fly — without ever touching the stored data.

function upcast(event) {
  if (event.type === 'MoneyDeposited' && (event.eventVersion || 1) === 1) {
    return {
      ...event,
      eventVersion: 2,
      currency: 'USD',
      amountCents: Math.round(event.amount * 100),
    };
  }
  return event;
}

const stored = [
  { type: 'MoneyDeposited', amount: 100 },                         // legacy v1
  { type: 'MoneyDeposited', eventVersion: 2, currency: 'USD', amountCents: 5000 },
];

const upcasted = stored.map(upcast);
console.log(upcasted[0]); // { ...amountCents: 10000, currency: 'USD', eventVersion: 2 }
console.log(upcasted.every((e) => typeof e.amountCents === 'number')); // true

Chaining Upcasters Across Multiple Versions

Schemas evolve more than once. The clean pattern is a chain of single-step upcasters: v1→v2, v2→v3, v3→v4. An event enters at its stored version and is pushed forward step by step until it reaches the latest version. This keeps each migration small, testable, and independent.

Never write a giant v1→v4 function — you'd duplicate logic and it rots fast. Compose small steps instead.

const upcasters = {
  1: (e) => ({ ...e, eventVersion: 2, amountCents: Math.round(e.amount * 100) }),
  2: (e) => ({ ...e, eventVersion: 3, currency: e.currency || 'USD' }),
  3: (e) => ({ ...e, eventVersion: 4, source: e.source || 'unknown' }),
};

const LATEST = 4;

function upcastToLatest(event) {
  let e = { ...event, eventVersion: event.eventVersion || 1 };
  while (e.eventVersion < LATEST) {
    e = upcasters[e.eventVersion](e);
  }
  return e;
}

const legacy = { type: 'MoneyDeposited', amount: 42 };
const result = upcastToLatest(legacy);
console.log(result.eventVersion);  // 4
console.log(result.amountCents);   // 4200
console.log(result.currency);      // USD
console.log(result.source);        // unknown

Putting It Together: Safe, Fast Loading

A production rehydration path combines everything:

  • Load the latest snapshot; discard it if its schemaVersion is stale
  • Read events after the (valid) snapshot version
  • Upcast every event to the latest shape
  • Fold them onto the start state
  • Optionally write a new snapshot if the replay gap was large

This gives you O(events-since-snapshot) loads in the happy path, automatic correctness when state shape changes, and forward-compatible event reading. Snapshots make it fast; upcasting keeps it correct as the system evolves.

Operational tip: when you ship a breaking state refactor, you don't need to migrate snapshots — just bump CURRENT_SCHEMA and let the next load rebuild them lazily.

Quick Check

Test your understanding of the safe-evolution strategy.

Recap

You learned how to make Event Sourcing both fast and durable over time:

  • Rehydration is a left fold over events; long streams make it slow.
  • Snapshots store (aggregateId, version, state) so you replay only events after them — a derived cache, never the source of truth.
  • Snapshot strategy: take one every N events or by replay-gap, ideally on a background worker.
  • Tag snapshots with schemaVersion; discard and full-replay when state shape changes — no snapshot migration needed.
  • Events are immutable. Evolve safely with additive-only changes, upcasting old events to the latest shape at read time, or introducing new event types.
  • Chain single-step upcasters (v1→v2→v3) so every consumer sees only the current shape.

Snapshots give you speed; versioning and upcasting give you the freedom to evolve without ever breaking history.

Gratis para empezar

Aprende JavaScript con un tutor de IA — gratis

Escribe y ejecuta código real en tu navegador, obtén ayuda instantánea de un tutor de IA disponible 24/7 y continúa donde lo dejaste en la web o en la aplicación.

Cursos
22
Lecciones
92

Preguntas frecuentes

¿La lección «Instantáneas, versionado y evolución del esquema de eventos» es gratis?

Sí — el texto completo de «Instantáneas, versionado y evolución del esquema de eventos» es gratis para leer aquí en la web. Para practicarla de forma interactiva (editor de código integrado y tutor de IA 24/7) y desbloquear el resto del curso de Node.js Backend Development Bootcamp, actualiza a CoddyKit PRO. El curso de Node.js Backend Development Bootcamp incluye 4 lecciones en total.

¿Qué aprenderé en «Instantáneas, versionado y evolución del esquema de eventos»?

Acelere la rehidratación mediante instantáneas y evolucione los esquemas de eventos sin romper los datos históricos. Practicas Node.js Backend Development Bootcamp con código real que ejecutas directamente en el navegador, y un tutor de IA 24/7 responde tus preguntas mientras trabajas en la lección.

¿Necesito experiencia previa para empezar Node.js Backend Development Bootcamp?

No se requiere experiencia previa. Node.js Backend Development Bootcamp en CoddyKit está estructurado para principiantes hasta estudiantes avanzados, así que puedes empezar aquí o desde el inicio y avanzar a tu ritmo. Esta es la lección 4 de 4.

¿Cuánto tiempo toma la lección «Instantáneas, versionado y evolución del esquema de eventos»?

La mayoría de las lecciones de CoddyKit toman alrededor de 5–10 minutos. Cada una es compacta e interactiva, así que avanzas constantemente y retomas exactamente por donde dejaste en la web y la app.

¿Puedo escribir y ejecutar código en esta lección de Node.js Backend Development Bootcamp?

Sí. Cada lección de Node.js Backend Development Bootcamp incluye un editor de código integrado, así que escribes y ejecutas código real directamente en tu navegador y obtienes retroalimentación instantánea de IA — sin configuración local necesaria.

Todas las lecciones de este curso

  1. Los eventos como fuente de verdad y el log de solo anexado
  2. Agregados, comandos y modelado de eventos de dominio
  3. Construcción de modelos de lectura y proyecciones
  4. Instantáneas, versionado y evolución del esquema de eventos
← Volver a Node.js Backend Development Bootcamp