I’ve spent years building event-driven systems, and one lesson keeps coming back: data contracts between services matter as much as the code itself. A single field rename in a JSON payload can silently break a downstream consumer—weeks later, when someone finally notices missing data. That frustration led me to Apache Avro. Combined with a Schema Registry, it gives me type safety, schema evolution rules, and a shared contract that both producers and consumers enforce at compile time and runtime.
Have you ever pushed a change to a Kafka topic and watched your downstream consumer start throwing NullPointerException? That’s the exact problem Avro solves. Instead of fragile JSON, you define a schema once, generate Java classes, and let the framework handle serialization. The Schema Registry stores every version, so consumers can read messages written with an older version—or reject them if the change breaks compatibility.
Let’s build it. I’ll start with a Spring Boot project. Add the typical spring-kafka and spring-boot-starter dependencies. Then pull in kafka-avro-serializer from Confluent, Apache Avro itself, and the Maven plugin that generates code from .avsc files. Here’s how my pom.xml looks:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>io.confluent</groupId>
<artifactId>kafka-avro-serializer</artifactId>
<version>7.6.0</version>
</dependency>
<dependency>
<groupId>org.apache.avro</groupId>
<artifactId>avro</artifactId>
<version>1.11.3</version>
</dependency>
The Confluent repository must be added separately. The Avro Maven plugin goes in the build section:
<plugin>
<groupId>org.apache.avro</groupId>
<artifactId>avro-maven-plugin</artifactId>
<version>1.11.3</version>
<executions>
<execution>
<phase>generate-sources</phase>
<goals><goal>schema</goal></goals>
<configuration>
<sourceDirectory>${project.basedir}/src/main/avro</sourceDirectory>
<outputDirectory>${project.build.directory}/generated-sources/avro</outputDirectory>
<stringType>String</stringType>
</configuration>
</execution>
</executions>
</plugin>
Notice the stringType property set to String. Without it, Avro generates CharSequence for string fields, which causes subtle bugs when you try to call .equals() or .length(). I learned that the hard way.
Now I create a schema file in src/main/avro/UserCreatedEvent.avsc. It defines a record with fields like userId, email, firstName, and a timestamp. Enums are supported natively—here I add a role field with values ADMIN, EDITOR, VIEWER:
{
"namespace": "com.baeldung.events",
"type": "record",
"name": "UserCreatedEvent",
"fields": [
{ "name": "userId", "type": "string" },
{ "name": "email", "type": "string" },
{ "name": "firstName", "type": "string" },
{ "name": "lastName", "type": "string" },
{
"name": "createdAt",
"type": { "type": "long", "logicalType": "timestamp-millis" }
},
{
"name": "role",
"type": { "type": "enum", "name": "UserRole", "symbols": ["ADMIN", "EDITOR", "VIEWER"] },
"default": "VIEWER"
}
]
}
Run mvn generate-sources and Avro creates a Java class UserCreatedEvent inside com.baeldung.events. It’s a plain old object with builders, getters, and a toByteBuffer() method. I can now use it as a type-safe message.
How do you send such an Avro message via Kafka? I configure a KafkaTemplate with a custom serializer. Spring Kafka allows you to plug in KafkaAvroSerializer for the value and StringSerializer for the key. Here’s the configuration:
@Bean
public ProducerFactory<String, UserCreatedEvent> producerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, KafkaAvroSerializer.class);
props.put(KafkaAvroSerializerConfig.SCHEMA_REGISTRY_URL_CONFIG, "http://localhost:8081");
return new DefaultKafkaProducerFactory<>(props);
}
@Bean
public KafkaTemplate<String, UserCreatedEvent> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
On the consumer side, I use KafkaAvroDeserializer with the schema registry URL, and I specify the specific Avro type to avoid generic GenericRecord:
@Bean
public ConsumerFactory<String, UserCreatedEvent> consumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "user-events-group");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, KafkaAvroDeserializer.class);
props.put(KafkaAvroDeserializerConfig.SCHEMA_REGISTRY_URL_CONFIG, "http://localhost:8081");
props.put(KafkaAvroDeserializerConfig.SPECIFIC_AVRO_READER_CONFIG, true);
return new DefaultKafkaConsumerFactory<>(props);
}
Now I can write a producer service:
@Service
public class UserEventProducer {
private final KafkaTemplate<String, UserCreatedEvent> kafkaTemplate;
public UserEventProducer(KafkaTemplate<String, UserCreatedEvent> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void sendUserCreated(UserCreatedEvent event) {
kafkaTemplate.send("user-created", event.getUserId().toString(), event);
}
}
And a consumer:
@KafkaListener(topics = "user-created")
public void handleUserCreated(UserCreatedEvent event) {
System.out.println("Received new user: " + event.getEmail());
// process the event
}
That works locally if you have Kafka and Schema Registry running. But for tests I prefer using Testcontainers to spin up ephemeral instances. Here’s a quick integration test:
@SpringBootTest
@Testcontainers
class UserEventProducerTest {
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@Container
static SchemaRegistryContainer schemaRegistry = new SchemaRegistryContainer("7.6.0")
.withKafka(kafka);
@Autowired
private UserEventProducer producer;
@Autowired
private KafkaTemplate<String, UserCreatedEvent> template;
@Test
void shouldSendAndReceiveAvroEvent() throws Exception {
UserCreatedEvent event = UserCreatedEvent.newBuilder()
.setUserId("123")
.setEmail("test@example.com")
.setFirstName("Alice")
.setLastName("Smith")
.setCreatedAt(System.currentTimeMillis())
.setRole(UserRole.VIEWER)
.build();
producer.sendUserCreated(event);
// Use a consumer to verify receipt
// ...
}
}
The real power of Avro shines when you need to evolve the schema. Suppose I want to add a phoneNumber field. In JSON, that would break every consumer. In Avro, I can add it with a default value, mark it as optional, or use a union type. The Schema Registry enforces compatibility. I define an evolution policy—for instance, backward compatibility means new consumers can read old messages. If I add a field with a default, it’s backward compatible. If I remove a field, it’s forward compatible.
But you must be careful. If you change a field’s type, that’s usually incompatible. The Schema Registry will reject the new schema unless you use a union with a default. I once tried to change a string field to int—the registry immediately failed my producer. That saved me from a production meltdown.
How do you choose the right compatibility mode? I usually start with BACKWARD for most systems. That means all existing consumers can read the new schema. If I’m adding a new consumer that only cares about the latest fields, I might switch to FORWARD or FULL. The registry lets you set the policy per subject.
Testing schema evolution is straightforward with Testcontainers. I write a test that registers schema version 1, sends an event, then registers schema version 2 with a new field, and verifies the consumer still works. It’s the only way to be confident about changes.
Let me share a personal touch: I once worked on a microservice that emitted a PaymentProcessedEvent. The schema had a field currencyCode as a string. After six months, we needed to change it to an enum of supported currencies. Because we had Avro and a schema registry, we introduced a new union field currency that accepted either a string or an enum. Consumers that had already been updated used the enum; old consumers ignored it. The transition was smooth.
Now, every time I start a new event-driven project, I default to Avro and the Schema Registry. It adds a little complexity upfront, but the safety net is worth it. You avoid silent data corruption, you get automatic code generation, and you can enforce contracts across teams.
Before you go, think about this: Are you still sending raw JSON over Kafka? If yes, what happens when a team renames a field tomorrow? If that thought makes you uneasy, it’s time to adopt schema validation.
I hope this article helps you move toward safer event-driven development. If you found it useful, please like, share, and comment below. I’d love to hear how you handle data contracts in your own projects.
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva