Skip to content

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.


Teams use several strategies to catch breaking API and schema changes. Each operates at a different layer of the development lifecycle.

ApproachHow it worksCatches problemsTradeoffs
Code reviewHumans read the diffSometimes, if reviewer knows the schemaMisses structural implications
Consumer-driven contracts (Pact)Consumers declare expectations, provider verifiesWhat’s covered by testsRequires org-wide adoption, Pact broker
Schema packagesPublish schemas as libraries, consumers testConsumer-side only, after the factDoesn’t prevent, just detects late
Schema registryRegistry rejects incompatible schemas at deploy/runtimeStructurally, with enforcementAdds infrastructure, latency, runtime coupling
Spec-level diffing in CIDiff schema structure in PRsStructurally, at PR timeNo 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.


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: BREAKING

This matters because:

  • Whitespace and formatting changes produce no structural diff
  • Moving a $ref inline produces no structural diff
  • Reordering required array entries produces no structural diff
  • Changing a type produces a breaking diff regardless of how it looks in the file

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.

The differ supports JSON Schema Draft-07, Draft 2019-09, and Draft 2020-12. Supported Draft 2020-12 keywords include:

  • dependentRequired / dependentSchemas: conditional requirements
  • prefixItems: tuple validation (replaces array-form items)
  • minContains / maxContains: array containment constraints
  • unevaluatedProperties / 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.


For quick reference, here are the key rules. See Classifications for the full reference.

ChangeClassificationReason
Property removedBreakingConsumers reading this field will break
Property type changed (narrowed)BreakingValues that were valid are no longer valid
Property type changed (widened)Non-breakingAll previously valid values remain valid
Required property addedBreakingProducers that omit this field now fail validation
Optional property addedNon-breakingExisting consumers unaffected
additionalProperties: truefalseBreakingPreviously-valid extra fields now rejected
ChangeClassificationReason
Enum value removedBreakingConsumers using that value will fail validation
Enum value addedNon-breakingAll existing values still valid
const value changedBreakingThe single allowed value changed
ChangeClassificationReason
title changedPatchNo validation impact
description changedPatchNo validation impact
examples changedPatchNo validation impact

Composition keywords (anyOf, oneOf, allOf, not) are handled with granular classification:

ChangeClassification
Option added to anyOf / oneOfBreaking (major)
Option removed from anyOf / oneOfNon-breaking (minor)
Member added to allOfBreaking (major)
Member removed from allOfNon-breaking (minor)
not schema changedBreaking (major)
if/then/else changedUnknown (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.

Some constructs cannot be classified deterministically. Contractual marks these as Unknown and requires human review:

Terminal window
$ 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 / else conditional schemas
  • dependentSchemas changes
  • unevaluatedProperties / unevaluatedItems changes

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.


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

Before diffing, Contractual resolves all $ref pointers in both schemas. This means:

  • A change to a shared $ref definition 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 $ref chains are detected and handled (the recursive portion is not traversed more than once)

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 → integer

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 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.


ClassificationSemver bump
Breakingmajor
Non-breakingminor
Patchpatch
Unknownmajor (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.


  • Usage: CLI options, CI integration, and custom differs
  • Classifications: Full reference table for all change types