number to string looks like a simple type change, but consumer code using strict equality (id === 123) silently fails. Always apply AI's breaking change analysis to your actual consumer code, not to abstract rules.A breaking changeWhat is breaking change?A modification to an API that causes existing code using it to stop working, such as renaming a field or changing a response format. is anything that makes existing consumer code stop working. It sounds simple, but the definition is trickier than it looks. Some changes are obviously breaking (deleting an endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users.), while others are subtle enough to slip through code review and only break in production when a specific client hits the changed behavior.
The goal is not to never make breaking changes, that would freeze your APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. forever. The goal is to make them deliberately, communicate them clearly, and give consumers time to adapt.
What constitutes a breaking changeWhat is breaking change?A modification to an API that causes existing code using it to stop working, such as renaming a field or changing a response format.
Obviously breaking
These are the changes every developer recognizes as dangerous:
// BREAKING: Removing a field from the response
// Before (v1)
{ "id": 1, "name": "Alice", "email": "alice@example.com" }
// After - consumer code doing user.email now crashes
{ "id": 1, "name": "Alice" }// BREAKING: Changing a field type
// Before: id is a number
{ "id": 123, "name": "Alice" }
// After: id is a string - parseInt(user.id) still works,
// but user.id === 123 now fails
{ "id": "123", "name": "Alice" }// BREAKING: Renaming an endpoint
// Before
GET /api/users/:id/orders
// After - all consumer code with the old URL gets 404
GET /api/users/:id/purchasesSubtly breaking
These changes pass code review because they look harmless. They are not.
// BREAKING: Changing a field from optional to required in request body
// Before: name was optional
POST /users { "email": "alice@example.com" }
// After: name is now required - old requests get 400 Bad Request
POST /users { "email": "alice@example.com", "name": "Alice" }// BREAKING: Narrowing allowed enum values
// Before: status accepted "active", "inactive", "pending"
// After: "pending" is no longer valid
// Consumers sending { status: "pending" } now get validation errors// BREAKING: Changing error response format
// Before
{ "error": "Not found" }
// After - consumers parsing error.message now get undefined
{ "code": "NOT_FOUND", "message": "Resource not found" }// BREAKING: Changing default sort order
// Before: GET /users returned users sorted by created_at ASC
// After: returns users sorted by name ASC
// Consumer code that assumes first result is the oldest user breaksBreaking vs non-breaking changes
| Change | Breaking? | Why |
|---|---|---|
| Add a new field to response | No | Consumers should ignore unknown fields |
| Remove a field from response | Yes | Consumers may depend on it |
| Add a new optional request parameter | No | Old requests still valid |
| Make an optional parameter required | Yes | Old requests now fail validation |
| Add a new endpoint | No | Does not affect existing endpoints |
| Remove an endpoint | Yes | Consumers get 404 |
| Rename a field | Yes | Same as remove + add from consumer perspective |
| Change field type (number to string) | Yes | Type comparisons and parsing break |
| Add a new enum value to response | No | Consumers should handle unknown values |
| Remove an enum value from response | Yes | Consumers matching on that value break |
| Add a new enum value to request validation | No | Does not affect old values |
| Remove an accepted enum value from request | Yes | Old requests with that value fail |
| Change default sort order | Yes | Consumers assuming order break |
| Change pagination defaults | Possibly | If consumers hardcode page assumptions |
| Add rate limiting | Possibly | Depends on consumer request patterns |
| Change error response shape | Yes | Error-handling code breaks |
{ error: "Not found" } and you change to { code: "NOT_FOUND", message: "Resource not found" }, their error-handling code breaks silently. Always treat error response shapes as part of your API contract.The additive-only strategy
The safest APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. evolution strategy is simple: only add, never remove. New fields, new endpoints, new optional parameters, all safe. Old fields stay forever (or until a major version bump).
// V1 response
{
"id": 1,
"name": "Alice",
"email": "alice@example.com"
}
// Evolved response - additive only
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"profile": { // NEW: added, not replacing anything
"avatar": "https://...",
"bio": "Developer"
},
"createdAt": "2024-01-15" // NEW: added
}The old consumer code still works perfectly, it just ignores profile and createdAt. This is why API design guides always say: consumers must ignore unknown fields. If your consumers follow this rule, you can evolve freely as long as you only add.
To enforce this on the consumer side, avoid strict schemaWhat is schema?A formal definition of the structure your data must follow - which fields exist, what types they have, and which are required. validation that rejects unknown fields:
// BAD: rejects responses with extra fields
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string()
}).strict(); // .strict() will throw on unknown fields
// GOOD: ignores extra fields gracefully
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string()
}); // Zod strips unknown fields by default - safeContract testingWhat is contract testing?Automated tests that verify a service still satisfies the expectations of every consumer, catching breaking API changes before deployment. with Pact
Even with the best intentions, breaking changes slip through. Contract testing catches them automatically by verifying that your APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. still satisfies the expectations of every consumer.
The flow works like this:
- Consumer defines a "pact", a contract describing what it expects from the API
- ProviderWhat is provider?A wrapper component that makes data available to all components nested inside it without passing props manually. (your API) runs these pacts as tests to verify it still fulfills them
- If a pact fails, you know you broke something before deploying
// Consumer side: define what you expect
const { Pact } = require('@pact-foundation/pact');
const provider = new Pact({
consumer: 'MobileApp',
provider: 'UserAPI'
});
describe('User API contract', () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
it('returns user with email field', async () => {
await provider.addInteraction({
state: 'user 123 exists',
uponReceiving: 'a request for user 123',
withRequest: {
method: 'GET',
path: '/v2/users/123'
},
willRespondWith: {
status: 200,
body: {
id: 123,
name: like('Alice'), // any string
email: like('a@b.com') // must be present
}
}
});
// Run your consumer code against the mock
const user = await fetchUser(123);
expect(user.email).toBeDefined();
});
});// Provider side: verify you still satisfy the contract
const { Verifier } = require('@pact-foundation/pact');
describe('Pact verification', () => {
it('satisfies MobileApp expectations', async () => {
await new Verifier({
providerBaseUrl: 'http://localhost:3001',
pactUrls: ['./pacts/mobile-app-user-api.json'],
stateHandlers: {
'user 123 exists': async () => {
await db.users.create({ id: 123, name: 'Alice', email: 'a@b.com' });
}
}
}).verifyProvider();
});
});If you remove the email field from your response, the MobileApp pact fails, and you catch the break before it reaches production.
Strategies for handling unavoidable breaking changes
Sometimes you genuinely need to make a breaking changeWhat is breaking change?A modification to an API that causes existing code using it to stop working, such as renaming a field or changing a response format.. When that happens, follow this pattern:
1. Support both old and new simultaneously
app.get('/v2/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json({
id: user.id,
name: user.name,
email: user.email, // Keep old field
contactEmail: user.email, // Add new field name
// Eventually remove email in v3
});
});2. Use response headers to signal changes
app.get('/v2/users/:id', (req, res) => {
res.setHeader('X-API-Deprecation', 'field:email;use:contactEmail;sunset:2025-06-01');
// ... response
});3. Log which consumers use deprecated features
function trackDeprecatedUsage(req, feature) {
const clientId = req.headers['x-client-id'] || 'unknown';
metrics.increment('api.deprecated_usage', {
feature,
client: clientId,
endpoint: req.path
});
}This gives you data to know when all consumers have migrated, and confidence to remove the old field.