AlgoMaster Logo

Lab: Evolve an Event Contract Without Breaking Consumers

24 min readUpdated September 13, 2026
Listen to this chapter
Unlock Audio

In this lab, you will publish an OrderPlaced event as JSON from Java. You will then test how two consumer versions handle three contract changes. You will add an optional field and confirm that nothing breaks, rename a required field and watch a consumer stop, then repair the damage with a second topic and dual publication. You will also separate a CapturePayment command from the PaymentCaptured event it causes, compare a thin ProductUpdated notification with a state-carrying version, and move the record key from order to customer to see what the ordering guarantee becomes.

The order examples keep the same amounts and customer assignments while their representation changes: ord-1042 and ord-1043 belong to cust-208, and ord-1044 belongs to cust-311. Amounts use integer currency minor units. For the versioning experiment, eventId identifies one immutable representation, occurrenceId identifies the underlying order acceptance, and orderId identifies the order. Retrying a representation preserves its event ID and payload; another version gets its own event ID and shares the occurrence ID. The command and catalog phases define their own contracts below.

Learning Objectives

  • Write an event schema and a matching Java record that follow one documented key, identity, and time contract.
  • Add a field that the two sample readers tolerate, and distinguish runtime reader behavior from registry schema compatibility.
  • Produce a visible consumer failure from a renamed required field and locate it by topic, partition, and offset.
  • Migrate an incompatible representation with a new topic, dual publication, and a shared occurrence identity.
  • Reject a duplicate command by commandId and trace a command to its event through causationId.
  • Count the lookups a notification consumer performs and reproduce a stale read with two quick updates.
  • Explain how the ordering guarantee changes when the key moves from orderId to customerId.

Time and Environment

  • Setup and Phase 1: 30 minutes
  • Phase 2: 30 minutes
  • Phase 3: 45 minutes
  • Phases 4 to 6: 40 minutes each
  • Report: 45 minutes

Use the single-broker kafka-local container for the broker and the CLI tools, which run inside the container with docker exec. The Java code runs on the host with Java 21 and Maven, using kafka-clients:4.3.1, slf4j-simple:1.7.36, and jackson-databind:2.20.0 for JSON. Jackson is a separate library, not a Kafka serializer; we pin the version so the record and unknown-property behavior you observe is reproducible. There is no Schema Registry. The consumers test how these specific records behave in application code. A registry would separately compare schema definitions; these are different checks.

Project Layout

Setup

Use fresh lab topics, fresh consumer groups, and an empty observations directory. If you ran an earlier lab on kafka-local, finish that lab’s cleanup first. The expected counts assume no earlier records or committed progress. Start a new broker:

Create the three-partition order topics:

Create the single-partition payment and catalog topics:

Start from the producer project layout from the producers module. Add com.fasterxml.jackson.core:jackson-databind:2.20.0 to the dependencies and set <mainClass> to com.example.kafka.Lab. Lab reads its first argument as a subcommand and calls the matching class, so every program runs as mvn compile exec:java -Dexec.args="<subcommand> ...".

Every consumer uses StringDeserializer, enable.auto.commit=false, auto.offset.reset=earliest, and commits with commitSync(records.nextOffsets()) after processing each batch. Every producer uses StringSerializer, acks=all, and enable.idempotence=true, and prints the partition and offset from RecordMetadata for each send. Share one ObjectMapper across the project:

Phase 1: Define OrderPlaced v1

Write schemas/order-placed-v1.json as a JSON Schema document. It is the written contract; the lab runs no validator against it, so your producer must enforce the same rules in code.

Bind it to a Java record. Use Long rather than long for the total so that a missing field decodes as null instead of a silent zero:

OrderProducer publishes three v1 representations to orders.placed: evt-7001 for ord-1042 (129900 USD minor units), evt-7002 for ord-1043 (49900), and evt-7003 for ord-1044 (259900). Their occurrence IDs are placement-1042, placement-1043, and placement-1044. Use data.orderId as the record key. Before each send, validate the required fields, nonnegative total, UTC occurredAt, and matching key. Running produce-orders v1 again must reuse those event IDs, occurrence IDs, and payloads.

Save the printed partitions and offsets to observations/phase1-produce.txt, then confirm the stored keys:

What you should see: three records with partitions between 0 and 2, and keys equal to data.orderId. With the same key serialization, keyed routing settings, and partition count, a second producer run sends each order to the same partition at a higher offset. Run that second v1 publication only when Phase 2 asks for it.

Phase 2: Two Consumers and a Compatible Change

Copy the v1 schema to schemas/order-placed-v2.json and add one optional property to data: salesChannel, a string whose documented values are WEB, MOBILE, and UNKNOWN. Leave it out of required. Create OrderPlacedV2 with the extra String salesChannel field.

OldOrderConsumer joins group order-reporting, decodes into OrderPlacedV1, and prints OLD evt-7001 ord-1042 total=129900. NewOrderConsumer joins group order-fulfillment, decodes into OrderPlacedV2, maps a null channel to UNKNOWN, and prints NEW evt-7001 ord-1042 total=129900 channel=UNKNOWN. Both validate the required fields after decoding and throw if one is missing. Start old-consumer and new-consumer in two terminals:

Then mix producers. produce-orders v2 publishes the same three order acceptances as evt-7004 to evt-7006, with channels WEB, MOBILE, WEB. Preserve each order’s occurrenceId, time, amount, and other fields. These are new representations of existing facts, not new order placements. Follow this with a second produce-orders v1 run:

Save each consumer's output to its observations/phase2-*.txt file.

What you should see: both consumers process all nine records and neither stops. The old consumer prints the v2 records without a channel because Jackson discarded the unknown property; that is the forward direction, old reader with new data. The new consumer prints channel=UNKNOWN for every v1 record; that is the backward direction, and the UNKNOWN mapping is your reader-side default. These specific old and new JSON readers both handle the sample records. Do not infer a registry FULL result: compare the complete JSON Schemas under the registry’s rules. FAIL_ON_UNKNOWN_PROPERTIES=false is the JSON reader's way of ignoring an added writer field; a JSON Schema with additionalProperties: false would have rejected the same payload.

Phase 3: Break It, Then Migrate

Copy the v2 schema to schemas/order-placed-v3.json, rename totalMinorUnits to total in both properties and required, and create OrderPlacedV3. Preserve occurrenceId and the other envelope fields. produce-orders v3 uses evt-7007 to evt-7009 for the three orders. Keep both consumers running, then deliberately publish these incompatible representations to the old topic:

What you should see: the old consumer decodes the first v3 record without a parse error, because total is only an unknown property, but its validation finds totalMinorUnits equal to null and throws. The poll loop's error path prints the message and the consumer exits without committing. Record the exception text, including the orders.placed-<partition> offset <n> location and the eventId, in observations/phase3-failure.txt. Restart the old consumer: it rereads uncommitted records and fails again on an incompatible record, because rereading the same bytes with the same reader cannot repair a contract mismatch. Confirm the stuck position:

The new consumer fails the same way. With a primitive long, both would have printed total=0 and kept going, turning a visible failure into wrong data.

The incompatible v3 representation needs its own topic. First, preserve the three contract violations already in orders.placed. On validation failure, each consumer writes the topic, partition, offset, and raw value to its own file: observations/phase3-quarantine-order-reporting.txt or observations/phase3-quarantine-order-fulfillment.txt. Write at most one entry per topic/partition/offset in each file, print QUARANTINED, and continue. Commit batch progress only after every record in the batch has either completed normally or reached its quarantine file. Restart both consumers and verify that each file contains the same three failed record coordinates.

Make produce-orders dual publish each order in both representations: v2 to orders.placed using evt-7004 through evt-7006, and v3 to orders.placed.v3 using evt-7007 through evt-7009. Reuse the exact payload for each representation, including its occurrence ID. Extend new-consumer with a v3 argument that uses group order-fulfillment-v3, subscribes to orders.placed.v3, and decodes OrderPlacedV3. Print the event ID and occurrence ID in both consumers so you can compare the representations:

Run new-consumer v3 in a third terminal, then the dual producer:

What you should see: during the dual run, the old consumer prints three additional OLD lines from orders.placed, and the v3 consumer prints three lines from orders.placed.v3. Matching orders share occurrence IDs but have different event IDs across versions. The two topics have independent offsets; use occurrence IDs to match facts.

Phase 4: Commands vs Events

send-command writes a CapturePayment command to payments.commands. For cmd-9001, use key and paymentId pay-501, correlationId checkout-1042, and a data object containing orderId ord-1042, amountMinorUnits 129900, and currency USD. For cmd-9002, use pay-502 and the corresponding order data. PaymentService joins payment-execution and checks a file of handled command IDs before executing each command. A matching ID prints DUPLICATE <commandId> ignored. Otherwise, it prints CAPTURED to simulate capture, appends the command ID to observations/handled-commands.txt, publishes PaymentCaptured, waits for publication to succeed, and then commits progress. The event uses a new event ID, preserves the key and correlation ID, and sets causationId to the command ID. This deliberately incomplete implementation exposes the failure windows in Q5; it does not perform a real payment.

Start payment-service, then send cmd-9001 twice and cmd-9002 for pay-502 once:

Stop the service with Ctrl-C, start it again, and send cmd-9001 a third time. Read payments.events with the console consumer from the beginning and save everything to observations/phase4-payments.txt.

What you should see: payments.commands holds four records, but payments.events holds exactly two PaymentCaptured events, one with causationId cmd-9001 and one with cmd-9002, each carrying its correlationId. The service prints DUPLICATE twice, once before and once after the restart, because the handled set lives in a file rather than in memory. Explain in the report what happens if the process crashes between appending to the file and the publish succeeding.

Phase 5: Notification vs Event-Carried State Transfer

stub/prod-501.json stands in for the catalog API. product-producer notify <rev> <price> writes that revision and price into the stub file, then publishes a thin ProductUpdated event to catalog.product-events with key prod-501 and only productId and productRevision in data. product-producer state <rev> <price> writes the same file but publishes the full snapshot, including priceMinorUnits and currency.

ProductConsumer joins group search-updater. If an event carries no price, it reads stub/prod-501.json and counts one lookup; otherwise, it uses the carried values and counts no lookup. It prints the event revision, any fetched revision, and the lookup count. For example: NOTIFIED rev=17 FETCHED rev=18 lookups=1 or CARRIED rev=19 price=64900 lookups=0.

Leave product-consumer stopped. Publish both notifications below and wait for both producer commands to complete. The stub must contain revision 18 before the consumer starts:

Now start product-consumer and wait until it prints and commits both notification records. Both lookups read revision 18, so the revision 17 event demonstrates a stale historical lookup without relying on a sleep. Stop the consumer, publish state 19 64900 and state 20 69900, wait for both commands to complete, and restart it. Save all four output lines to observations/phase5-lookups.txt. The snapshot records carry their own values regardless of the stub’s current revision.

What you should see: with revision 18 already in the stub file before the consumer performs its lookup, the revision 17 notification prints NOTIFIED rev=17 FETCHED rev=18. The lookup returned a different revision than the one notified, even though every component worked correctly. Both snapshot events print CARRIED with their own revision and lookups=0.

Phase 6: Keys and Partitioning Strategy

keyed-orders order publishes three lifecycle events to orders.events keyed by orderId: OrderPlaced for ord-1042, OrderPlaced for ord-1043, and OrderCancelled for ord-1042, all for customer cust-208. keyed-orders customer publishes the same three events to orders.by-customer keyed by customerId. Do not switch the key in place on orders.events; a key change is a migration, and mixing key encodings in one topic would split one order's history across partitions.

Record the partition and offset of each send in observations/phase6-keys.txt, then verify with the console consumer on each topic using print.partition=true and print.key=true.

What you should see: in orders.by-customer, all three records sit in one partition at consecutive offsets, so a consumer sees placement of ord-1042, placement of ord-1043, and cancellation of ord-1042 in that sequence. In orders.events, the two ord-1042 records share a partition in append order, but ord-1043 lands wherever its own key hashes. If it happens to share the partition, that is coincidence rather than a guarantee.

Required Deliverables

  • schemas/order-placed-v1.json, v2, and v3, plus the three Java records and all source under src/main/java/com/example/kafka/.
  • The observations/*.txt files for every phase, including both consumer quarantine files with three entries each.
  • report.md with a table showing each change, how the old reader handles new sample data, how the new reader handles old sample data, and the application effect. Cover the optional addition, required-field rename, and key change. Keep schema compatibility separate from the ordering implications of a key change.
  • Written answers to these questions in report.md:
    • Q1 Identity: Why does rerunning produce-orders v1 reuse evt-7001, and how does that differ from a new OrderCancelled event?
    • Q2 Reader behavior: Which sample reader demonstrates each compatibility direction in Phase 2? Why do those successful reads and the Java UNKNOWN fallback not prove that a registry would accept the complete JSON Schemas under FULL?
    • Q3 Visible failure: What would the old consumer have printed with a primitive long, and why is quarantine better than stopping forever or skipping?
    • Q4 Dedup: For the two representations of placement-1042 in the dual run, how many facts would a consumer count if it deduplicated by eventId? How would an occurrence-based check change that result?
    • Q5 Crash window: Identify the duplicate-capture window and the missing-event window. Explain why merely reordering these steps cannot eliminate both.
    • Q6 Stale read: Which style should a price-history report consume, and what would a versioned lookup need to promise for the notification style to work?
    • Q7 Ordering: State the ordering guarantee under each key and identify the coincidence in your orders.events output, if any.

Acceptance Checks

  • Every orders.placed record has a key equal to data.orderId in its value.
  • phase2-old-consumer.txt and phase2-new-consumer.txt each contain nine lines and no exception.
  • phase3-failure.txt records the failing topic, partition, offset, and eventId. Also record the group’s committed offset and explain any difference from the failing record’s offset.
  • phase3-quarantine-order-reporting.txt and phase3-quarantine-order-fulfillment.txt each contain three entries with matching topic/partition/offset coordinates and raw values containing "total".
  • orders.placed.v3 holds three records whose occurrenceId values match three records in orders.placed.
  • payments.events holds exactly two PaymentCaptured records with distinct causationId values, and handled-commands.txt lists two IDs.
  • phase5-lookups.txt shows lookups=1 on both notification lines and lookups=0 on both snapshot lines, with at least one FETCHED revision above its NOTIFIED revision.
  • orders.by-customer has all three records in one partition at consecutive offsets.

Grading Rubric

AreaPoints
v1 schema, record, key rule, and producer validation15
Compatible sample reads in both directions and registry distinction explained15
Visible failure located by topic, partition, and offset10
Migration with quarantine, dual publication, and shared occurrence identity15
Command duplicate rejection and causation trace15
Lookup count and stale-read evidence10
Key switch with correct ordering explanation10
Report questions and compatibility table10
Total100

Optional Extensions

  • Set FAIL_ON_UNKNOWN_PROPERTIES to true in the old consumer, rerun Phase 2, and map the resulting UnrecognizedPropertyException to additionalProperties: false.
  • Add expiresAt handling to PaymentService, send a command whose deadline has passed, and publish PaymentCaptureRejected with the original commandId.
  • With product-consumer stopped, publish one notification and one snapshot and wait for both producer commands to complete. Then move stub/prod-501.json aside before starting the consumer. Log the notification’s lookup failure and preserve it for retry, but continue to the snapshot so you can observe its successful processing without a lookup. Do not commit past the failed notification unless you first record it durably in a recovery file.
  • Using the Avro schemas and reader-resolution rules from the schema-evolution lesson, reproduce the transitive trap in a separate three-version example: A has string salesChannel, B removes it, and C adds it as an integer with a reader default of 0. Test reader C against writers A and B: B lacks the field and uses the default, while A’s existing string cannot resolve to an integer.
  • Adapt order_placed.proto to the v3 contract, including its renamed total and any added identity/channel fields. Encode it with protobuf-java and compare its size with the equivalent JSON event.

Cleanup

Stop every consumer and producer with Ctrl-C, then remove the broker and its data:

Verify with docker ps -a, which should list no kafka-local container. Keep the project directory, observations, and report; delete observations/handled-commands.txt if you intend to rerun Phase 4 from scratch.

Summary

The lab separates runtime decoding, business validation, and registry schema compatibility. The optional addition works with these two readers and sample records; a registry comparison remains a separate check. Renaming the total field exposes the old reader’s missing required value. The migration preserves those failures in per-consumer quarantine files, moves v3 to its own topic, and uses occurrence IDs to connect different representations of the same accepted order.

The command experiment uses separate command and event identities and remembers handled IDs across a normal restart. It also leaves crash windows between simulated capture, the file write, and publication. The catalog experiment shows why fetching current state cannot reconstruct an older revision, while carried snapshots preserve each revision’s price. Finally, switching from order keys to customer keys changes which events share a partition and an ordering boundary.