DEV Community

Cover image for Backward Compatibility: A Practitioner's Guide to Evolving APIs Without Breaking Clients
Serif COLAKEL
Serif COLAKEL

Posted on

Backward Compatibility: A Practitioner's Guide to Evolving APIs Without Breaking Clients

How to version REST endpoints, evolve GraphQL schemas, and ship mobile updates — without leaving existing users behind.


Why It Matters

Every deployed API is a contract. Every mobile binary installed on a user's phone is a snapshot of that contract. The moment you change a response shape, rename a field, or remove an endpoint, you risk breaking clients you cannot force-update.

Backward compatibility is not about avoiding change. It is about managing change so that existing consumers continue to work while the system evolves underneath them.

This article covers three layers: REST API versioning, GraphQL schema evolution, and mobile app compatibility (React Native & Flutter). Each section delivers concrete patterns and production-ready code.


Part I — REST APIs

The Versioning Decision

REST APIs have four common versioning strategies. Each comes with tradeoffs:

Strategy Example Pros Cons
URI path /api/v1/users Simple, cacheable, widely understood Implies the resource itself changed; cache duplication
Query parameter /api/users?version=1 Easy to implement, can default to latest Complicates routing and cache keys
Custom header X-API-Version: 1 Keeps URIs clean Hard to test in browsers, invisible in logs
Content negotiation Accept: application/vnd.app.v2+json Fine-grained, per-resource versioning Complex to test, requires custom media types

Rule of thumb: Use URI versioning for public APIs. Use header-based versioning for internal services where you control all clients.

Non-Breaking vs. Breaking Changes

Not every change requires a new version:

✅ Non-breaking (no version bump needed):
   - Adding a new field to a response
   - Adding a new optional query parameter
   - Adding a new endpoint
   - Returning a new enum value (if clients handle unknowns)

❌ Breaking (requires a new version):
   - Removing or renaming a field
   - Changing a field's type (string → number)
   - Making an optional parameter required
   - Changing the response structure
Enter fullscreen mode Exit fullscreen mode

Pattern: Side-by-Side Versioning

When a breaking change is unavoidable, run both versions simultaneously:

const express = require("express");
const app = express();

// v1 — original contract: flat array of names
app.get("/api/v1/users", (req, res) => {
  res.json({ users: ["John", "Ali"] });
});

// v2 — new contract: array of objects with richer data
app.get("/api/v2/users", (req, res) => {
  res.json({
    users: [
      { name: "John", age: 30 },
      { name: "Ali", age: 25 },
    ],
  });
});

// Unversioned path redirects to latest
app.get("/api/users", (req, res) => {
  res.redirect("/api/v2/users");
});
Enter fullscreen mode Exit fullscreen mode

The key: v1 stays alive until tracking data confirms no clients are using it.

HATEOAS: Self-Describing APIs

The most underused REST principle for backward compatibility is HATEOAS — embedding links that tell the client what actions are available:

{
  "departmentId": 10,
  "name": "Engineering",
  "links": [
    { "rel": "employees", "href": "/departments/10/employees", "type": "GET" },
    { "rel": "budget",    "href": "/departments/10/budget",    "type": "GET" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

When you move the employees endpoint to a new URL, clients that follow the href instead of hardcoding the path do not break. The server controls discovery.

HTTP Status Codes as Communication

Use status codes intentionally during deprecation:

Code When to use
200 OK Standard success
201 Created New resource created
301 Moved Permanently Endpoint has moved to a new URL
410 Gone Endpoint existed but has been permanently removed
422 Unprocessable Entity Client sent a deprecated field that's no longer accepted

A 410 Gone is more informative than a 404 Not Found — it tells the client "this existed, it's intentionally removed, stop calling it."


Part II — GraphQL

The Versionless Philosophy

GraphQL fundamentally rejects API versioning. The official stance: continuously evolve the schema rather than creating v1 and v2 endpoints.

This works because of a key GraphQL property: clients only receive the fields they ask for. Adding a new field to a type doesn't affect any existing query — the client simply doesn't select it.

Safe Changes (Non-Breaking)

# ✅ Adding a new nullable field — completely safe
type User {
  id: ID!
  name: String!
  email: String
  birthDate: String   # new — old queries ignore it
}

# ✅ Adding a new type — no existing queries reference it
type Address {
  street: String!
  city: String!
}

# ✅ Adding an optional argument to a query
type Query {
  users(limit: Int, filter: String): [User!]!
  #                  ^^^^^^ new optional arg
}
Enter fullscreen mode Exit fullscreen mode

Dangerous Changes (Breaking)

# ❌ Removing a field — queries that select `age` will fail
type User {
  id: ID!
  name: String!
  # age: Int!  ← removed
}

# ❌ Renaming a field — same as remove + add
type User {
  id: ID!
  name: String!
  yearsOld: Int!   # was `age` — breaks every query using `age`
}

# ❌ Making a nullable field required
type Query {
  users(limit: Int!): [User!]!
  #            ^^^^ was optional, now required — breaks callers
}
Enter fullscreen mode Exit fullscreen mode

Pattern: Deprecate, Then Remove

When you need to replace a field, use the @deprecated directive:

type User {
  id: ID!
  name: String!
  age: Int @deprecated(reason: "Use 'birthDate' instead. Will be removed 2025-06-01.")
  birthDate: String
}
Enter fullscreen mode Exit fullscreen mode

The resolver supports both simultaneously during the migration period:

const resolvers = {
  User: {
    age(parent) {
      // Still works, but logs a warning for monitoring
      console.warn("Deprecated field 'age' was queried.");
      return parent.age;
    },
    birthDate(parent) {
      return parent.birthDate;
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Clients discover the deprecation through introspection — every GraphQL field exposes isDeprecated and deprecationReason. IDE tooling (Apollo Studio, GraphiQL) shows deprecation warnings inline.

Federation: Independent Evolution

In a federated GraphQL architecture, different teams own different subgraphs. This structure naturally supports backward-compatible evolution:

flowchart LR
    Client -- Query --> Gateway
    subgraph UserService["User Subgraph"]
      UserDB[(Users)]
    end
    subgraph OrderService["Order Subgraph"]
      OrderDB[(Orders)]
    end
    Gateway -- fetchUser --> UserService
    Gateway -- fetchOrder --> OrderService
Enter fullscreen mode Exit fullscreen mode

When the User subgraph adds a birthDate field, the Order subgraph is unaffected. The gateway merges the updated schema automatically. No coordination required for additive changes.


Part III — Mobile: React Native

Version Semantics

React Native uses 0.x.y versioning where minor bumps (0.78 → 0.79) may contain breaking changes, and patch bumps are safe fixes. Always read the release blog before upgrading.

Pattern: API Fallback

When a backend introduces a new API version, mobile apps should try the new version first and fall back gracefully:

async function fetchUsers() {
  let response = await fetch("https://api.example.com/v2/users");

  if (response.status === 404) {
    // v2 not available on this server — fall back to v1
    response = await fetch("https://api.example.com/v1/users");
  }

  const data = await response.json();
  return data.users;
}
Enter fullscreen mode Exit fullscreen mode

This is critical for mobile apps where users may not update immediately. The binary in their pocket might be weeks old, calling a backend that has moved forward.

Pattern: Feature Flags for Controlled Rollout

Feature flags decouple deployment from release. Ship the code to all users, but activate it for a subset:

import { Text, View } from "react-native";
import { useFeature } from "posthog-react-native";

function HomeScreen() {
  const showNewUI = useFeature("new_ui_enabled");

  return (
    <View>
      {showNewUI
        ? <Text>New Experience</Text>
        : <Text>Classic Experience</Text>}
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

This pattern enables:

  • Instant rollback — flip the flag, no App Store review needed
  • Gradual rollout — 5% → 25% → 100%
  • A/B testing — measure performance before committing

Platform-Specific Code

React Native's Platform API handles iOS/Android differences cleanly:

import { Platform, Text } from "react-native";

function Greeting() {
  const text = Platform.select({
    ios: "Hello, iOS user!",
    android: "Hello, Android user!",
    default: "Hello!",
  });

  return <Text>{text}</Text>;
}
Enter fullscreen mode Exit fullscreen mode

Part IV — Mobile: Flutter

Flutter's Compatibility Promise

Flutter enforces backward compatibility rigorously. The team runs an open-source test suite against user applications before releasing any version. If a change would break user tests, it's announced in advance on flutter-announce with migration instructions.

Deprecated APIs are kept alive for multiple releases — never removed without warning.

Pattern: Storage Migration with Fallback

When migrating from SharedPreferences to flutter_secure_storage, don't switch instantly. Bridge the gap:

import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

Future<String> getAuthToken() async {
  final secureStorage = FlutterSecureStorage();

  // Try the new storage first
  String? token = await secureStorage.read(key: 'auth_token');

  if (token == null) {
    // Fall back to legacy storage for users who haven't migrated
    final prefs = await SharedPreferences.getInstance();
    token = prefs.getString('auth_token');

    if (token != null) {
      // One-time migration: move to secure storage
      await secureStorage.write(key: 'auth_token', value: token);
      await prefs.remove('auth_token');
    }
  }

  return token ?? '';
}
Enter fullscreen mode Exit fullscreen mode

The pattern: read from new, fall back to old, migrate on first access.

Feature Flags with Firebase Remote Config

import 'package:firebase_remote_config/firebase_remote_config.dart';

Future<void> checkFeatures() async {
  final remoteConfig = FirebaseRemoteConfig.instance;
  await remoteConfig.fetchAndActivate();

  bool showStats = remoteConfig.getBool('user_statistics_enabled');

  if (showStats) {
    Navigator.pushNamed(context, '/statistics');
  }
}
Enter fullscreen mode Exit fullscreen mode

SDK Version Constraints

pubspec.yaml enforces version boundaries:

environment:
  sdk: ">=3.0.0 <4.0.0"

dependencies:
  shared_preferences: ^2.2.0
  flutter_secure_storage: ^9.0.0
Enter fullscreen mode Exit fullscreen mode

Before upgrading, always run:

flutter pub outdated   # Check for outdated dependencies
dart fix --apply       # Apply automated fixes for deprecations
Enter fullscreen mode Exit fullscreen mode

Part V — The Deprecation Lifecycle

Every deprecation — whether REST, GraphQL, or mobile — should follow a structured timeline:

timeline
    title Deprecation Lifecycle
    T+0 : Planning — Decide to evolve. Design the replacement.
    T+1m : Announcement — Mark deprecated in docs, changelog, and code.
    T+4m : Migration — Both versions live. Track adoption metrics.
    T+10m : Sunset — Old version returns 410 Gone or is removed.
Enter fullscreen mode Exit fullscreen mode
Phase Actions Typical Duration
Planning Design the replacement. Ensure it's backward-compatible. Write migration guide. 2–4 weeks
Announcement Add @deprecated / deprecated: true. Emit deprecation warnings in responses. Notify via changelog, email, Slack. Day 1
Migration Run old and new in parallel. Monitor usage metrics. Reach out to lagging consumers. 3–6 months
Sunset Remove old code. Return 410 Gone or drop the field. Archive documentation. Final

[!WARNING]
The most common failure mode: removing deprecated paths before consumers have migrated. Always track usage before sunsetting.


Comparison: REST vs. GraphQL

Dimension REST GraphQL
Versioning URI, header, or content negotiation Versionless — continuous schema evolution
Breaking changes Removing endpoints, changing response shape Removing fields, renaming fields
Safe changes Adding endpoints, adding response fields Adding fields, adding types
Deprecation OpenAPI deprecated flag, Sunset header @deprecated directive, introspection
Discovery HATEOAS links Schema introspection
Data model Fixed response shape per endpoint Client selects exactly the fields it needs

Checklist

Before shipping any change that modifies a public surface:

  • [ ] Is this change additive only?
  • [ ] Are removed paths marked @deprecated with migration guidance?
  • [ ] Does the read path handle all historical data shapes?
  • [ ] Do tests cover the old shape → new shape transformation?
  • [ ] Is the change in the changelog with a migration guide?
  • [ ] Is there a concrete sunset date?
  • [ ] Are usage metrics in place to track migration progress?

References

  • GraphQL Official Documentation — Schema Design & Deprecation
  • Apollo Documentation — Schema Evolution Best Practices
  • RESTfulAPI.net — API Versioning Strategies
  • React Native Release Policy & Upgrade Guide
  • Flutter Breaking Changes Policy & Documentation
  • ConfigCat / Flagsmith / PostHog — Feature Flag Best Practices

Happy coding! 🚀

Top comments (0)