¿Cómo diseñas un esquema de eventos y manejas su evolución en el tiempo (Avro/Protobuf + un schema registry)?
El evento que publicas hoy lo leerán consumidores que todavía no has escrito — la evolución de esquemas es la disciplina que lo hace posible sin necesitar un despliegue sincronizado.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo diseñas un esquema de eventos y manejas su evolución en el tiempo (Avro/Protobuf + un schema registry)?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Por qué esto es más difícil que versionar una API REST
En una API de solicitud/respuesta, el cliente y el servidor suelen desplegarse casi al mismo tiempo, y un cambio que rompe algo es visible de inmediato — la siguiente llamada falla. En un sistema orientado a eventos, un mensaje publicado hoy puede quedar en un topic (o reproducirse desde el principio de uno) y ser leído por un consumidor que se despliegue dentro de varios meses, escrito por un equipo que ni siquiera te reporta a ti. El productor y cada consumidor —presente y futuro— están acoplados a través del esquema, pero desacoplados en el tiempo y en el despliegue. Ese es exactamente el problema que la evolución de esquemas existe para resolver.
#Las tres piezas: formato, registro, regla de compatibilidad
Formato de serialización. Avro y Protobuf son los dos formatos que verás en sistemas reales (JSON también es común, pero no tiene aplicación nativa de esquema). Los esquemas de Avro típicamente se declaran en JSON y viajan con un ID de schema registry en lugar de ir incrustados completos en cada mensaje, lo cual mantiene los mensajes compactos. Los esquemas de Protobuf se declaran en archivos .proto y se compilan a bindings del lenguaje; los campos se identifican por número, no por nombre, que es el origen de la mayoría de sus reglas de evolución.
Schema registry. Un schema registry (Confluent Schema Registry, AWS Glue Schema Registry, Apicurio) es un almacén versionado contra el cual el productor registra un esquema antes de publicar. El registro asigna un ID de esquema, el productor incrusta solo ese pequeño ID en el header/payload del mensaje, y los consumidores obtienen el esquema completo correspondiente por ID bajo demanda (y lo cachean). Es clave que el registro aplica una validación de compatibilidad al registrar: una nueva versión de esquema se rechaza de plano si viola el modo de compatibilidad configurado para ese subject, lo que impide que un cambio incompatible llegue siquiera al topic.
Regla de compatibilidad. Es el modo que configuras por subject (básicamente, por topic):
- BACKWARD — un consumidor que usa el esquema nuevo puede leer datos escritos con el esquema anterior. Esto es lo que te permite actualizar consumidores antes que productores.
- FORWARD — un consumidor que usa el esquema anterior puede leer datos escritos con el esquema nuevo. Esto es lo que te permite actualizar productores antes que consumidores.
- FULL — ambas se cumplen. Es el valor por defecto más seguro para topics con muchos equipos consumidores independientes, porque hace irrelevante el orden de actualización entre productor y consumidor.
#Un esquema Avro real, v1
{"type": "record","name": "OrderPlaced","namespace": "com.example.orders.v1","fields": [{ "name": "orderId", "type": "string" },{ "name": "customerId", "type": "string" },{ "name": "totalAmountCents", "type": "long" },{ "name": "currency", "type": "string", "default": "USD" },{ "name": "placedAt", "type": { "type": "long", "logicalType": "timestamp-millis" } },{"name": "items","type": {"type": "array","items": {"type": "record","name": "OrderItem","fields": [{ "name": "sku", "type": "string" },{ "name": "quantity", "type": "int" },{ "name": "unitPriceCents", "type": "long" }]}}}]}
#Evolucionándolo: un v2 compatible hacia atrás
Seis meses después, producto quiere agregar un descuento y un campo opcional de lealtad, sin romper la docena de consumidores que ya leen este topic:
{"type": "record","name": "OrderPlaced","namespace": "com.example.orders.v1","fields": [{ "name": "orderId", "type": "string" },{ "name": "customerId", "type": "string" },{ "name": "totalAmountCents", "type": "long" },{ "name": "currency", "type": "string", "default": "USD" },{ "name": "placedAt", "type": { "type": "long", "logicalType": "timestamp-millis" } },{ "name": "discountCents", "type": "long", "default": 0 },{ "name": "loyaltyPointsEarned", "type": ["null", "int"], "default": null },{"name": "items","type": {"type": "array","items": {"type": "record","name": "OrderItem","fields": [{ "name": "sku", "type": "string" },{ "name": "quantity", "type": "int" },{ "name": "unitPriceCents", "type": "long" },{ "name": "discountCents", "type": "long", "default": 0 }]}}}]}
Ambos campos nuevos son aditivos y traen un default, así que el cambio es compatible en ambas direcciones: un consumidor antiguo que lee un mensaje v2 simplemente nunca ve discountCents/loyaltyPointsEarned (los campos desconocidos se ignoran); un consumidor nuevo que lee un mensaje v1 antiguo recibe el valor por defecto de ambos. Esa combinación —campo nuevo, con default— es lo que hace que un cambio sea compatible FULL y no solo en una dirección.
#Qué lo rompe
#La versión en Protobuf de la misma disciplina
Protobuf aplica sus reglas de evolución a través de números de campo en lugar de nombres:
syntax = "proto3";package orders.v1;message OrderPlaced {string order_id = 1;string customer_id = 2;int64 total_amount_cents = 3;string currency = 4;int64 placed_at_epoch_millis = 5;repeated OrderItem items = 6;int64 discount_cents = 7; // agregado en v2, el default 0 es implícito en proto3optional int32 loyalty_points_earned = 8; // opcional explícito, distingue "0" de "ausente"reserved 9, 10; // números retirados de un campo que borramos — nunca se reutilizan}message OrderItem {string sku = 1;int32 quantity = 2;int64 unit_price_cents = 3;}
Las reglas son un espejo de las de Avro: agregar un nuevo número de campo es seguro (los binarios antiguos ignoran los números de campo desconocidos en el wire); nunca reutilices un número de campo retirado para otra cosa, que es justo para lo que existe reserved — un ingeniero futuro que agregue el campo 9 para algo no relacionado, de otra forma, corrompería silenciosamente cualquier mensaje leído por un binario antiguo que aún espera el significado original del campo 9. Los valores por defecto implícitos de cero en proto3 también son una trampa: no puedes distinguir "el cliente envió 0" de "el cliente no puso este campo" a menos que uses optional (que agrega un bit de presencia) o un tipo wrapper.
#Disciplina práctica de evolución
- Trata los esquemas como un contrato de API con su propio proceso de revisión y su propio repositorio/chequeo de CI — un cambio de esquema fusionado debería correr validación de compatibilidad contra el registro antes de poder desplegarse.
- Usa por defecto el modo de compatibilidad
FULLpara cualquier topic consumido por más de un equipo; elimina la necesidad de coordinar el orden de despliegue entre productor y consumidor. - Nunca borres un campo en el momento en que dejas de usarlo — deprécalo (deja de poblarlo, mantenlo en el esquema con su default) durante al menos un ciclo completo de release de los consumidores antes de eliminarlo.
- Versiona el namespace/subject, no necesariamente el nombre del esquema, cuando un cambio de verdad no puede hacerse compatible (un rediseño genuinamente disruptivo) — publica
OrderPlacedV2en un topic o subject nuevo y haz doble escritura durante la migración en lugar de forzar a todos los consumidores a actualizarse de forma atómica.