Foundations API Design

Backward Compatibility

Evolving systems without breaking existing consumers — the patterns and strategies that enable independent deployment and continuous delivery.

⏱ 6 min read

What it is

Backward compatibility (also called backwards compatibility) means that a newer version of a system can still work correctly with older clients or consumers without requiring those clients to update. If service B (a consumer) was built against v1 of service A's API, service A can release v2 and service B will continue to function correctly without any changes — that is backward compatibility. The converse — a new client working with an old server — is forward compatibility. Both are required for truly independent deployment in a microservices or distributed system environment.

Postel's Law, also known as the Robustness Principle, encapsulates the spirit of compatible API design: "Be conservative in what you send, be liberal in what you accept." APIs should be strict about what they produce (never remove or rename fields that consumers depend on) and lenient about what they consume (tolerate unknown fields, missing optional fields, and value variations). This principle directly enables backward and forward compatibility by ensuring both producer and consumer can evolve independently without requiring synchronized deployments.

Why it exists

In monolithic systems, all code is deployed together — compatibility between components is a build-time concern, not a runtime one. In distributed systems, microservices, mobile applications, and third-party API integrations, different versions of producer and consumer run simultaneously and must interoperate. A mobile app cannot be force-updated the moment a backend API changes. A downstream microservice may not be ready to deploy at the same time as an upstream service. Breaking changes in an API that does not maintain backward compatibility force all consumers to update simultaneously — a coordinated deployment that is expensive, risky, and at odds with independent team velocity.

Backward compatibility is foundational to continuous delivery and zero-downtime deployments. Blue-green deployments, canary releases, and rolling updates all require that the new version of a service interoperates with both old and new clients during the transition period. Without backward compatibility, any deployment window involves a hard cutover where old and new versions cannot coexist — effectively requiring maintenance windows.

When to use

  • Public APIs — any API consumed by third-party clients or mobile apps where you cannot control the upgrade cycle of all consumers.
  • Microservices communicating over the network — services must interoperate across independent deployment cycles.
  • Event schemas in event-driven systems — events published to a message bus may be consumed by multiple services at different versions.
  • Database schemas in systems with multiple consumers — changing a column name in a shared database breaks all services that reference the old name.
  • Any API that will exist long enough for consumers to build on it — practically all production APIs.

When not to use

  • Internal, tightly coupled systems deployed atomically as a single unit — when all code is always deployed together, backward compatibility has no runtime value (though it is still good practice for libraries).
  • Early-stage APIs with no external consumers — rapid iteration may require breaking changes; use a pre-v1 signal (v0, beta label) to set expectations.
  • APIs with a small, controlled set of consumers who can all be updated simultaneously under a migration plan — though even here, the expand-contract pattern reduces deployment risk.

Typical architecture

The expand-contract pattern (also called parallel change) is the canonical technique for making breaking changes backward-compatibly, particularly for database schema migrations. It proceeds in three phases: Expand (add the new column/field/endpoint alongside the old), Migrate (update all consumers to use the new), and Contract (remove the old once all consumers have migrated). This allows zero-downtime migration with no synchronized deployment required.


SAFE (additive) changes — backward compatible:
  ✓ Add a new optional field to a response
  ✓ Add a new endpoint or API operation
  ✓ Add a new value to an enum in a response (consumers must tolerate unknown values)
  ✓ Add a new optional request parameter with a sensible default
  ✓ Relax a validation constraint (accept more inputs)

BREAKING changes — NOT backward compatible:
  ✗ Remove a field from a response
  ✗ Rename a field in a request or response
  ✗ Change a field's type (string → integer)
  ✗ Make an optional field required
  ✗ Change the semantics of a field (same name, different meaning)
  ✗ Remove an endpoint
  ✗ Tighten a validation constraint (reject previously-valid inputs)

Expand-Contract Pattern (database example):
Phase 1 — EXPAND
  ┌──────────────┐    v1 reads: full_name column
  │ Old column:  │    v2 reads: first_name + last_name
  │  full_name   │    v2 writes: both old and new columns
  │ New columns: │
  │  first_name  │◄── New service version deployed
  │  last_name   │    Old service still running against old column
  └──────────────┘

Phase 2 — MIGRATE
  All consumers updated to use first_name/last_name.
  Backfill script populates new columns from old column.
  Old column kept populated for remaining old consumers.

Phase 3 — CONTRACT
  ┌──────────────┐    All consumers now use first_name/last_name.
  │ New columns: │    Drop full_name column.
  │  first_name  │◄── Safe to remove; no consumers reference it.
  │  last_name   │
  └──────────────┘

API Versioning Strategies:
  URL Path:    GET /v1/users/{id}  vs  GET /v2/users/{id}
  Header:      Accept: application/vnd.myapi+json;version=2
  Query Param: GET /users/{id}?version=2
  Subdomain:   GET https://v2.api.example.com/users/{id}

Serialization format backward compatibility:
  Protobuf: field numbers are permanent identifiers.
            Never reuse a field number. Only add new fields.
            Field 1 in v1 schema must mean the same thing in v20.
  Avro:     reader schema resolves against writer schema via registry.
            Add fields with defaults; never remove or rename fields.
  JSON:     no schema enforcement by default; discipline required.
            Use a schema registry (e.g., AsyncAPI, OpenAPI) + linting.
          

Pros and cons

Pros

  • Enables independent deployment — services and consumers can be upgraded on different schedules without coordinated cutover.
  • Reduces deployment risk — no synchronized "big bang" deployment required when a producer changes its API.
  • Supports continuous delivery — every commit can be deployed without requiring consumer readiness.
  • Enables API longevity — well-designed backward-compatible APIs can serve consumers for years without forcing migrations.

Cons

  • API surface accumulates debt — additive-only evolution means old, suboptimal fields live forever, increasing cognitive load for new consumers.
  • Multiple versions increase maintenance cost — running v1 and v2 simultaneously requires supporting two code paths.
  • Expand-contract migrations add operational complexity — the three-phase process requires careful tracking of which consumers have migrated.
  • Forward compatibility is harder — consumers must be written to tolerate unknown fields, which requires discipline and is easy to forget.

Implementation notes

Consumer-driven contract testing (Pact): The most robust way to ensure backward compatibility is to test it automatically in CI. Pact is a consumer-driven contract testing framework: each consumer service defines a "contract" — a specification of the exact requests it makes and responses it expects. The provider service's CI pipeline runs these consumer contracts against its latest code to verify it does not break any consumer. This inverts the typical integration test model: instead of the provider testing against its own assumptions, it tests against its consumers' actual requirements. Breaking changes are caught before deployment, not after.

Semantic versioning for APIs: Follow semver conventions: MAJOR version increments signal breaking changes (v1 → v2), MINOR increments add backward-compatible features (v1.1), PATCH increments fix bugs without changing the API contract. Publish a deprecation policy — for example, "we will maintain the previous major version for 12 months after a new major is released." Communicate deprecation via API response headers (Deprecation: true, Sunset: Sat, 01 Jan 2026 00:00:00 GMT) and via documentation.

Protobuf field number discipline: Protocol Buffers use field numbers — not field names — as the wire identity of each field. This means a field can be renamed without breaking compatibility (the number stays the same). However, if a field number is reused for a different field, deserialization will produce corrupted data. The rule is absolute: once a field number is used, it is permanently reserved, even if the field is removed. Use the reserved keyword to document retired field numbers and names: reserved 3, 4; reserved "old_name";.

Common failure modes

  • Renaming a field "for clarity": A developer renames user_id to userId (snake_case to camelCase) without providing the old name as an alias. All consumers that read user_id receive null instead of an error — a silent data loss that may not surface in testing.
  • Making an optional field required: Adding a validation that a previously optional field is now required causes all clients that don't send the field to receive 400 Bad Request errors — potentially breaking all existing integrations simultaneously.
  • Removing an enum value: A service removes a status enum value it no longer produces. A consumer that has a database constraint or switch statement on that value may fail — or worse, silently ignore records with the removed status.
  • Skipping the contract phase: The expand phase adds a new column, consumers migrate to it, but no one removes the old column. Months later, a new developer does not know which column is canonical and starts writing to the old one again — reintroducing the problem the migration was supposed to solve.

Decision checklist

  • Is this API change additive only (new fields, new endpoints, new optional params)? If not, it is a breaking change requiring a new major version.
  • Have all existing consumers been audited to confirm none of them depend on the field or behavior being removed or changed?
  • Is consumer-driven contract testing (Pact or equivalent) in place to automatically catch breaking changes in CI before deployment?
  • For database schema changes: are we following expand-contract (add new, migrate consumers, remove old) rather than direct rename/removal?
  • For serialization formats (Protobuf, Avro): have we followed schema evolution rules (never reuse field numbers, only add with defaults)?
  • Is there a published deprecation policy and timeline for old API versions, communicated to all consumers?
  • Are deprecation headers (Deprecation, Sunset) included in responses from deprecated API versions?

Example use cases

  • Stripe has maintained backward compatibility on their v1 API for over a decade — fields are never removed, only deprecated with ample migration time. This stability is a competitive advantage: developers trust that code written against Stripe's API today will still work in five years.
  • A mobile banking app backend uses expand-contract for every database schema change — the iOS app version deployed 6 months ago and the current version must both work against the same database, making direct column renames impossible.
  • A company using Kafka for inter-service events uses Confluent Schema Registry with Avro. All schemas are validated against the registry's backward compatibility rules before publishing — a schema change that would break existing consumers is rejected at build time.
  • A team adopts Pact contract testing after a production incident where a backend rename of email_address to email broke the mobile app for 2 hours. Contract tests would have caught the breaking change before the PR was merged.
  • Architecture Decision Records — breaking API changes and migration strategies should be documented in ADRs for future team context.
  • Idempotency — idempotency key behavior is part of the API contract and must be preserved across versions.
  • Architecture Principles — "design for evolvability" is a principle that directly drives backward compatibility practices.

Further reading