Build a Schema Registry for Event-Driven Systems
distributed-systems data-engineering api-design
System Design Deep Dive
Schema Registry for Event-Driven Systems
Preventing breaking schema changes from silently corrupting thousands of downstream consumers - without slowing down producer teams.
Think of a Kafka topic as a shared language between services. The Order Service speaks it when it emits OrderPlaced events; the Analytics Service, Search Indexer, and Audit Logger all listen and parse those same messages. When the language is informal - just JSON with no enforced structure - the system works fine until a developer on the Order team renames customerId to customer_id in a Monday morning commit. By Tuesday, the Analytics pipeline is silently dropping every order because its deserialization code can no longer find the field it expects. The bug is discovered three days later in a quarterly report.
This is the schema contract problem: in an event-driven system, producers and consumers are decoupled in time but tightly coupled in data format. Without a mechanism to enforce that format contracts are honored across releases, any producer can unilaterally break every consumer - and the failure is often invisible until significant data loss has already occurred. The naive solution is team-wide convention and code review, which scales to about 10 engineers and falls apart completely at 100.
A schema registry is the infrastructure solution. It acts as a centralized catalog of every event schema in the system, enforces compatibility rules before a new schema version can be published, and provides consumers with a reliable way to look up the schema for any message they receive. It transforms the schema contract from an informal gentlemen’s agreement into a machine-enforced invariant.
The challenge is that enforcement cannot be so strict that it blocks legitimate producer evolution. A producer team that needs to add a new field to support a new feature should not require a multi-team migration ceremony. The registry must support backward and forward compatibility - the ability for new producers and old consumers (or old producers and new consumers) to coexist on the same topic simultaneously. Getting these compatibility rules right, implementing an efficient wire format, and scaling the registry itself to handle millions of schema lookups per second across multiple regions - these are the hard problems this post addresses.
We need to solve for: enforcing compatibility without blocking legitimate evolution, efficient per-message schema resolution at high throughput, versioning semantics that let producers and consumers evolve independently, and an operational model that does not make the registry a single point of failure for the entire event pipeline.
Requirements and Constraints
Functional Requirements:
- Register new schemas and new schema versions via a REST API, enforcing configured compatibility mode per subject
- Look up a schema by its integer ID in under 5ms (p99) for Kafka producer/consumer hot path
- Support Avro and Protobuf schema formats with full field-level compatibility checking
- Enforce per-subject compatibility modes:
BACKWARD,FORWARD,FULL,NONE - Support the concept of a “subject” mapped to Kafka topic names (topic-level and record-level subjects)
- Provide a compatibility check endpoint so CI/CD pipelines can fail fast before deploying a new producer
- Store full schema version history with the ability to query any historical version by ID or version number
- Support soft-deletes of schema versions without breaking consumers that hold references to those IDs
Non-Functional Requirements:
- Schema lookup latency: p50 under 1ms (cache hit), p99 under 5ms (cache miss, DB fetch)
- Schema registration throughput: 100 new schema versions per second peak (schema changes are infrequent in practice)
- Schema ID lookup throughput: 500,000 requests per second across the fleet (producers and consumers combined)
- Availability: 99.99% uptime - the registry being down must not stop producers from producing if they have cached schema IDs
- Data durability: zero schema loss - a registered schema must survive any single-node or region failure
- Support multi-region active-passive with cross-region replication lag under 1 second
Constraints and Assumptions:
- Schema IDs are immutable once assigned - deleting a schema ID is a soft delete, not a physical delete
- Compatibility checking applies only to schema evolution within a subject, not across subjects
- The registry does not validate individual messages against schemas - that happens in producer/consumer libraries
- Initial scale: 200 subjects, 5,000 total schema versions, 50 Kafka topics
- Growth: 2 new schema versions per week per team, 30 teams
High-Level Architecture
The system has five major components arranged in two planes: the control plane (schema registration and compatibility checking) and the data plane (schema lookups at message production and consumption time).
The Schema Registry REST API is the entry point for both producers (registering schemas) and consumers (looking up schemas by ID). It fronts all reads and writes and enforces compatibility rules synchronously - a registration request that violates the configured compatibility mode receives a 409 Conflict before the schema is persisted.
The Schema Store is the durable backing store for all schema versions. We use the _schemas Kafka topic as the primary store (enabling log-compacted replication across regions) with a relational database for fast indexed lookups. Every schema is assigned a globally unique monotonically increasing integer ID.
The Compatibility Engine is the heart of the registry. When a new schema version is submitted, it loads the current version from the store, runs the configured compatibility algorithm against the pair, and returns pass or fail. The algorithm varies by schema format (Avro uses field-level union analysis; Protobuf uses field number and type rules).
Producer-side Serializers (the KafkaAvroSerializer or equivalent) integrate with the registry on the write path. Before encoding a message, the serializer looks up the schema ID from its local cache. On a cache miss, it calls the registry POST /subjects/{subject}/versions to register or retrieve the ID, then caches it locally for the process lifetime.
Consumer-side Deserializers integrate on the read path. Each message has a 5-byte prefix containing the magic byte (0x00) and a 4-byte schema ID. The deserializer extracts the ID, looks it up in its local cache, calls the registry on a miss, then decodes the message using the retrieved Avro or Protobuf schema.
The schema ID in the message wire format is what enables consumers to evolve independently. A consumer running schema v1 receives a message encoded with schema v2 - it reads the 4-byte ID, fetches v2 from the registry, and can use Avro's schema projection to read v2 data using v1's reader schema, ignoring any new fields it does not understand. Without the registry, there is no way to know which schema was used to write a given message.
The Schema Store and Versioning Model
The schema store is responsible for assigning and persisting schema IDs, storing schema text, and mapping subject names to ordered version sequences. Think of a subject as a named history of one schema’s evolution - like a Git repository for a single type definition.
The wire protocol uses a compact binary prefix to avoid embedding full schemas in every message. Compare the overhead: a typical Avro schema definition is 500-2000 bytes. A message payload averages 200-500 bytes. Embedding the schema in every message would double or triple the data volume in Kafka. The 5-byte prefix approach amortizes schema overhead to essentially zero.
-- Schema Registry data model
-- Global schema ID -> schema text mapping (immutable once created)
CREATE TABLE schemas (
id SERIAL PRIMARY KEY,
schema_type VARCHAR(20) NOT NULL CHECK (schema_type IN ('AVRO', 'PROTOBUF', 'JSON')),
schema_text TEXT NOT NULL,
schema_hash VARCHAR(64) NOT NULL, -- SHA-256 of canonical schema text
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (schema_hash) -- dedup identical schemas across subjects
);
-- Subject = a named, versioned sequence of schema IDs
CREATE TABLE subjects (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE, -- e.g., "orders-value", "orders-key"
compatibility_mode VARCHAR(20) NOT NULL DEFAULT 'BACKWARD'
CHECK (compatibility_mode IN ('BACKWARD','FORWARD','FULL','NONE',
'BACKWARD_TRANSITIVE','FORWARD_TRANSITIVE','FULL_TRANSITIVE')),
deleted_at TIMESTAMPTZ -- soft delete
);
-- Mapping: subject + version -> schema ID
CREATE TABLE subject_versions (
subject_id INTEGER NOT NULL REFERENCES subjects(id),
version_number INTEGER NOT NULL,
schema_id INTEGER NOT NULL REFERENCES schemas(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ, -- soft delete a version
PRIMARY KEY (subject_id, version_number)
);
CREATE INDEX idx_subject_versions_schema ON subject_versions(schema_id);
CREATE INDEX idx_subject_versions_latest ON subject_versions(subject_id, version_number DESC)
WHERE deleted_at IS NULL;
The schema_hash column is the deduplication key. If two different subjects happen to evolve to the exact same schema definition, they share one row in schemas and thus one ID. This is intentional - it means two topics that happen to use the same event type get the same schema ID, and consumers looking up the ID get the correct schema regardless of which topic it came from.
Premium Content
Unlock the full article along with everything else in the archive — all in one place.