1. ¿Qué es la serialización (y por qué importa)?

Cuando dos servicios se comunican a través de Kafka, se envían bytes, no objetos. La serialización es el proceso de convertir un objeto en memoria a bytes para transmitirlo, y la deserialización es el proceso inverso: convertir esos bytes de vuelta a un objeto.

El enfoque más común es JSON: legible, universal, fácil de depurar. Pero tiene un problema: es verbose y no tiene un schema estricto impuesto a nivel de protocolo.

Mismo pedido de e-commerce — JSON vs Avro JSON — 187 bytes {"orderId": "ORD-001", "product": "Laptop", "quantity": 1, "price": 999.99, "status": "PENDING", "ts": "2026-01-15T..."} 187 B vs Avro (binario) — 41 bytes 0x 4F 52 44 2D 30 30 31 → orderId 0x 4C 61 70 74 6F 70 → product 0x 02 → quantity 0x 66 66 7A 44 → price 0x 00 → status 0x ... 41 B (~78% smaller)

El mismo objeto de pedido — JSON ocupa 187 bytes, Avro binario solo 41 bytes (≈78% de reducción).

Apache Avro resuelve ambos problemas: serializa datos en formato binario compacto Y define un schema estricto que tanto el productor como el consumidor deben respetar.

2. ¿Qué es Apache Avro?

Apache Avro es un framework de serialización de datos creado por la Apache Software Foundation. Nació en el ecosistema de Hadoop pero se popularizó enormemente con Kafka por dos características clave:

Ejemplo de Schema Avro

Un schema Avro es un archivo JSON que describe los campos, sus tipos y opcionalmente sus valores por defecto:

order.avsc
{
  "type": "record",
  "name": "Order",
  "namespace": "com.faraujop.ecommerce",
  "fields": [
    { "name": "orderId",  "type": "string" },
    { "name": "product",  "type": "string" },
    { "name": "quantity", "type": "int" },
    { "name": "price",    "type": "double" },
    { "name": "status",   "type": "string" }
  ]
}
Idea Clave

El schema es el contrato entre productor y consumidor. Si el productor envía un campo que no existe en el schema, o omite uno requerido, el serializador lanza un error de inmediato, antes de que el mensaje llegue a Kafka.

3. Avro vs JSON vs Protobuf

Existen varios formatos de serialización. Aquí una comparación práctica:

Característica JSON Apache Avro Protobuf
Formato Texto (legible) Binario (compacto) Binario (compacto)
Schema ✗ Optional ✓ Required ✓ Required (.proto)
Tamaño ✗ Large ✓ Very small ✓ Very small
Evolución de schema ✗ Manual ✓ Native △ Partial
Ecosistema Kafka △ Common ✓ Native (Confluent) △ Supported
Legibilidad humana ✓ Yes ✗ No ✗ No

Avro es el estándar de facto para Kafka, especialmente cuando se usa Confluent Platform, porque todo el ecosistema está diseñado a su alrededor (Schema Registry, connectors, KSQL).

4. El Schema Registry: Control Centralizado

Aquí hay un problema: si el schema Avro vive solo en el código del productor, ¿cómo sabe el consumidor cómo deserializar el mensaje? También necesita el schema.

La solución es el Schema Registry: un servicio centralizado (típicamente Confluent Schema Registry) que almacena todos los schemas y les asigna un ID numérico único.

Flujo completo: Avro + Schema Registry + Kafka Schema Registry Almacena schemas + IDs { orderId:string, ... } → ID:42 Producer Serializa objeto con schema ID:42 Kafka Topic: orders [ID:42 | bytes...] Consumer Lee ID:42, pide schema, deserializa ① Registra schema ② Publica ③ Consume ④ Pide schema ID:42 El Schema Registry cachea los schemas localmente tras la primera consulta — el overhead de red es mínimo. The Schema Registry caches schemas locally after the first request — network overhead is minimal.

El Schema Registry se consulta una vez por ID de schema, luego se cachea localmente. Kafka solo almacena bytes, no el schema.

¿Cómo se ve un mensaje dentro de Kafka?

Cada mensaje Avro con Schema Registry tiene un prefijo mágico de 5 bytes: 1 byte de número mágico (0x00) + 4 bytes con el ID del schema. El consumidor lee esos 4 bytes, obtiene el schema del registry y deserializa el resto.

// Estructura de un mensaje Avro en Kafka
[ 0x00 ]  // magic byte
[ 0x00 0x00 0x00 0x2A ]  // schema ID = 42 (4 bytes, big-endian)
[ ... bytes de datos Avro ... ]

5. Avro en .NET: Ejemplo Práctico

Paquetes NuGet necesarios

terminal
dotnet add package Confluent.Kafka
dotnet add package Confluent.SchemaRegistry
dotnet add package Confluent.SchemaRegistry.Serdes.Avro
dotnet add package Apache.Avro

Definición del Schema (order.avsc)

Primero crea el archivo de schema. En .NET se suele usar GenericRecord (dinámico, sin generación de código) o generar clases C# a partir del schema con las herramientas de Avro.

order.avsc
{
  "type": "record",
  "name": "Order",
  "namespace": "com.faraujop.ecommerce",
  "fields": [
    { "name": "orderId",  "type": "string" },
    { "name": "product",  "type": "string" },
    { "name": "quantity", "type": "int"    },
    { "name": "price",    "type": "double" },
    { "name": "status",   "type": "string" }
  ]
}

Producer con Avro

OrderProducer.cs
using Confluent.Kafka;
using Confluent.SchemaRegistry;
using Confluent.SchemaRegistry.Serdes;
using Avro;
using Avro.Generic;

var schemaRegistryConfig = new SchemaRegistryConfig
{
    Url = "http://localhost:8081"
};

var producerConfig = new ProducerConfig
{
    BootstrapServers = "localhost:9092"
};

// Leemos el schema desde el archivo .avsc
var schemaJson = await File.ReadAllTextAsync("order.avsc");
var schema = (RecordSchema)Schema.Parse(schemaJson);

using var schemaRegistry = new CachedSchemaRegistryClient(schemaRegistryConfig);
using var producer = new ProducerBuilder<string, GenericRecord>(producerConfig)
    .SetValueSerializer(new AvroSerializer<GenericRecord>(schemaRegistry))
    .Build();

// Creamos el mensaje con GenericRecord
var order = new GenericRecord(schema);
order.Add("orderId",  "ORD-001");
order.Add("product",  "Laptop");
order.Add("quantity", 1);
order.Add("price",    999.99);
order.Add("status",   "PENDING");

await producer.ProduceAsync("orders", new Message<string, GenericRecord>
{
    Key   = order["orderId"].ToString()!,
    Value = order
});

Console.WriteLine("✅ Pedido enviado con Avro");

Consumer con Avro

OrderConsumer.cs
using Confluent.Kafka;
using Confluent.SchemaRegistry;
using Confluent.SchemaRegistry.Serdes;
using Avro.Generic;

var schemaRegistryConfig = new SchemaRegistryConfig
{
    Url = "http://localhost:8081"
};

var consumerConfig = new ConsumerConfig
{
    BootstrapServers = "localhost:9092",
    GroupId          = "inventory-service",
    AutoOffsetReset  = AutoOffsetReset.Earliest
};

using var schemaRegistry = new CachedSchemaRegistryClient(schemaRegistryConfig);
using var consumer = new ConsumerBuilder<string, GenericRecord>(consumerConfig)
    .SetValueDeserializer(await new AvroDeserializer<GenericRecord>(schemaRegistry)
        .AsSyncOverAsync())
    .Build();

consumer.Subscribe("orders");

while (true)
{
    var result = consumer.Consume();
    var order  = result.Message.Value;

    Console.WriteLine($"Pedido: {order["orderId"]} | {order["product"]} x{order["quantity"]} | {order["status"]}");
}
Tip

Para mayor seguridad de tipos, puedes usar las herramientas de generación de código de Avro para crear clases C# fuertemente tipadas a partir de tus archivos .avsc, en lugar del enfoque dinámico con GenericRecord.

6. Evolución de Schemas: La Killer Feature

Aquí es donde Avro brilla de verdad. En un sistema real, los schemas cambian con el tiempo. Se añade un campo, se renombra, se elimina. Con JSON, hay que coordinar manualmente todos los servicios — una pesadilla en microservicios.

Avro soporta evolución de schemas con tres modos de compatibilidad:

Evolución de Schema — V1 → V2 con campo opcional nuevo Schema V1 (original) "orderId" : string "product" : string "quantity" : int "price" : double "status" : string añadir campo Schema V2 (nuevo) "orderId" : string "product" : string "quantity" : int "price" : double "status" : string "discount" : double = 0.0 ✨ ⚡ El consumer con V1 recibe mensajes V2 y simplemente ignora "discount" — sin errores.

Los campos opcionales nuevos con valores por defecto son retrocompatibles: los consumidores antiguos ignoran campos desconocidos sin errores.

Cómo añadir un campo de forma segura

order-v2.avsc — Campo nuevo con valor por defecto
{
  "type": "record",
  "name": "Order",
  "namespace": "com.faraujop.ecommerce",
  "fields": [
    { "name": "orderId",  "type": "string" },
    { "name": "product",  "type": "string" },
    { "name": "quantity", "type": "int" },
    { "name": "price",    "type": "double" },
    { "name": "status",   "type": "string" },
    {
      "name": "discount",
      "type": ["null", "double"],  // nullable
      "default": null             // valor por defecto → retrocompatible
    }
  ]
}

Modos de compatibilidad en el Schema Registry

Modo Significado Usar cuando
BACKWARD Nuevo consumer lee mensajes viejos Actualizas consumers primero
FORWARD Viejo consumer lee mensajes nuevos Actualizas producers primero
FULL Ambas direcciones Necesitas máxima flexibilidad
Atención

Renombrar o eliminar campos requeridos (sin default) rompe la compatibilidad. Siempre añade campos nuevos con un valor default, y depreca los campos gradualmente en lugar de eliminarlos de inmediato.

7. ¿Cuándo usar Avro?

Avro es una excelente opción cuando tienes:

Es posible que no necesites Avro si: estás haciendo un prototipo, tienes un solo consumidor, o tus mensajes ya son pequeños.

Puntos Clave

Felipe Araujo
Felipe Araujo Pacheco
Tech Lead con 20+ años construyendo sistemas distribuidos y arquitecturas orientadas a eventos. Apasionado del código limpio, Kafka y hacer lo complejo comprensible.
← Volver al Blog