Designing Topics and Event Contracts
In plain terms
A checkout team changes an amount field while the warehouse still expects the old representation. Whether that release is safe depends on the contract shared by the two applications. A topic is a long-lived interface: its name, key, schema, meaning, retention, and owner tell consumers what they can depend on. The log and its delivery mechanisms provide the setting in which these dependencies must hold.
The event design article wrote the tracking plan, the human agreement about what an event means. This article is the machine half: how that agreement becomes a schema the broker’s clients enforce, how topics are cut and keyed so that the guarantees from the Kafka article hold, and how a change is made without a coordinated deploy of every consumer.
Where one topic ends and the next begins
Topic boundaries balance isolation and shared consumption. Putting unrelated events together couples topic-level permissions, retention, throughput, and recovery. Splitting every event type can increase metadata, consumer filtering or joining, and operational work, but may be appropriate for independent lifecycles. Compare owner, entity key, sensitivity, retention, consumers, and failure isolation. Differences are reasons to consider a separate topic, not a universal rule. A topic can carry several event types if their schema strategy and processing semantics are explicit.
The naming convention encodes the boundary, so that a topic’s name tells a reader what it holds and who to ask: owner.entity with a suffix for the sensitivity class, such as orders.order, growth.user, identity.user.sensitive, or, for change streams from a database, cdc.orders. In this convention, a version suffix (.v2) marks a migration to a new topic; Kafka itself does not require this naming rule. Ordering is the reason entity is in the rule: everything about one order that must be read in sequence has to share a topic and a key, because the Kafka article’s ordering guarantee stops at the partition boundary.
The key and the partition count
Choose a partition key for records whose partition append order must stay together. Order IDs preserve order-level history; customer IDs group a customerโs orders but may create hot partitions. Neither reconstructs event-time order across concurrent producers. A stable salt derived from order ID can spread one customer while keeping each order together, but it loses customer-wide order. Changing from anonymous ID to user ID at login can also move a journey across partitions; document the identity mapping and how consumers reconcile the two histories.
Estimate partition count from peak bytes and records per second, measured per-partition throughput, and the slowest required consumer path. Add headroom, then test skew and failure recovery. Partitions consume replica storage, metadata, open files, memory, and recovery work even when traffic is low; creating ten times as many in anticipation of growth is not free. The count need not divide every group size. Increasing it can remap keys, so plan ordering and migration rather than treating it as a routine scale knob.
A stable business ID is not enough if its encoded key changes. Key-based partitioners use serialized key bytes, and compaction compares keys within each partition. Encoding order O17 differently can move new records to another partition or leave two byte-level keys for the same order. A tombstone under the new key will not delete the old key. Test key bytes as well as value-schema compatibility; when changing a key serializer or partition count, define how old state is retired and how order is preserved across the transition.
Retention, compaction, and what kind of thing a topic holds
An event records an occurrence; a change record describes a state transition; a state record supplies the current value for a key. Before/after images depend on the CDC source and configuration. Preserve history with delete retention when replay needs every event. For a state topic, compaction can remove superseded values; it is asynchronous, and combining it with delete retention can also remove old state. A Kafka null value is a tombstone, not a JSON object containing a null field. After delete markers expire, a stale state store may miss deletions. The contract must say whether to append, merge, or delete, and how to recover after missing the required history.
The record on the wire: format and registry
The broker stores bytes; clients interpret the application schema. JSON is readable, but field names alone do not define units, required fields, or business meaning. Avro resolves a writer schema against a reader schema. Protobuf identifies fields by number in its binary format and supports generated APIs. Encoded size depends on the actual data and compression. Protobuf binary-compatible renames can still break generated code or JSON consumers; reserve removed field numbers and names. Choose a format with the readers and migration path in mind.
A schema registry stores schema versions under a subject and checks the configured compatibility policy. Confluentโs TopicNameStrategy commonly uses topic-value; RecordNameStrategy and TopicRecordNameStrategy offer different compatibility scopes for multiple record types. The classic Confluent payload prefix is a version byte plus a four-byte schema ID. Avro data follows that prefix; Protobuf also includes message indexes. Other header modes and registries differ. The ID identifies the writer schema; it does not make every reader compatible. Clients need schema resolution, compatible versions, and access to retained schema definitions. Distinguish a corrupt or unknown ID from a temporary registry outage before quarantining a record.
A subject version is a position in one subjectโs history; a schema ID identifies a registered schema within its registry scope. Version 2 is not necessarily schema ID 2, and an identical schema can be associated with several subjects. A valid ID alone does not prove that a record belongs on this topic. Enforce the intended subject and producer permissions as well as payload validation.
Compatibility: which changes are safe
Backward means a new reader can read old data; upgrade readers first when relying only on that direction. Forward means an old reader can read new data; it does not promise that a new reader can replay old history. Full checks both directions. Non-transitive policies compare against the latest version; transitive policies compare against all previous registered versions. Select a policy for the versions that coexist and the history that must be replayed. In Avro, a reader default fills a field absent from the writer schema; it does not make that field optional when writing. Null requires a type that permits null, such as ["null", "string"]. The table assumes the same record name and otherwise unchanged fields:
| Change | Backward | Forward | Full | Why |
|---|---|---|---|---|
| Add a field with a default | ok | ok | ok | new readers fill the default for old data; old readers ignore the new field |
| Add a field without a default | breaks | ok | breaks | a new reader needs it and old data never wrote it |
| Remove a field that had a default | ok | ok | ok | old readers fill the default when it is absent |
| Remove a field without a default | ok | breaks | breaks | an old reader needs it and new data never writes it |
| Rename a required field without aliases/defaults | breaks | breaks | breaks | a rename is a remove and an add, and both sides lose a required field |
| Widen a type (int to long, long to double) | ok | breaks | breaks | Avro permits this promotion only in the listed direction; floating-point precision can change |
| Change long to string | breaks | breaks | breaks | neither direction resolves this pair |
| Change meaning with the same schema | may pass | may pass | may pass | schema compatibility does not verify business semantics |
A required rename without aliases breaks both directions; defaults and reader aliases can change that result. Numeric promotion is structural compatibility, not a guarantee of precision: long-to-double can round large integers. A unit change with the same schema may pass registry checks while breaking business meaning, so review units, time semantics, IDs, examples, and consumer expectations separately. Protobuf and JSON Schema have their own rules; do not apply the Avro table to them.
For example, v1 has a string field with a default, v2 removes it, and v3 adds an integer field under the same name with an integer default. Each step can pass BACKWARD against only the latest schema, but v3 cannot read v1โs string as an integer. BACKWARD_TRANSITIVE checks that older pair too. Keep schemas needed by retained records; keeping topic bytes without their schema definitions is insufficient for replay.
Making a breaking change anyway
A structural break may be rejected by the registry; a dollars-to-cents change with the same type may pass. For an incompatible contract, create a versioned topic or an explicitly versioned envelope with compatible readers. Define a cutover boundary, stable event IDs, and which version each consumer processes around that boundary. If dual-writing, use a durable source/outbox or suitable Kafka transactions and reconcile both outputs; two unrelated sends can leave a gap. Dual-read consumers must deduplicate the overlap. A continuously written old topic has no final end to drain until a boundary or producer stop is defined. Validate output counts and meaning, retain rollback capability, and retire only after all owners confirm cutover and the promised replay window is satisfied. An empty group list alone does not prove no one depends on the topic.
Validation and dead letters
Check compatibility at schema registration and in CI; validate records before sending and at trust boundaries. Types alone cannot prove that a numeric amount uses cents, a timestamp has the right meaning, or an ID is legitimate. Add domain checks and reconciliation. A dead-letter path needs durable writes, source identity, reason, owner, retention, and a replay procedure; commit source progress only after the required output or quarantine write succeeds. Restrict or redact originals according to their contents rather than making the dead-letter topic a public copy of sensitive data. Retry transient infrastructure failures separately from invalid records.
The operational contract
The schema is the smaller half of a topic’s contract. The rest is the promise the owner makes about how the topic behaves, and it is one page per topic, kept next to the schema and reviewed with it:
topic events.checkout owner: checkout team support: #checkout-data
kind events (immutable) key: stable actor_id; anonymous/user mapping is explicit
ordering partition append order for a stable key partitions: 24 replication: 3, min.insync 2, acks=all
schema events.checkout-value, Avro, compatibility FULL_TRANSITIVE; current v2; history in the registry
rate 60 records/s average, 900/s peak (launch days); records under 2 KB
retention 30 days total replay; local and remote retention configured consistently
sensitivity pseudonymous identifiers; restricted according to linkage risk; ACL: checkout-api writes, listed groups read
consumers raw-layer writer, fraud lane, funnel job (three groups; see the catalog)
change policy compatible changes any time with a note; breaking changes as events.checkout.v2 with 60 days dual write
quality dead-letter rate under 0.1%; oldest unprocessed event age alert at 5 minutes; incident channel #data-incidents
The rate and record-size lines support capacity tests; the replay window bounds backfills; the sensitivity line guides access review; and the consumer list identifies migration dependencies. Brokers expose configuration such as retention and partition count, but not the full business contract. Keep the document and actual settings in sync, including the key schema, compatibility policy, alerts, and owners.
This is a proposed operational contract. The actor key is serialized separately from the value, and its schema and evolution policy need their own contract. A production subjectโs policy must be centrally configured and access-controlled.
The subscription company’s topics, contracted
The subscription company in this section is a hypothetical teaching example, not a real system. The topics, keys, retention periods, and sensitivity notes below are proposed settings for illustration.
| Topic | Kind | Key | Format, compatibility | Retention | Sensitivity |
|---|---|---|---|---|---|
growth.user | events: views, cart adds | anonymous or user id | Avro, full transitive | 7 days | pseudonymous |
events.checkout (existing name retained) | events | anonymous or user id | Avro, full transitive | 30 days, tiered | pseudonymous |
orders.order | events: placed, refunded | order id | Avro, full transitive | 90 days | review order identifiers and transaction data |
cdc.orders | change records | order id | Avro, backward transitive; upgrade warehouse reader first | 14 days | review order identifiers and transaction data |
cdc.customers | state | customer id | Avro, full transitive | compacted; tombstones kept 7 days | address: restricted read |
identity.user.sensitive | events: logins | user id | Avro, full transitive | 3 days | restricted; no credentials in the record |
Anti-patterns
- The firehose topic. Everything in
events, so that anyone who may read anything may read everything. - Topic per screen. Sixty topics, each with one reader, joined at query time with no ordering across them.
- The constant key. Every record uses the same tenant key, concentrating the load in one partition while the mapping stays fixed. Adding group members does not split that partitionโs ordinary group assignment.
- Schemaless JSON on a shared topic. The producer added a field, renamed another, and the consumers found out from null columns.
- Compatibility off “for the migration”. Left off, and the next breaking change ships without anyone noticing.
- Changing meaning under the same name. Dollars became cents in schema v3; every check passed.
- Dropping invalid records. A dead-letter topic nobody created, so the collector skipped them and the totals came out 3% low.
- Mixed kinds in one topic. Events and state records interleaved, and a consumer that appended the state records as if they had happened.
References
Checked on 2026-09-09. Avro field examples follow the linked 1.12 specification; registry framing and subject strategies are Confluent-specific.
- Avro 1.12 specification: defaults and schema resolution
- Confluent schema evolution and transitive compatibility
- Confluent serializers, subjects, and wire formats
- Protobuf proto3 language guide
- Kafka 4.1 design: log compaction
Discover more from Insightful Data Lab
Subscribe to get the latest posts sent to your email.
