Evolve a contract
A shared contract package makes both sides agree at compile time. It cannot make them deploy at the same moment: during a rollout, and for as long as some service still pins the old contract version, old and new code exchange messages. This guide keeps those messages valid in both directions.
What goes wrong if you get it wrong is specific. The consumer validates every message against its version of the schema, and a message that fails validation is dead-lettered on first delivery — never retried, never handled. An incompatible change does not crash anything; it quietly moves traffic into the dead-letter queue.
Make additive changes
Adding an optional field is safe in either deploy order:
const orderCreatedV1 = z.object({ orderId: z.string(), amount: z.number() });
// Next contract version
const orderCreated = z.object({
orderId: z.string(),
amount: z.number(),
currency: z.string().optional(), // new, optional
});- An old consumer receives
currencyand ignores it —z.objectstrips keys it does not declare. - A new consumer receives messages from an old publisher without
currency, which the schema allows.
Changes that look additive but are not:
- A new required field. Old publishers do not send it, so new consumers reject their messages.
- A new enum value. Old consumers declared the old set, so they reject the new value.
- A narrowed type (a longer minimum length, a stricter format). Messages already in the queue, or from old publishers, may fail it.
For a field that must become required, add it as optional, deploy every publisher so it is always sent, then make it required.
Keep readers tolerant
Additive changes only work if consumers ignore what they do not know. Default object schemas do: Zod's z.object and Valibot's v.object strip undeclared keys, and ArkType ignores them. Strict variants — z.strictObject, v.strictObject, ArkType's "+": "reject" — reject any message carrying a field the reader has not heard of, which turns every additive change on the publisher into dead letters on the consumer. Avoid them on consumed messages.
Remove a field in two phases
- Make it optional in the contract, and change consumers to stop reading it. Deploy every consumer.
- Stop sending it. Remove it from the schema and from publishers. Deploy.
Doing both at once breaks every consumer still running the old contract, which treats the missing field as invalid. A rename is the same procedure: add the new field (optional), move readers to it, then remove the old one.
Version a message you cannot change compatibly
When a message has to change shape outright, publish the new shape as a new message on its own routing key, and run both until every consumer has moved:
const orderCreatedV1 = defineEventPublisher(ordersExchange, orderMessageV1, {
routingKey: "order.created",
});
const orderCreatedV2 = defineEventPublisher(ordersExchange, orderMessageV2, {
routingKey: "order.created.v2",
});- Add the
v2publisher, and publish both versions. - Move each consumer to the
v2event —defineEventConsumer(orderCreatedV2, queue)— one service at a time. - When no consumer is bound to
order.createdany more, stop publishingv1and remove it.
Choose the new key with your bindings in mind: a consumer bound to order.created.# would receive both versions.
defineContract rejects a publisher whose routing key reaches no queue in the contract. While a consumer owned by another service is the only one bound to the new key, declare it with externalConsumers: true — see define a contract.
Do not change a queue in place
Queue and exchange properties — queue type, dead-letter settings, arguments — are fixed once the broker has declared them. A contract that re-declares an existing queue with different properties is refused by RabbitMQ, and the worker fails to start. To change one, declare a queue under a new name, move consumers to it, and delete the old one once it is empty.
Review changes through the AsyncAPI diff
Commit the generated AsyncAPI document and regenerate it in CI, failing the build when it changed without being committed:
- run: pnpm generate:asyncapi:json
- run: git diff --exit-code asyncapi.jsonThis detects any change to the contract, not only breaking ones — it does not classify them. Its value is that every contract change arrives in a pull request as a readable diff of the message schemas, where a reviewer can check it against the rules above: new required fields, new enum values, removed fields and changed queues are the lines to question. See generate AsyncAPI.
Where next
- Define a contract — sharing one contract package between services.
- Route dead letters — inspecting and replaying messages that failed validation.
- Schema libraries — how Zod, Valibot and ArkType behave as message schemas.