Contracts & the catalog

Versioned Avro and Protobuf contracts in a shared contracts module and Apicurio registry, and why a discovery catalog is a mesh requirement even though it isn't built yet.

35 minutesChapter 05Building data products

Each service is a data product, but other domains can use it only if they can find it, understand its shape, and trust that it won’t change without warning. This chapter covers the two mechanisms this project uses for that: a shared contracts module holding every service’s Avro and Protobuf definitions as its single source of truth, and Apicurio Schema Registry enforcing those Avro contracts at runtime. It closes with what is not yet built, a discovery catalog, and why that gap matters.

The code is in contracts. There’s no standalone demo script for the registry itself; demo-kafka.sh exercises it as part of proving the Kafka data plane, and this chapter’s cross-check comes from there.

Figure 4.1 shows the chapter’s scope. Every protocol surface in the mesh carries its own contract:

Protocol Contract type
REST OpenAPI document
gRPC .proto file
GraphQL SDL schema
Kafka Avro schema

Apicurio Registry is the one home for all of them regardless of format, with OpenMetadata sitting downstream to turn those contracts plus the running Postgres schemas and Kafka topics into a lineage graph. That target picture is wider than what this chapter’s code wires up today; the sections below separate running enforcement from the conceptual target, starting with the two contract types contracts owns.

Diagram showing every protocol's contract type feeding into Apicurio Registry, with OpenMetadata downstream building a lineage graph from the registry and the running data stores
Figure 4.1 — Contracts, the registry, and the catalog across the mesh

One module, two contract types

contracts is a plain Maven module — framework-agnostic, no Quarkus dependency at all, so any JVM service (or build) can depend on it. It holds two different kinds of contract, generated by two different plugins, for two different protocols:

examples/contracts/
  src/main/avro/
    order-placed.avsc         # capstone.order.v1.OrderPlaced
    payment-captured.avsc     # capstone.payment.v1.PaymentCaptured
    shipment-dispatched.avsc  # capstone.shipping.v1.ShipmentDispatched
  src/main/proto/
    capstone/inventory/v1/inventory.proto

The Avro schemas are the event contracts. Here’s order-placed.avsc in full:

{
  "type": "record",
  "name": "OrderPlaced",
  "namespace": "capstone.order.v1",
  "doc": "Emitted by order-service after an order is durably persisted.",
  "fields": [
    { "name": "event_type",  "type": "string" },
    { "name": "order_id",    "type": "string" },
    { "name": "customer_id", "type": "string" },
    { "name": "item_sku",    "type": "string" },
    { "name": "quantity",    "type": "int" },
    { "name": "amount",      "type": "string" },
    { "name": "status",      "type": "string" },
    { "name": "created_at",  "type": "string" }
  ]
}

name + namespace together give every record a fully qualified identity (capstone.order.v1.OrderPlaced) that doubles as its Apicurio artifact identity and its generated Java package, so the three stay in lockstep by construction. amount is a string, not Avro’s decimal logical type: the schema’s doc field calls this “a first-slice simplification” and names the decimal logical type as the later refinement. Anyone extending this contract should treat it as a known shortcut.

The org.apache.avro:avro-maven-plugin’s schema goal, bound to Maven’s generate-sources phase, turns each .avsc into a generated SpecificRecord Java class under target/generated-sources/avro — that’s the concrete OrderPlaced type OrderEventProducer builds with OrderPlaced.newBuilder() in the previous chapter. The record is never hand-written; the .avsc is the one source of truth, and the Java class is a build artifact of it.

The Protobuf side is a single .proto for the one synchronous cross-service call in this project:

syntax = "proto3";

package capstone.inventory.v1;

service InventoryService {
  rpc CheckStock(CheckStockRequest) returns (CheckStockResponse);
}

message CheckStockRequest {
  string sku = 1;
  int32 quantity = 2;
}

message CheckStockResponse {
  bool available = 1;
  int32 quantity_on_hand = 2;
}

The contracts module does not generate gRPC stubs itself — it packages inventory.proto as a plain resource so it ships inside the jar. Each consuming service (order-service as a client, inventory-service as a server) adds the quarkus-grpc extension and points quarkus.generate-code.grpc.scan-for-proto at this module’s Maven coordinates:

quarkus.generate-code.grpc.scan-for-proto=com.patterncatalyst.datamesh:contracts

That property is what makes the InventoryService Mutiny interface and the CheckStockRequest/CheckStockResponse builders used throughout the previous chapter appear under each service’s own target/generated-sources/grpc at build time — generated fresh per module from the one proto this module owns, never copy-pasted between services.

Two jobs the registry does: runtime vs. discovery

These two contract types are enforced differently once a service is running.

A runtime contract sits on the hot path. The Avro event schemas are this project’s example: OrderEventProducer’s emitter.send(event) only works at all because Apicurio’s Avro serializer and deserializer agree on the schema, fetched from the registry at publish/consume time; the event will not encode or decode if the schema is wrong or missing. Because of that, Apicurio can enforce governance on it: a compatibility rule configured on the artifact can reject a schema change that would break existing consumers, at publish time, before it ever reaches one.

A discovery contract is different. The .proto here is compiled ahead of time into generated stubs, not fetched at request time — nothing fails at runtime if the registry doesn’t have a copy of it. It still deserves a single, versioned home (so a consumer can always find the exact shape a gRPC service expects), but the coupling is loose and offline rather than tight and online.

Figure 4.2 shows the distinction as two numbered paths through the same registry. The runtime path is the four steps this project runs: ① OrderEventProducer serializes OrderPlaced and registers (or fetches) its schema against Apicurio, ② the Avro bytes plus a 4-byte schema id land on the order-placed Kafka topic, ③ OrderPlacedConsumer in notification-service fetches that same schema by id, and ④ it deserializes the bytes back into an OrderPlaced record before persisting a Notification — the round trip the previous chapter’s OrderEventProducer/OrderPlacedConsumer pair walks through in code. The discovery path (⑤–⑧) goes beyond what is wired up: it shows all four services publishing contracts — OpenAPI, Protobuf, GraphQL SDL, and Avro again — into the same registry, with OpenMetadata ingesting them alongside the Postgres schemas and Kafka topics to build a lineage graph. Of those four, only Protobuf is a checked-in discovery contract in this project (inventory.proto, packaged into the contracts jar as covered above); order-service and inventory-service don’t add the quarkus-smallrye-openapi extension, so there’s no /openapi.json document generated today for REST to publish anywhere, and while graphql-gateway’s quarkus-smallrye-graphql extension does derive a GraphQL SDL schema from GatewayApi’s annotations (what the diagram draws as schema.graphql), nothing in this repository registers it anywhere or ingests it into a catalog. Figure 4.2 marks ⑤–⑧ as the target shape, not code that runs today.

Diagram contrasting the Avro runtime path (serialize, publish, fetch schema, deserialize) with the discovery path where services publish OpenAPI, Protobuf, GraphQL SDL and Avro contracts for a catalog to ingest
Figure 4.2 — How a contract flows: runtime path vs. discovery path

Conflating the two is how teams get surprised in both directions: treating the Avro schema as documentation misses that a bad change breaks every consumer immediately, and treating the proto as if it were enforced like the Avro schema overstates what compiling against a checked-in .proto guarantees at runtime.

What’s registered, and how to see it

application.properties in each producing/consuming service configures the Apicurio Avro serde explicitly (see the previous chapter’s note on why value.serializer is pinned rather than autodetected), pointing at the compose stack’s Apicurio instance. demo-kafka.sh is where this project shows the registry doing work: it places an order (triggering OrderEventProducer.publish), reads the raw bytes back off the Kafka topic with kcat, bypassing any Avro deserializer, and asserts the first byte of the record value is 0x00, the Apicurio/Confluent wire-format magic byte that precedes a 4-byte schema id and the Avro-encoded payload. It also asserts the value does not start with 0x7b ({), which would mean the serializer had silently regressed to plain JSON. Then it confirms Apicurio’s own artifact count went up after the publish, and that a matching artifact exists under either the topic name or the record’s fully qualified name capstone.order.v1.OrderPlaced — confirming from the registry’s side that a schema was registered for this run, not just that bytes arrived.

Raw-byte wire-format proof plus registry-side confirmation is cheap evidence that the contract is enforced and not merely declared in a .avsc file and ignored at runtime.

Why a catalog is a requirement, not an add-on — and why it isn’t built yet

A registry tells you the shape of one contract at a time. It does not tell you which products exist across the whole mesh, who owns each one, who consumes whom, or where a given piece of data ultimately came from. That’s a catalog’s job — ingesting from the registry, the databases, and the event backbone to assemble a discoverable picture with lineage: the producer-and-consumer graph across domains, so a change’s downstream blast radius is something you can look up rather than reverse-engineer from code.

Figure 4.3 repeats Figure 4.1 with each contract marked by its job: a filled dot for the one runtime contract (Avro, enforced live against Apicurio) and hollow dots for the three discovery contracts (OpenAPI, Protobuf, GraphQL SDL). That marking is also why the OpenMetadata box sits across a gap: everything to its left is running code or a checked-in artifact in this repository, and everything inside it is not.

Diagram marking each protocol's contract as either a runtime contract (Avro, filled dot) or a discovery contract (OpenAPI, Protobuf, GraphQL SDL, hollow dots), all registered in Apicurio, with OpenMetadata downstream ingesting contracts and data sources into a lineage graph
Figure 4.3 — Runtime vs. discovery contracts, and the catalog gap

This project does not build that catalog yet: there is no OpenMetadata deployment, no ingestion job, and no demo-discovery.sh or demo-om-lineage.sh in this repository today. The sibling Python reference architecture layers OpenMetadata on top of the same kind of registry this chapter describes. The OpenMetadata ingestion and lineage work is planned but not yet built here, so this section stays conceptual and cites no catalog demo.

It belongs in this chapter because of the argument: a mesh’s premise is that domains own their data independently and other domains consume it without a central team brokering access. That premise only holds if products are discoverable and their contracts are trustworthy. Without a registry (this project has one), contracts drift and consumers break silently. Without a catalog (it does not yet have one), nobody can find products or see lineage across the whole mesh, so in practice teams fall back to asking around, which reintroduces exactly the bottleneck a mesh exists to remove. Discovery infrastructure is a precondition for federated governance to be computational, checked at publish time and recorded automatically, rather than enforced through team coordination. This project demonstrates the registry half of that claim in running code; the catalog half is future work.

Build, run, observe

cd demos && ./demo-kafka.sh

The next chapter’s data-planes discussion returns to this demo for the event backbone. Here, the two assertion steps that concern contracts are the raw first-byte check (0x00, not {) right after the order is placed, and the Apicurio artifact-count delta and artifact-id match that follow it. Together they show that this project’s Avro contracts hold at runtime.

What you learned

  • contracts is the single source of truth for both contract types in this project — Avro .avsc for events, compiled ahead of time into generated SpecificRecords, and a .proto for the one gRPC call, scanned out of the module’s jar by each consuming service’s own build.
  • Runtime contracts (Avro, fetched/enforced live against Apicurio) and discovery contracts (the proto, compiled ahead of time) have different coupling; conflating them leads to surprises about what a contract guarantees.
  • A catalog is a mesh requirement in principle — without one, discovery degrades back into a central team being asked where things are — but this project has not built one yet.

The next question is how data moves across the mesh on top of them — the synchronous read layer and the asynchronous event backbone.


Verification status: verified. demo-kafka.sh’s raw-byte magic-byte assertion passed and OrderPlacedAvroWireIT is green. One item remains unexercised: an intentionally incompatible Avro schema change being rejected by Apicurio at publish time is still not covered by any demo.