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
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");
});
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" }
]
}
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
}
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
}
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
}
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;
},
},
};
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
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;
}
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>
);
}
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>;
}
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 ?? '';
}
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');
}
}
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
Before upgrading, always run:
flutter pub outdated # Check for outdated dependencies
dart fix --apply # Apply automated fixes for deprecations
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.
| 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
@deprecatedwith 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)