Integration & APIs/
Lesson
AI pitfall
AI frequently suggests changes that it classifies as "non-breaking" but are actually breaking. Changing a field from 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.

Good to know
The subtly breaking changes are the dangerous ones because they pass code review. Changing a default sort order, narrowing an enum, or making an optional field required, these look harmless but break consumers who depended on the old behavior. Always check for these in addition to the obvious structural changes.

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/purchases

Subtly 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 breaks
02

Breaking vs non-breaking changes

ChangeBreaking?Why
Add a new field to responseNoConsumers should ignore unknown fields
Remove a field from responseYesConsumers may depend on it
Add a new optional request parameterNoOld requests still valid
Make an optional parameter requiredYesOld requests now fail validation
Add a new endpointNoDoes not affect existing endpoints
Remove an endpointYesConsumers get 404
Rename a fieldYesSame as remove + add from consumer perspective
Change field type (number to string)YesType comparisons and parsing break
Add a new enum value to responseNoConsumers should handle unknown values
Remove an enum value from responseYesConsumers matching on that value break
Add a new enum value to request validationNoDoes not affect old values
Remove an accepted enum value from requestYesOld requests with that value fail
Change default sort orderYesConsumers assuming order break
Change pagination defaultsPossiblyIf consumers hardcode page assumptions
Add rate limitingPossiblyDepends on consumer request patterns
Change error response shapeYesError-handling code breaks
Edge case
Changing an error response format is a breaking change that most teams forget about. If consumers parse { 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.
03

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 - safe
04

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

  1. Consumer defines a "pact", a contract describing what it expects from the API
  2. 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
  3. 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.

05

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.