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 use200 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 OpenAPIdeprecated 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! 🚀
답글 남기기