Breaking Change Detection
Contractual detects breaking changes by structurally comparing two versions of a schema, not by diffing strings or lines. It interprets schema structure and classifies changes by impact on existing consumers.
Approaches to detecting breaking changes
Section titled “Approaches to detecting breaking changes”Teams use several strategies to catch breaking API and schema changes. Each operates at a different layer of the development lifecycle.
| Approach | How it works | Catches problems | Tradeoffs |
|---|---|---|---|
| Code review | Humans read the diff | Sometimes, if reviewer knows the schema | Misses structural implications |
| Consumer-driven contracts (Pact) | Consumers declare expectations, provider verifies | What’s covered by tests | Requires org-wide adoption, Pact broker |
| Schema packages | Publish schemas as libraries, consumers test | Consumer-side only, after the fact | Doesn’t prevent, just detects late |
| Schema registry | Registry rejects incompatible schemas at deploy/runtime | Structurally, with enforcement | Adds infrastructure, latency, runtime coupling |
| Spec-level diffing in CI | Diff schema structure in PRs | Structurally, at PR time | No runtime enforcement, no business-level semantics |
Contractual takes the last approach. These are not competing approaches. A team might use spec-level diffing in CI for early warning alongside a schema registry for runtime enforcement.
Structural diff vs string diff
Section titled “Structural diff vs string diff”When git diff is used on a schema file, it shows which lines changed. That does not indicate whether the change is safe for consumers.
A structural differ reads both schemas into memory, normalizes them (resolving $ref, merging allOf, flattening defaults), and then compares the logical structure:
String diff sees: Structural diff sees:
- "type": "string" Field "amount":+ "type": "number" type changed string → number Classification: BREAKINGThis matters because:
- Whitespace and formatting changes produce no structural diff
- Moving a
$refinline produces no structural diff - Reordering
requiredarray entries produces no structural diff - Changing a type produces a breaking diff regardless of how it looks in the file
How the JSON Schema differ works
Section titled “How the JSON Schema differ works”Contractual’s JSON Schema differ performs a depth-first traversal of both schemas simultaneously. At each node it compares the old value to the new value and applies classification rules.
Draft support
Section titled “Draft support”The differ supports JSON Schema Draft-07, Draft 2019-09, and Draft 2020-12. Supported Draft 2020-12 keywords include:
dependentRequired/dependentSchemas: conditional requirementsprefixItems: tuple validation (replaces array-formitems)minContains/maxContains: array containment constraintsunevaluatedProperties/unevaluatedItems: strict additional property handling- Annotation keywords:
deprecated,readOnly,writeOnly - Content keywords:
contentEncoding,contentMediaType,contentSchema
If a schema uses draft-specific keywords that the differ does not recognize, those keywords are ignored (not flagged as breaking). Use contractual lint to catch unknown keywords before diffing.
Classification rules summary
Section titled “Classification rules summary”For quick reference, here are the key rules. See Classifications for the full reference.
Object properties
Section titled “Object properties”| Change | Classification | Reason |
|---|---|---|
| Property removed | Breaking | Consumers reading this field will break |
| Property type changed (narrowed) | Breaking | Values that were valid are no longer valid |
| Property type changed (widened) | Non-breaking | All previously valid values remain valid |
| Required property added | Breaking | Producers that omit this field now fail validation |
| Optional property added | Non-breaking | Existing consumers unaffected |
additionalProperties: true → false | Breaking | Previously-valid extra fields now rejected |
Enum and const
Section titled “Enum and const”| Change | Classification | Reason |
|---|---|---|
| Enum value removed | Breaking | Consumers using that value will fail validation |
| Enum value added | Non-breaking | All existing values still valid |
const value changed | Breaking | The single allowed value changed |
Metadata-only changes
Section titled “Metadata-only changes”| Change | Classification | Reason |
|---|---|---|
title changed | Patch | No validation impact |
description changed | Patch | No validation impact |
examples changed | Patch | No validation impact |
Composition keyword handling
Section titled “Composition keyword handling”Composition keywords (anyOf, oneOf, allOf, not) are handled with granular classification:
| Change | Classification |
|---|---|
Option added to anyOf / oneOf | Breaking (major) |
Option removed from anyOf / oneOf | Non-breaking (minor) |
Member added to allOf | Breaking (major) |
Member removed from allOf | Non-breaking (minor) |
not schema changed | Breaking (major) |
if/then/else changed | Unknown (requires review) |
Adding options to a union makes the schema accept different values, which can be breaking for consumers who switch on type. Removing options makes the schema less restrictive (non-breaking). Changes to not invert the excluded set, which is always breaking.
The “Unknown” category
Section titled “The “Unknown” category”Some constructs cannot be classified deterministically. Contractual marks these as Unknown and requires human review:
$ contractual breaking
orders-api 1 unknown change requires review
UNKNOWN orders-api: if/then/else changed Cannot determine if this is breaking. Review manually.Constructs that produce Unknown classifications:
if/then/elseconditional schemasdependentSchemaschangesunevaluatedProperties/unevaluatedItemschanges
An Unknown classification does not fail the CI check by default, but it appears in the PR comment and generates a changeset entry at the major level by default. This can be overridden in the changeset frontmatter after review.
OpenAPI structural diffing
Section titled “OpenAPI structural diffing”For OpenAPI contracts, Contractual uses a built-in Node-native structural differ that operates at the HTTP operation level, not just the schema level, covering:
- Endpoint additions and removals
- HTTP method additions and removals
- Path and query parameter changes
- Request body schema changes
- Response schema changes
- Authentication requirement changes
- Server URL changes
Edge cases
Section titled “Edge cases”$ref resolution
Section titled “$ref resolution”Before diffing, Contractual resolves all $ref pointers in both schemas. This means:
- A change to a shared
$refdefinition propagates to every location that uses it - Moving a type definition from inline to a
$ref(or vice versa) does not produce a diff if the resulting schema is identical - Circular
$refchains are detected and handled (the recursive portion is not traversed more than once)
Nested objects
Section titled “Nested objects”The differ recurses into nested objects to any depth. A breaking change to a property of a property of a property is still classified as breaking and reported with its full path:
BREAKING orders-api: properties.customer.properties.address.properties.zip type changed: string → integerArray items
Section titled “Array items”Changes to items are treated as changes to the schema of every element in an array. If items.type changes from string to number, any array containing strings is now invalid and classified as breaking.
If items changes from a single schema to an array of schemas (tuple validation), Contractual flags this as breaking because the positional semantics changed.
allOf merging
Section titled “allOf merging”allOf schemas are merged before diffing. If a new allOf entry makes a property required, the differ sees a required field added, classified as breaking, regardless of where in the allOf structure the addition appears.
Classification-to-semver mapping
Section titled “Classification-to-semver mapping”| Classification | Semver bump |
|---|---|
| Breaking | major |
| Non-breaking | minor |
| Patch | patch |
| Unknown | major (conservative default, override with changeset edit) |
The highest bump across all changes in a changeset wins. If a PR has both breaking and non-breaking changes, the contract gets a major bump.
Next steps
Section titled “Next steps”- Usage: CLI options, CI integration, and custom differs
- Classifications: Full reference table for all change types