Breaking Changes Guide
This guide covers how CVT detects breaking changes between API schema versions and how to use the can-i-deploy safety check.
What Are Breaking Changes?
Breaking changes are modifications to an API that can cause existing consumers to fail. CVT detects these automatically when comparing schema versions.
Types of Breaking Changes
| Type | Description | Example |
|---|---|---|
ENDPOINT_REMOVED | An endpoint was removed | DELETE /pet/{petId} no longer exists |
REQUIRED_FIELD_ADDED | A new required field in request body | Request now requires category field |
REQUIRED_PARAMETER_ADDED | New required query/path/header param | ?apiVersion now required |
TYPE_CHANGED | Field type changed incompatibly | id changed from integer to string |
RESPONSE_SCHEMA_CHANGED | Response structure changed | Response no longer includes status field |
ENUM_VALUE_REMOVED | Enum value was removed | status no longer accepts "pending" |
Non-Breaking Changes
These changes are safe for existing consumers:
| Change | Why It's Safe |
|---|---|
| Adding optional fields | Consumers can ignore them |
| Adding new endpoints | Existing calls aren't affected |
| Adding optional parameters | Existing calls work without them |
| Adding enum values | Existing values still work |
| Relaxing validation | Previously valid requests remain valid |
| Improving descriptions | Documentation-only |
Schema Comparison
Using the CLI
# Compare two schema files
cvt compare --old ./v1/openapi.json --new ./v2/openapi.json
# JSON output for scripting
cvt compare --old ./v1/openapi.json --new ./v2/openapi.json --json
Output Example
✗ Found 2 breaking change(s):
[ENDPOINT_REMOVED] DELETE /pet/{petId} was removed
Old: DELETE /pet/{petId}
[REQUIRED_FIELD_ADDED] Required field 'category' added to POST /pet request
New: category (required)
Using the SDK
- Node.js
- Python
- Go
- Java
import { ContractValidator } from "@sahina/cvt-sdk";
const validator = new ContractValidator("localhost:9550");
// Register both versions
await validator.registerSchemaWithVersion("petstore", "./openapi-v1.json", "1.0.0");
await validator.registerSchemaWithVersion("petstore", "./openapi-v2.json", "2.0.0");
// Compare
const result = await validator.compareSchemas("petstore", "1.0.0", "2.0.0");
if (!result.compatible) {
console.error("Breaking changes detected:");
for (const change of result.breakingChanges) {
console.error(`- [${change.type}] ${change.method} ${change.path}`);
console.error(` ${change.description}`);
}
}
from cvt_sdk import ContractValidator
validator = ContractValidator("localhost:9550")
# Register both versions
validator.register_schema_with_version("petstore", "./openapi-v1.json", "1.0.0")
validator.register_schema_with_version("petstore", "./openapi-v2.json", "2.0.0")
# Compare
result = validator.compare_schemas("petstore", "1.0.0", "2.0.0")
if not result["compatible"]:
print("Breaking changes detected:")
for change in result["breaking_changes"]:
print(f"- [{change['type']}] {change['method']} {change['path']}")
print(f" {change['description']}")
validator, err := cvt.NewValidator("")
if err != nil {
log.Fatal(err)
}
defer validator.Close()
// Register both versions
err = validator.RegisterSchemaWithVersion(ctx, "petstore", "./openapi-v1.json", "1.0.0")
if err != nil {
log.Fatal(err)
}
err = validator.RegisterSchemaWithVersion(ctx, "petstore", "./openapi-v2.json", "2.0.0")
if err != nil {
log.Fatal(err)
}
// Compare
result, err := validator.CompareSchemas(ctx, "petstore", "1.0.0", "2.0.0")
if err != nil {
log.Fatal(err)
}
if !result.Compatible {
fmt.Println("Breaking changes detected:")
for _, change := range result.BreakingChanges {
fmt.Printf("- [%s] %s %s\n", change.Type, change.Method, change.Path)
fmt.Printf(" %s\n", change.Description)
}
}
ContractValidator validator = new ContractValidator("localhost:9550");
// Register both versions
validator.registerSchemaWithVersion("petstore", "./openapi-v1.json", "1.0.0");
validator.registerSchemaWithVersion("petstore", "./openapi-v2.json", "2.0.0");
// Compare
CompareResult result = validator.compareSchemas("petstore", "1.0.0", "2.0.0");
if (!result.isCompatible()) {
System.out.println("Breaking changes detected:");
for (BreakingChange change : result.getBreakingChanges()) {
System.out.printf("- [%s] %s %s%n", change.getType(), change.getMethod(), change.getPath());
System.out.printf(" %s%n", change.getDescription());
}
}
Deployment Safety (can-i-deploy)
The can-i-deploy check combines breaking change detection with the consumer registry to determine if a new schema version is safe to deploy.
How It Works
┌─────────────────────┐
│ New Schema v2.0.0 │
└─────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ CVT Server │
│ │
│ 1. Detect breaking changes from v1.0.0 │
│ 2. Look up registered consumers │
│ 3. Check which consumers use affected │
│ endpoints/fields │
│ 4. Return safety assessment │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────┐
│ Safe / Unsafe │
│ + Affected list │
└─────────────────────┘
CLI Usage
# Basic usage
cvt can-i-deploy --schema petstore --version 2.0.0 --env prod
# With server address
cvt can-i-deploy --schema petstore --version 2.0.0 --env prod --server cvt.internal:9550
# JSON output for CI/CD
cvt can-i-deploy --schema petstore --version 2.0.0 --env prod --json
SDK Usage
- Node.js
- Python
- Go
- Java
const result = await validator.canIDeploy("petstore", "2.0.0", "prod");
if (result.safeToDeploy) {
console.log("Safe to deploy!");
} else {
console.error("UNSAFE:", result.summary);
// Show breaking changes
for (const change of result.breakingChanges) {
console.error(`- [${change.type}] ${change.description}`);
}
// Show affected consumers
for (const consumer of result.affectedConsumers) {
if (consumer.willBreak) {
console.error(`Consumer ${consumer.consumerId} will break!`);
console.error(
` Uses endpoints: ${consumer.relevantChanges.map((c) => c.path).join(", ")}`,
);
}
}
}
result = validator.can_i_deploy("petstore", "2.0.0", "prod")
if result["safe_to_deploy"]:
print("Safe to deploy!")
else:
print(f"UNSAFE: {result['summary']}")
# Show breaking changes
for change in result["breaking_changes"]:
print(f"- [{change['type']}] {change['description']}")
# Show affected consumers
for consumer in result["affected_consumers"]:
if consumer["will_break"]:
print(f"Consumer {consumer['consumer_id']} will break!")
paths = ", ".join(c["path"] for c in consumer["relevant_changes"])
print(f" Uses endpoints: {paths}")
result, err := validator.CanIDeploy(ctx, "petstore", "2.0.0", "prod")
if err != nil {
log.Fatal(err)
}
if result.SafeToDeploy {
fmt.Println("Safe to deploy!")
} else {
fmt.Println("UNSAFE:", result.Summary)
// Show breaking changes
for _, change := range result.BreakingChanges {
fmt.Printf("- [%s] %s\n", change.Type, change.Description)
}
// Show affected consumers
for _, consumer := range result.AffectedConsumers {
if consumer.WillBreak {
fmt.Printf("Consumer %s will break!\n", consumer.ConsumerID)
var paths []string
for _, c := range consumer.RelevantChanges {
paths = append(paths, c.Path)
}
fmt.Printf(" Uses endpoints: %s\n", strings.Join(paths, ", "))
}
}
}
CanIDeployResult result = validator.canIDeploy("petstore", "2.0.0", "prod");
if (result.isSafeToDeploy()) {
System.out.println("Safe to deploy!");
} else {
System.err.println("UNSAFE: " + result.getSummary());
// Show breaking changes
for (BreakingChange change : result.getBreakingChanges()) {
System.err.printf("- [%s] %s%n", change.getType(), change.getDescription());
}
// Show affected consumers
for (ConsumerImpact consumer : result.getAffectedConsumers()) {
if (consumer.isWillBreak()) {
System.err.printf("Consumer %s will break!%n", consumer.getConsumerId());
String paths = consumer.getRelevantChanges().stream()
.map(BreakingChange::getPath)
.collect(Collectors.joining(", "));
System.err.printf(" Uses endpoints: %s%n", paths);
}
}
}
Output: Safe to Deploy
Deployment Safety Check
=======================
Schema: petstore
Version: 2.0.0
Environment: prod
✅ SAFE TO DEPLOY
No breaking changes detected that would affect registered consumers.
Output: Unsafe to Deploy
Deployment Safety Check
=======================
Schema: petstore
Version: 2.0.0
Environment: prod
❌ UNSAFE TO DEPLOY
Breaking changes in v2.0.0:
- RESPONSE_SCHEMA_CHANGED: GET /pet/{petId} response removed 'status'
- ENDPOINT_REMOVED: DELETE /pet/{petId}
Affected consumers in prod:
├── order-service v2.1.0
│ Schema version: 1.0.0
│ Impact: BREAKING
│ Affected by:
│ - GET /pet/{petId}
│
├── notification-service v1.5.0
│ Schema version: 1.0.0
│ Impact: BREAKING
│ Affected by:
│ - DELETE /pet/{petId}
│
└── billing-service v1.0.0
Schema version: 1.0.0
Impact: None
Safe consumers: 1/3
Affected consumers: 2/3
Recommendation: Coordinate with order-service and notification-service teams before deploying.
Environment Promotion
CVT tracks consumers per environment, enabling safe promotion workflows.
Development to Staging to Production
- Node.js
- Python
- Go
- Java
const validator = new ContractValidator("cvt.internal:9550");
// 1. Register consumer in dev after tests pass
await validator.registerConsumer({
consumerId: "order-service",
consumerVersion: "2.1.0",
schemaId: "petstore",
schemaVersion: "1.0.0",
environment: "dev", // Start in dev
usedEndpoints: [
{ method: "GET", path: "/pet/{petId}", usedFields: ["id", "name"] },
],
});
// 2. Promote to staging after dev validation
await validator.registerConsumer({
consumerId: "order-service",
consumerVersion: "2.1.0",
schemaId: "petstore",
schemaVersion: "1.0.0",
environment: "staging", // Promote to staging
usedEndpoints: [
{ method: "GET", path: "/pet/{petId}", usedFields: ["id", "name"] },
],
});
// 3. Finally promote to production
await validator.registerConsumer({
consumerId: "order-service",
consumerVersion: "2.1.0",
schemaId: "petstore",
schemaVersion: "1.0.0",
environment: "prod", // Production ready
usedEndpoints: [
{ method: "GET", path: "/pet/{petId}", usedFields: ["id", "name"] },
],
});
validator.close();
from cvt_sdk import ContractValidator, RegisterConsumerOptions, EndpointUsage
validator = ContractValidator('cvt.internal:9550')
endpoints = [
EndpointUsage(method='GET', path='/pet/{petId}', used_fields=['id', 'name'])
]
# 1. Register consumer in dev after tests pass
validator.register_consumer(RegisterConsumerOptions(
consumer_id='order-service',
consumer_version='2.1.0',
schema_id='petstore',
schema_version='1.0.0',
environment='dev', # Start in dev
used_endpoints=endpoints
))
# 2. Promote to staging after dev validation
validator.register_consumer(RegisterConsumerOptions(
consumer_id='order-service',
consumer_version='2.1.0',
schema_id='petstore',
schema_version='1.0.0',
environment='staging', # Promote to staging
used_endpoints=endpoints
))
# 3. Finally promote to production
validator.register_consumer(RegisterConsumerOptions(
consumer_id='order-service',
consumer_version='2.1.0',
schema_id='petstore',
schema_version='1.0.0',
environment='prod', # Production ready
used_endpoints=endpoints
))
validator.close()
validator, _ := cvt.NewValidator("cvt.internal:9550")
defer validator.Close()
ctx := context.Background()
endpoints := []cvt.EndpointUsage{
{Method: "GET", Path: "/pet/{petId}", UsedFields: []string{"id", "name"}},
}
// 1. Register consumer in dev after tests pass
validator.RegisterConsumer(ctx, cvt.RegisterConsumerOptions{
ConsumerID: "order-service",
ConsumerVersion: "2.1.0",
SchemaID: "petstore",
SchemaVersion: "1.0.0",
Environment: "dev", // Start in dev
UsedEndpoints: endpoints,
})
// 2. Promote to staging after dev validation
validator.RegisterConsumer(ctx, cvt.RegisterConsumerOptions{
ConsumerID: "order-service",
ConsumerVersion: "2.1.0",
SchemaID: "petstore",
SchemaVersion: "1.0.0",
Environment: "staging", // Promote to staging
UsedEndpoints: endpoints,
})
// 3. Finally promote to production
validator.RegisterConsumer(ctx, cvt.RegisterConsumerOptions{
ConsumerID: "order-service",
ConsumerVersion: "2.1.0",
SchemaID: "petstore",
SchemaVersion: "1.0.0",
Environment: "prod", // Production ready
UsedEndpoints: endpoints,
})
import io.github.sahina.sdk.ContractValidator;
import io.github.sahina.sdk.RegisterConsumerOptions;
import io.github.sahina.sdk.EndpointUsage;
import java.util.List;
ContractValidator validator = new ContractValidator("cvt.internal:9550");
List<EndpointUsage> endpoints = List.of(
new EndpointUsage("GET", "/pet/{petId}", List.of("id", "name"))
);
// 1. Register consumer in dev after tests pass
validator.registerConsumer(RegisterConsumerOptions.builder()
.consumerId("order-service")
.consumerVersion("2.1.0")
.schemaId("petstore")
.schemaVersion("1.0.0")
.environment("dev") // Start in dev
.usedEndpoints(endpoints)
.build());
// 2. Promote to staging after dev validation
validator.registerConsumer(RegisterConsumerOptions.builder()
.consumerId("order-service")
.consumerVersion("2.1.0")
.schemaId("petstore")
.schemaVersion("1.0.0")
.environment("staging") // Promote to staging
.usedEndpoints(endpoints)
.build());
// 3. Finally promote to production
validator.registerConsumer(RegisterConsumerOptions.builder()
.consumerId("order-service")
.consumerVersion("2.1.0")
.schemaId("petstore")
.schemaVersion("1.0.0")
.environment("prod") // Production ready
.usedEndpoints(endpoints)
.build());
validator.close();
Checking Safety Before Promotion
# Before promoting petstore v2.0.0 to production
cvt can-i-deploy --schema petstore --version 2.0.0 --env prod
# Output will show if any prod consumers will break
CI/CD Integration
PR Check for Schema Changes
name: API Compatibility Check
on:
pull_request:
paths:
- "api/openapi.json"
jobs:
check-breaking-changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get previous schema
run: git show origin/main:api/openapi.json > /tmp/old-schema.json
- name: Check for breaking changes
run: |
cvt compare --old /tmp/old-schema.json --new ./api/openapi.json
Pre-Deploy Safety Gate
deploy:
runs-on: ubuntu-latest
steps:
- name: Check deployment safety
run: |
result=$(cvt can-i-deploy \
--schema ${{ env.SCHEMA_ID }} \
--version ${{ github.sha }} \
--env prod \
--server ${{ secrets.CVT_SERVER }} \
--json)
if [ $(echo $result | jq '.safe_to_deploy') != "true" ]; then
echo "DEPLOYMENT BLOCKED: Breaking changes would affect consumers"
echo $result | jq '.affected_consumers[] | select(.will_break) | .consumer_id'
exit 1
fi
- name: Deploy
if: success()
run: ./deploy.sh
Handling Breaking Changes
When you need to make a breaking change, you have several options:
1. Coordinate with Consumers
- Run
can-i-deployto identify affected consumers - Contact consumer teams with timeline
- Wait for consumers to update their code
- Deploy once all consumers are ready
2. Version Your API
Keep both versions available during transition:
# Old version still available
/api/v1/pet/{petId}
# New version with breaking changes
/api/v2/pet/{petId}
3. Feature Flags
Gradually roll out changes:
- Node.js
- Python
- Go
- Java
app.get("/pet/:petId", (req, res) => {
const pet = getPet(req.params.petId);
if (req.headers["x-api-version"] === "2") {
// New format (breaking)
res.json({ id: pet.id, details: { name: pet.name, category: pet.category } });
} else {
// Old format (compatible)
res.json({ id: pet.id, name: pet.name, status: pet.status });
}
});
@app.get("/pet/{pet_id}")
def get_pet(pet_id: str, request: Request):
pet = get_pet_by_id(pet_id)
if request.headers.get("x-api-version") == "2":
# New format (breaking)
return {"id": pet.id, "details": {"name": pet.name, "category": pet.category}}
else:
# Old format (compatible)
return {"id": pet.id, "name": pet.name, "status": pet.status}
func getPetHandler(w http.ResponseWriter, r *http.Request) {
pet := getPet(chi.URLParam(r, "petId"))
if r.Header.Get("X-API-Version") == "2" {
// New format (breaking)
json.NewEncoder(w).Encode(map[string]any{
"id": pet.ID,
"details": map[string]any{"name": pet.Name, "category": pet.Category},
})
} else {
// Old format (compatible)
json.NewEncoder(w).Encode(map[string]any{
"id": pet.ID, "name": pet.Name, "status": pet.Status,
})
}
}
@GetMapping("/pet/{petId}")
public ResponseEntity<Map<String, Object>> getPet(
@PathVariable String petId,
@RequestHeader(value = "X-API-Version", required = false) String apiVersion
) {
Pet pet = petService.getPet(petId);
if ("2".equals(apiVersion)) {
// New format (breaking)
return ResponseEntity.ok(Map.of(
"id", pet.getId(),
"details", Map.of("name", pet.getName(), "category", pet.getCategory())
));
} else {
// Old format (compatible)
return ResponseEntity.ok(Map.of(
"id", pet.getId(), "name", pet.getName(), "status", pet.getStatus()
));
}
}
4. Deprecation Period
- Add deprecation headers to old endpoints
- Set a sunset date
- Monitor usage metrics
- Remove after deprecation period
Best Practices
For API Producers
- Run comparisons in CI - Catch breaking changes before they're merged
- Use can-i-deploy before production - Make it a required gate
- Version your API - Major versions for breaking changes
- Communicate deprecations - Give consumers time to adapt
For API Consumers
- Register your dependencies - Enable can-i-deploy checks
- Specify used fields - Help producers understand impact
- Test against schema - Catch issues before producers deploy
- Monitor deprecation warnings - Plan updates proactively
Related Documentation
- Consumer Testing Guide - Register as a consumer
- Producer Testing Guide - Validate your API
- CI/CD Integration Guide - Pipeline examples
- Configuration Reference - Environment variables and settings
- CLI Reference - Command-line options
- API Reference - Message types