Backward-Compatibility Contracts
Every published API is a promise. The moment a consumer writes code against your response shape, that shape becomes a contract you are obligated to honour — whether or not you ever wrote it down. Backward compatibility is the discipline of evolving the implementation behind that promise without forcing every consumer to change in lockstep. At the gateway boundary this stops being a philosophical question and becomes an enforceable one: the gateway is the single point where you can inspect what enters and what leaves, reject requests that violate the contract, and detect responses that have quietly drifted away from it. This page sits under Advanced Routing & API Versioning and covers what “compatible” actually means for an HTTP/JSON API, how to encode the contract as machine-checkable schema, and how to configure Kong 3.x and Envoy 1.32+ to enforce it on the wire.
Compatibility is the counterweight to versioning. API versioning strategies exist precisely so that when you cannot make a change compatibly, you have a controlled way to introduce a new surface. The goal of a compatibility contract is to make that escape hatch rare: most evolution should be additive and invisible, and a new version should be reserved for genuinely breaking redesigns.
Design invariants every compatibility contract must satisfy:
- The contract is a first-class artifact — an OpenAPI or JSON Schema document under version control, not tribal knowledge in a consumer’s head.
- Additive changes ship freely; breaking changes require a new version and a deprecation window, never a silent mutation of an existing one.
- Request validation is enforced inline at the gateway for all traffic; response validation runs at least in staging and canary tiers before a release is promoted.
- Consumers are tolerant readers and producers are conservative writers — the contract permits unknown fields but forbids removing guaranteed ones.
- The externally promised contract is decoupled from upstream implementation: the gateway mediates so an upstream refactor cannot leak through as a consumer-visible break.
- Every rejected request and every detected response-drift emits a structured log line and a metric — compatibility violations are observable, not silent 500s.
The Compatibility Change Taxonomy
Before configuring any gateway, you need a precise mental model of which changes are safe. Compatibility is directional and asymmetric. A change is backward compatible if a consumer built against the old contract keeps working against the new one. It is forward compatible if a consumer built against the new contract keeps working against the old one — which is what the tolerant reader pattern buys you. Most day-to-day API evolution is about preserving backward compatibility for existing consumers while adding capability for new ones.
The diagram below is the taxonomy every change should be sorted into before it merges. The left branch is free to ship; the right branch forces a versioning decision.
The subtle entries are the ones that cause production incidents. Enum widening — a producer adding a new value to an enum it returns — is a breaking change for any consumer that exhaustively switches on the enum and throws on the default branch, even though it looks additive. Nullability changes are breaking in both directions: making a previously non-null field nullable breaks consumers that never null-checked it, and making a nullable field required-in-response is safe only if every code path truly populates it. Loosening request validation is safe; tightening it is breaking, because a request that used to succeed now fails.
Encoding the Contract as Machine-Checkable Schema
A compatibility contract you cannot execute is a compatibility contract you will violate. The contract must be an OpenAPI 3.1 or JSON Schema document, stored beside the service code, that both the gateway and CI can load. The gateway uses it in two directions: request validation rejects malformed or contract-violating input before it reaches the upstream, and response validation confirms that what the upstream returned still matches what consumers were promised.
The single most important schema knob for compatibility is additionalProperties. Set it to true (or omit it) on response bodies so that the gateway does not reject a response merely because the upstream added a new field — this is the tolerant reader pattern expressed in schema. Set it to false on request bodies only if you deliberately want to reject unknown input, and understand that flipping it from true to false is itself a breaking change.
// JSON Schema (draft 2020-12) — response contract for GET /v2/orders/{id}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true, // tolerant reader: unknown fields allowed
"required": ["id", "status", "total"], // guaranteed fields — never remove
"properties": {
"id": { "type": "string", "format": "uuid" },
"status": { "type": "string", "enum": ["pending", "paid", "shipped", "cancelled"] },
"total": { "type": "number", "minimum": 0 },
"note": { "type": ["string", "null"] } // nullable is part of the contract
}
}
The required array is the load-bearing promise: those keys must be present in every response forever, or you have shipped a breaking change. The enum on status is a double-edged commitment — it documents the values consumers can expect, but if you validate responses against it, adding a fifth status value will make validation fail in canary, which is exactly the early-warning signal you want.
Request and Response Validation at the Gateway
Kong 3.x provides two complementary plugins. The request-validator (Enterprise) or the OSS json-schema validation approach checks incoming bodies against a schema; for full contract coverage, the openapi plugin validates requests and responses against an OpenAPI document attached to the service. The example below rejects any request that violates the body schema before it consumes upstream capacity.
# Kong 3.x declarative — request body validation on a versioned service
_format_version: "3.0"
services:
- name: orders-api
url: http://orders.internal:8080
routes:
- name: orders-v2
paths: ["/v2/orders"]
strip_path: false
plugins:
# Reject contract-violating input at the edge, before the upstream hop
- name: request-validator
config:
version: draft4
body_schema: |
{
"type": "object",
"required": ["sku", "quantity"],
"additionalProperties": false,
"properties": {
"sku": { "type": "string", "minLength": 1 },
"quantity": { "type": "integer", "minimum": 1 },
"note": { "type": "string" }
}
}
verbose_response: true # return the failing JSON-path to the caller
allowed_content_types: ["application/json"]
For teams standardising on OpenAPI, the openapi plugin loads a single specification and enforces both directions, which keeps the contract in one artifact rather than inlined per plugin:
# Kong 3.x — OpenAPI contract enforcement (request + response)
plugins:
- name: openapi
config:
spec: "@/etc/kong/specs/orders-v2.yaml"
validate_request_body: true
validate_response_body: true # enable in staging/canary; sample in prod
validate_request_uri_params: true
notify_only_response_body: true # log response drift without failing the call
notify_only_response_body: true is the pragmatic production setting: response drift is logged and metered rather than turned into a consumer-facing 500. That gives you the detection benefit of response validation without letting a strict schema cause an outage when the upstream legitimately adds a field.
Envoy 1.32+ does not ship a first-class JSON-schema plugin, so contract enforcement takes one of two shapes. For lightweight extraction and routing decisions based on body content, the json_to_metadata filter parses selected JSON fields into request metadata that later filters and RBAC rules can act on. For full schema validation and transformation, the external processing filter (ext_proc) streams request and response bodies to a sidecar validation service that owns the schema — the same service CI runs, which guarantees the wire check and the pipeline check never diverge.
# Envoy 1.32+ — ext_proc delegates contract validation to a sidecar validator
http_filters:
- name: envoy.filters.http.ext_proc
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor
grpc_service:
envoy_grpc:
cluster_name: contract_validator
failure_mode_allow: false # fail closed: reject if validator is down
processing_mode:
request_header_mode: "SEND"
request_body_mode: "BUFFERED" # buffer body so schema check sees it whole
response_body_mode: "BUFFERED" # enable in canary; costs streaming + latency
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
The failure_mode_allow: false choice is a compatibility posture decision. Failing closed means a validator outage rejects traffic, protecting the contract at the cost of availability; failing open (true) prioritises availability and lets unvalidated traffic through. Request validation should generally fail closed for write operations and may fail open for reads, but the choice must be explicit, not a default nobody reviewed.
For the lightweight metadata path, json_to_metadata avoids buffering the whole body when you only need a field or two:
# Envoy 1.32+ — extract a version discriminator from the body for routing/checks
- name: envoy.filters.http.json_to_metadata
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.json_to_metadata.v3.JsonToMetadata
request_rules:
rules:
- selectors:
- key: "schemaVersion"
on_present:
metadata_namespace: "envoy.lb"
key: "schema_version"
on_missing:
key: "schema_version"
value: "unknown"
Shielding Consumers from Upstream Changes
The deepest value of enforcing compatibility at the gateway is that the gateway can mediate: the contract you promise consumers no longer has to equal the shape your upstream happens to emit today. When an upstream team renames orderTotal to total in their internal refactor, a response-transformation step at the gateway re-adds the old key so the outward contract is unchanged, and response validation in canary confirms nothing else drifted.
# Kong 3.x — response-transformer preserves a field the upstream renamed
plugins:
- name: response-transformer
config:
add:
json:
- "orderTotal:$(headers.x-total)" # re-materialise the promised field
rename:
json:
- "total:orderTotal" # map new upstream name to old contract
This is the mechanism that decouples an upstream’s release cadence from your externally promised contract. It should be a bridge, not a permanent home: every transformation that papers over an upstream change is technical debt tracked against a deprecation lifecycle, so the compatibility shim is removed on a schedule rather than accreting forever. When a change genuinely cannot be shimmed — a semantic redesign of the resource — that is the signal to introduce a new version rather than distort the existing one.
Comparative Implementation
| Gateway | Config approach | Trade-off |
|---|---|---|
| Kong 3.x | request-validator for body schema; openapi plugin for full request/response enforcement from one spec |
Simplest to adopt; response validation buffers the body, so use notify_only_response_body in production |
| Envoy 1.32+ | ext_proc streams bodies to a schema-owning sidecar; json_to_metadata for lightweight field extraction |
Most flexible and shares the validator with CI, but requires operating an extra service and buffering costs streaming |
| Tyk 5.x | Built-in request validation against an OpenAPI/JSON schema in the API definition | Native and low-friction for requests; response-side contract checks are thinner than Kong’s openapi plugin |
| NGINX Plus R32+ | njs module invokes a JSON-schema check, or auth_request delegates to a validator service |
Full control but you assemble the validation logic yourself; no turnkey OpenAPI enforcement |
The unifying principle across all four: request validation is cheap enough to run inline for every request, while response validation is expensive enough that it belongs in staging and canary, sampled in production. Do not let the cost of response validation talk you out of it entirely — sampled response checks are how you catch the upstream that silently dropped a field.
Operational Gotchas
Silent field removal. The most common compatibility break is an upstream refactor that stops populating a field the contract still promises. If the schema uses additionalProperties: true but does not list the field in required, validation passes and consumers break. Fix: every guaranteed field must be in the required array, and response validation must run in canary so the drop is caught before promotion.
Enum widening that looks additive. Adding a value to a returned enum sails through code review as “just one more status” but breaks consumers that exhaustively match. If you validate responses against a closed enum, the new value fails canary validation — which is the desired alarm. Treat the alarm as a versioning decision, not a schema bug to silence.
Nullability drift. A field that was always populated becomes occasionally null after an upstream change. Consumers that never null-checked it now throw. Encode nullability explicitly ("type": ["string", "null"]) and treat any transition between nullable and non-nullable as breaking.
additionalProperties: false on responses. Setting this on a response schema turns the tolerant reader pattern inside out: the gateway now rejects the upstream for adding a field, converting a safe additive change into a self-inflicted outage. Reserve additionalProperties: false for request bodies where strict input rejection is intended.
Validation that fails open silently. An ext_proc validator that is misconfigured with failure_mode_allow: true, or a Kong plugin that errors and is skipped, means the contract is unenforced while every dashboard shows green. Alert on validator health and on a sudden drop in validation-rejection rate, not only on rejections themselves.
Content-type and encoding assumptions. Body validators that assume application/json will silently skip application/json; charset=utf-8 or gzipped bodies unless configured for them. Pin allowed_content_types and confirm the gateway decompresses before it validates.
Production Configuration Checklist
- The contract exists as an OpenAPI 3.1 or JSON Schema artifact in version control, loaded by both the gateway and CI.
- Every field guaranteed to consumers appears in the schema’s
requiredarray. - Response schemas use
additionalProperties: trueso unknown upstream fields never cause rejection (tolerant reader). - Request body validation runs inline at the gateway for all write traffic, with
verbose_responsedisabled in production to avoid leaking schema internals. - Response validation runs in staging and canary; in production it is sampled or set to notify-only rather than fail-closed.
- Enum outputs are validated in canary so widening is caught as a versioning decision.
- Nullability is declared explicitly for every optional field, and nullability transitions are treated as breaking.
-
failure_mode_allow/ fault-tolerance posture is explicitly chosen per operation (fail-closed for writes) and reviewed. - Response-transformation shims that preserve renamed or removed upstream fields are tracked against a deprecation schedule.
- Validator health and validation-rejection rate are both alerted on, so a silently unenforced contract is detectable.
-
allowed_content_typesis pinned and the gateway decompresses bodies before validating them. - Breaking changes are gated in CI against the previous published contract, not merged and discovered in production.
FAQ
What counts as a breaking change to an HTTP/JSON API?
A change is breaking if any conforming consumer written against the previous contract can observe different behaviour that causes it to fail. Removing or renaming a response field, tightening a request validation rule, narrowing an enum’s accepted output set, changing a field’s type or nullability, adding a required request field, and changing HTTP status semantics are all breaking. Adding an optional response field, adding an optional request parameter with a safe default, and adding a new endpoint are additive and safe. When a change cannot be made additively, introduce it under a new version using an API versioning strategy rather than mutating the existing surface.
Should schema validation run at the gateway or in the upstream service?
Run request validation at the gateway so malformed input is rejected before it consumes upstream capacity, and run response validation at the gateway in staging and canary environments to catch contract drift the upstream introduced. The upstream still validates its own inputs defensively, but the gateway is the enforcement point that shields every consumer uniformly and gives you one place to version and audit the contract.
What is the tolerant reader pattern and why does it matter for compatibility?
A tolerant reader ignores fields it does not recognise, does not fail when optional fields are absent, and does not assume field ordering. It matters because it lets a producer add fields additively without breaking consumers. The gateway supports tolerant readers by validating only the fields the contract guarantees, allowing additional properties (additionalProperties: true), and never rejecting a response solely because it contains an unknown field.
How does response validation at the gateway avoid slowing down production traffic?
Full JSON-schema response validation buffers and parses the body, which adds latency and defeats streaming, so most teams enable it in staging and canary tiers and sample it at a low percentage in production rather than validating every response. Request validation is cheaper and safe to run inline for all traffic. Where production response checks are required, restrict them to a lightweight structural check or a sampled subset.
How does the gateway shield consumers from an upstream that ships a breaking change?
The gateway pins a versioned contract and mediates between it and the upstream. When the upstream renames or removes a field, a response-transformation step re-adds or re-maps the field so the outward contract is unchanged, and response validation in canary flags any drift the transformation missed before it reaches consumers. This decouples the upstream’s internal release cadence from the externally promised contract.
Related
- API Versioning Strategies — when a change cannot be made compatibly, this is how you introduce the next version cleanly.
- Deprecation & Lifecycle Management — how compatibility shims and retired versions are sunset on a schedule rather than left to rot.
- Consumer-Driven Contract Testing at the Gateway — catch breaking changes in CI with Pact-style contracts before they reach the gateway at all.
- Request & Response Transformation — the transformation primitives that let the gateway preserve a contract across an upstream change.
- Path & Header-Based Routing — how routing selects the contract-bound route before validation runs.