Saltar al contenido

Evolución de esquemas de eventos sin romper consumidores

Cómo añadir campos, renombrar propiedades y reestructurar eventos en sistemas asíncronos sin coordinar despliegues ni romper a los consumidores.

5 min de lectura
Diagrama que muestra la compatibilidad entre productor y consumidor a través de versiones de esquemas de eventos

Los sistemas basados en eventos desacoplan servicios de forma elegante — hasta que necesitas cambiar el esquema de un evento. De repente, el desacoplamiento juega en tu contra. Productores y consumidores se despliegan de forma independiente, los mensajes permanecen en una cola durante horas o días, y no hay un HTTP 422 que te avise de que algo se rompió. Los cambios de esquema hechos mal corrompen el estado en silencio o hacen colapsar workers a las 2 AM.

Este no es un problema de herramientas que resuelvas eligiendo el broker de mensajes adecuado. Es un problema de disciplina. Este es el marco que aplico antes de tocar cualquier esquema de eventos en producción.

Por qué evolucionar esquemas es más difícil que evolucionar APIs

Con las APIs REST, los cambios incompatibles son visibles. El cliente recibe un 400, los reintentos fallan, los dashboards se ponen rojos. El ciclo de retroalimentación es rápido.

Con los sistemas basados en eventos, el ciclo de retroalimentación está roto:

  • Los consumidores van rezagados: Un consumidor podría estar procesando mensajes producidos días antes cuando despliegas un cambio de esquema. Los mensajes antiguos que ya están en la cola ahora fallan la validación.
  • Múltiples consumidores: Un cambio de esquema puede romper tres servicios distintos — y solo uno pertenece a tu equipo.
  • No hay un handshake síncrono: No hay un momento en el que productor y consumidor negocian un contrato. El contrato vive en la documentación, o peor, en el conocimiento tribal.

La solución no es evitar los cambios de esquema. Es hacer solo cambios compatibles, y tener un patrón deliberado para los incompatibles.

Los cuatro modos de compatibilidad

Antes de tocar un esquema, clasifica el cambio:

Tipo de cambioEjemploCompatibilidad
Agregar campo opcional+ correlationId?: stringHacia atrás ✅ Hacia adelante ✅
Eliminar campo opcional- deprecatedField?Solo hacia adelante ⚠️
Agregar campo obligatorio+ requiredAt: stringNinguna ❌
Renombrar o cambiar el tipostatus: boolean → stringNinguna ❌

Compatible hacia atrás significa que los consumidores antiguos pueden leer mensajes nuevos. Compatible hacia adelante significa que los consumidores nuevos pueden leer mensajes antiguos. La compatibilidad completa en ambas direcciones es el único modo seguro para sistemas de eventos de larga vida en los que no puedes coordinar despliegues entre equipos.

El patrón expandir-contraer

Para cambios incompatibles, nunca mutas el esquema de un solo golpe. Usa tres fases:

  1. Expandir: Agrega el nuevo campo junto al viejo. Produce ambos.
  2. Migrar: Actualiza todos los consumidores para leer el nuevo campo. Depreca el viejo.
  3. Contraer: Elimina el campo viejo cuando ningún consumidor lo referencie.
tstypescript
// Phase 1 — Expand: produce both old and new field simultaneously
interface OrderPlacedExpanded {
  orderId: string;
  customerId: string;   // kept for old consumers still reading this field
  accountId: string;    // new canonical field for new consumers
  totalCents: number;
}
 
function produceOrderPlaced(order: Order): OrderPlacedExpanded {
  return {
    orderId: order.id,
    customerId: order.account.id,  // backward compat
    accountId: order.account.id,   // forward compat
    totalCents: order.totalCents,
  };
}
tstypescript
// Phase 2 — Consumers prefer the new field with a fallback
function handleOrderPlaced(event: OrderPlacedExpanded): void {
  // ✅ graceful fallback during the transition window
  const accountId = event.accountId ?? event.customerId;
  processOrder(accountId, event.orderId, event.totalCents);
}

Solo después de que cada consumidor esté desplegado con el fallback pasas a la fase 3 y eliminas customerId. Saltarte la fase 2 hará que los consumidores se caigan con mensajes que ya están en la cola — mensajes producidos antes de tu despliegue.

El patrón de sobre para cambios incompatibles

Cuando un cambio verdaderamente incompatible es inevitable — reestructurar un evento, cambiar la semántica de un campo — introduce una nueva versión del esquema. El patrón de sobre hace explícito el enrutamiento por versión en lugar de depender de adivinar la forma:

tstypescript
interface EventEnvelope<T = unknown> {
  eventId: string;
  eventType: string;
  schemaVersion: number;
  occurredAt: string;     // ISO 8601
  payload: T;
}
 
interface OrderPlacedV1Payload {
  orderId: string;
  customerId: string;
  totalCents: number;
}
 
interface OrderPlacedV2Payload {
  orderId: string;
  account: { id: string; email: string };
  total: { cents: number; currency: string };
}
 
type OrderPlacedEvent =
  | (EventEnvelope<OrderPlacedV1Payload> & { schemaVersion: 1 })
  | (EventEnvelope<OrderPlacedV2Payload> & { schemaVersion: 2 });

El consumidor despacha por versión, no por heurísticas de presencia de campos:

tstypescript
function handleOrderPlaced(envelope: OrderPlacedEvent): void {
  if (envelope.schemaVersion === 1) {
    const { orderId, customerId, totalCents } = envelope.payload;
    processOrderV1(orderId, customerId, totalCents);
    return;
  }
 
  if (envelope.schemaVersion === 2) {
    const { orderId, account, total } = envelope.payload;
    processOrderV2(orderId, account.id, total.cents, total.currency);
    return;
  }
 
  // exhaustive check — compiler warns if a new version is added without handling it
  const _exhaustive: never = envelope;
}
!

Evita incrustar la versión en el nombre del tipo de evento (order.placed.v2). Acopla el enrutamiento a preocupaciones del esquema y prolifera los nombres de topic. Prefiere un campo schemaVersion en el sobre con un único topic estable por tipo de evento lógico.

Validación en tiempo de ejecución en el límite del consumidor

Los tipos de TypeScript desaparecen en tiempo de ejecución. Cuando llega un mensaje del broker, no conoces su forma hasta validarlo. Empareja tus interfaces con un esquema en tiempo de ejecución:

tstypescript
import { z } from "zod";
 
const OrderPlacedV2Schema = z.object({
  eventId: z.string().uuid(),
  eventType: z.literal("order.placed"),
  schemaVersion: z.literal(2),
  occurredAt: z.string().datetime(),
  payload: z.object({
    orderId: z.string(),
    account: z.object({ id: z.string(), email: z.string().email() }),
    total: z.object({
      cents: z.number().int().positive(),
      currency: z.string().length(3),
    }),
  }),
});
 
// ❌ Trusting the message shape — the compiler is lying to you
async function consumeRaw(message: Buffer): Promise<void> {
  const event = JSON.parse(message.toString()) as OrderPlacedV2; // cast, not safety
  processOrder(event.payload.orderId);                            // crashes if shape is wrong
}
 
// ✅ Validate at the boundary before touching the payload
async function consume(message: Buffer): Promise<void> {
  const parsed = JSON.parse(message.toString());
  const result = OrderPlacedV2Schema.safeParse(parsed);
 
  if (!result.success) {
    logger.error("Schema validation failed", {
      errors: result.error.flatten(),
      rawEvent: parsed,
    });
    await deadLetterQueue.send(message); // park it, don't drop it
    return;
  }
 
  processOrder(result.data.payload.orderId);
}

La validación fallida pertenece a una cola de mensajes muertos, no a un crash ni a un silencioso descarte. Ambos resultados son peores que estacionar el mensaje para inspección manual con todo el contexto.

Saber cuándo termina la transición

La parte más difícil de la evolución de esquemas no es el código — es saber cuándo es seguro completar la fase 3. Algunas señales que realmente funcionan:

Métricas de deprecación del lado del productor: Incrementa un contador cada vez que se escribe el campo viejo. Cuando el contador se aplana después de la migración, el campo se puede eliminar.

Contadores de fallback del lado del consumidor: Rastrea con qué frecuencia event.accountId ?? event.customerId usa la rama de fallback. Un contador que llega a cero es una señal fiable de migración — no una fecha de despliegue.

Aplicación del schema registry: Herramientas como Confluent Schema Registry o AWS Glue Schema Registry pueden rechazar publicaciones de esquemas incompatibles en tiempo de CI, antes de que lleguen a producción.

shbash
# Validate schema compatibility before publishing — runs in CI
curl -s -o /dev/null -w "%{http_code}" \
  -X POST \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  --data '{"schema":"{...}"}' \
  "http://registry:8081/compatibility/subjects/order-placed-value/versions/latest"
 
# Returns 200 with {"is_compatible":true} or fail the pipeline

Usar un registry mueve la validación de compatibilidad a la izquierda — detectas el cambio incompatible en el pull request, no en producción, y no a las 2 AM.

Puntos clave

  1. Clasifica antes de cambiar: Cada modificación de esquema es compatible hacia atrás, compatible hacia adelante, o ninguna. Conoce cuál es antes de escribir una sola línea.
  2. Expande antes de contraer: Los cambios incompatibles requieren tres fases de despliegue — expandir, migrar, contraer. Saltarte la fase de migración corrompe los mensajes que ya están en la cola.
  3. Sobres para cada evento: Un campo schemaVersion en un sobre estándar convierte la adivinanza implícita de formas en un despacho de versiones explícito y verificado por el compilador.
  4. Valida en el límite del consumidor: La validación de esquemas en tiempo de ejecución detecta la discrepancia. Manda los fallos a dead-letter — nunca los descartes en silencio.
  5. Rastrea la transición con métricas: Los contadores de deprecación y los aciertos de la rama de fallback te indican cuándo la migración está completa. No confíes en las fechas de despliegue.
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX