This is Part 2 of "API Evolution Chronicles" - real stories from a decade of API transformations across multiple companies.
The Problem: REST API Hell#
By 2020, our REST API had become a monster.
REST API Hell: The Nightmare Numbers
Endpoints
Across 23 different services
API Calls per View
Average to load a single dashboard
Mobile Crashes
Related to API timeouts
Onboarding Time
Just to understand API structure
Support Tickets
Were 'How do I get data for X?' questions
Developer Satisfaction
Before the GraphQL migration
Our mobile team lead summed it up perfectly: "I spend more time figuring out which endpoints to call than actually building features."
Something had to change.
The GraphQL Evaluation: Not Just Following Trends#
GraphQL was the hot new thing in 2020, but I've seen too many teams adopt technologies because they're trendy, not because they solve real problems.
Here's how we evaluated GraphQL against our specific pain points:
Our Core Problems vs. GraphQL Solutions#
Problem 1: Over-fetching and Under-fetching
- REST: Mobile apps downloaded 10x more data than needed
- GraphQL: Query exactly what you need
Problem 2: Multiple Round Trips
- REST: Dashboard = 7 separate API calls
- GraphQL: Dashboard = 1 query with nested relationships
Problem 3: API Documentation Complexity
- REST: 247 endpoints to document and maintain
- GraphQL: Self-documenting schema with introspection
Problem 4: Frontend-Backend Coupling
- REST: Every UI change required backend endpoint modifications
- GraphQL: Frontend queries evolve independently
Problem 5: Developer Onboarding
- REST: Weeks to understand endpoint relationships
- GraphQL: Explore entire API through GraphQL Playground in hours
The Decision Framework#
I created this evaluation matrix (you can adapt it for your tech decisions):
REST vs GraphQL: Our Evaluation
Our legacy implementation
✅ Pros:
- •Mature ecosystem
- •Team familiarity
- •Simple caching
❌ Cons:
- •7 calls per view
- •247 endpoints to maintain
- •Poor mobile performance
Modern data fetching
✅ Pros:
- •Single query
- •Self-documenting
- •Precise data fetching
❌ Cons:
- •Learning curve
- •Complex caching
- •N+1 query risks
Decision Matrix:
| Criteria | Weight | REST Score | GraphQL Score | Weighted Impact |
|---|---|---|---|---|
| Developer Experience | 30% | 3/10 | 8/10 | GraphQL +1.5 |
| Performance (Mobile) | 25% | 4/10 | 9/10 | GraphQL +1.25 |
| Maintenance Complexity | 20% | 3/10 | 7/10 | GraphQL +0.8 |
| Learning Curve | 15% | 8/10 | 5/10 | REST +0.45 |
| Ecosystem Maturity | 10% | 9/10 | 6/10 | REST +0.3 |
| Total | 100% | 4.7/10 | 7.4/10 | GraphQL Wins |
The decision: GraphQL for new development, gradual REST migration.
The Migration Strategy: The Strangler Fig Pattern#
Instead of a big-bang rewrite, we used the "Strangler Fig" pattern—gradually replacing REST endpoints with GraphQL while maintaining backward compatibility.
Phase 1: GraphQL Foundation (Months 1-3)#
Week 1-2: Schema Design
type User {
id: ID!
email: String!
profile: UserProfile!
projects: [Project!]!
activities(limit: Int = 10): [Activity!]!
}
type Project {
id: ID!
name: String!
description: String
owner: User!
collaborators: [User!]!
tasks(status: TaskStatus): [Task!]!
}
type Task {
id: ID!
title: String!
status: TaskStatus!
assignee: User
project: Project!
comments: [Comment!]!
}
Week 3-4: Resolver Implementation Started with our most-used data (User, Project, Task entities):
const resolvers = {
Query: {
user: async (_, { id }) => {
// Calls existing REST services
return await userService.findById(id);
},
projects: async (_, { userId }) => {
return await projectService.findByUser(userId);
},
},
User: {
projects: async (user) => {
return await projectService.findByUser(user.id);
},
activities: async (user, { limit }) => {
return await activityService.findByUser(user.id, limit);
},
},
};
Month 2: Developer Tools Setup
- GraphQL Playground for API exploration
- Schema documentation auto-generation
- Query complexity analysis and rate limiting
- Caching strategy with DataLoader
Month 3: First GraphQL Endpoint Launched user dashboard endpoint—the perfect test case because:
- High traffic (good performance testing)
- Complex data relationships (showcases GraphQL benefits)
- Used by all our client applications
Phase 2: Parallel Development (Months 4-8)#
The Rule: All new features use GraphQL, maintain REST for existing functionality.
Results after 4 months:
- 47% of API traffic through GraphQL
- API calls per mobile session dropped from 12 to 4
- Dashboard load time improved by 65%
- Developer satisfaction jumped from 5.2/10 to 7.8/10
Phase 3: Strategic Migration (Months 9-12)#
Identified REST endpoints to migrate based on:
- Usage analytics (highest traffic first)
- Developer pain points (most complained-about endpoints)
- Maintenance overhead (endpoints with most bugs/changes)
Migration Priority Matrix:
| Endpoint | Usage (req/day) | Developer Complaints | Maintenance Issues | Priority |
|---|---|---|---|---|
/api/dashboard | 50K | High | High | 1 (Done Month 3) |
/api/projects | 30K | High | Medium | 2 |
/api/user/profile | 25K | Medium | High | 3 |
/api/search | 15K | High | Medium | 4 |
The Technical Implementation: Lessons Learned#
Lesson 1: Schema Design is Everything#
Wrong approach (our first attempt):
# Mirroring REST endpoints in GraphQL
type Query {
getUser(id: ID!): User
getUserProjects(userId: ID!): [Project]
getUserActivities(userId: ID!): [Activity]
}
Right approach (after refactoring):
# Thinking in graphs, not endpoints
type Query {
user(id: ID!): User
}
type User {
projects: [Project!]!
activities(limit: Int, after: String): ActivityConnection!
}
The difference: The second approach lets clients describe their data needs instead of making multiple calls.
Lesson 2: N+1 Problem is Real (And Solvable)#
The Problem:
query {
projects {
name
owner {
name
email
}
}
}
Without proper optimization, this query would:
- Fetch all projects (1 query)
- Fetch owner for each project (N queries)
Total: N+1 database queries for N projects.
The Solution: DataLoader
const userLoader = new DataLoader(async (userIds) => {
const users = await User.findByIds(userIds);
return userIds.map((id) => users.find((user) => user.id === id));
});
const resolvers = {
Project: {
owner: async (project) => {
return await userLoader.load(project.ownerId);
},
},
};
Result: N+1 queries became 2 queries total.
Lesson 3: Query Complexity Management#
The Problem: Clients could write expensive queries:
query ExpensiveQuery {
users {
projects {
tasks {
comments {
author {
projects {
tasks {
# This could go on forever...
}
}
}
}
}
}
}
}
The Solution: Query Complexity Analysis
import costAnalysis from 'graphql-cost-analysis';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
costAnalysis({
maximumCost: 1000,
onComplete: (cost) => {
console.log(`Query cost: ${cost}`);
},
}),
],
});
Schema with cost annotations:
type User {
projects: [Project!]! @cost(complexity: 10, multipliers: ["first"])
activities: [Activity!]! @cost(complexity: 5, multipliers: ["limit"])
}
Lesson 4: Caching Strategy#
REST caching was simple: Cache by URL
GraphQL caching required more thought: Same endpoint, different queries
Our solution: Normalized cache with Apollo
const cache = new InMemoryCache({
typePolicies: {
User: {
fields: {
projects: {
merge(existing = [], incoming) {
return [...existing, ...incoming];
},
},
},
},
},
});
The Migration Challenges (And How We Solved Them)#
Challenge 1: Team Learning Curve#
Problem: 23 developers needed to learn GraphQL while maintaining existing features.
Solution:
Week 1
GraphQL fundamentals workshop (4 hours)
Week 2
Hands-on schema design session
Week 3
Resolver implementation training
Week 4
Performance optimization techniques
Monthly
"GraphQL Office Hours" for ongoing questions
Result: 95% of developers comfortable with GraphQL within 6 weeks.
Challenge 2: Client Application Updates#
Problem: Mobile apps and web frontends needed GraphQL integration.
Solution:
- Apollo Client for React applications
- Apollo iOS for mobile apps
- Code generation for type safety
- Migration guides with side-by-side REST vs GraphQL examples
Example migration guide:
// OLD: REST approach
const loadDashboard = async () => {
const user = await api.get('/user/me');
const projects = await api.get(`/user/${user.id}/projects`);
const activities = await api.get(`/user/${user.id}/activities`);
return { user, projects, activities };
};
// NEW: GraphQL approach
const DASHBOARD_QUERY = gql`
query Dashboard {
me {
name
email
projects {
id
name
tasksCount
}
activities(limit: 10) {
id
type
createdAt
}
}
}
`;
const { data } = useQuery(DASHBOARD_QUERY);
Challenge 3: Monitoring and Debugging#
Problem: REST monitoring tools didn't work with GraphQL's single endpoint.
Solution:
- Apollo Studio for query analytics and performance monitoring
- Custom metrics for query complexity and error tracking
- Query whitelisting in production for security
- Schema versioning strategy for breaking changes
The Results: Numbers Don't Lie#
After 12 months of migration:
Performance Improvements#
- API response time: 340ms → 204ms average (40% improvement)
- Mobile data usage: 2.3MB → 0.8MB per session (65% reduction)
- Database queries per request: 8.7 → 5.2 average (40% reduction)
- Cache hit rate: 23% → 67% (normalized caching benefit)
Developer Experience Improvements#
- API onboarding time: 3-4 weeks → 2-3 days
- Support tickets: 247/month → 173/month (30% reduction)
- Developer satisfaction: 5.2/10 → 8.4/10
- Feature development time: 40% faster (fewer API calls to implement)
Business Impact#
- Mobile app stability: 40% crash reduction
- Developer adoption: 85% prefer GraphQL for new features
- API documentation: 90% self-service (thanks to introspection)
- Development velocity: 35% increase in shipped features
The Gotchas: What We Wish We'd Known#
1. File Uploads Are Still Painful#
GraphQL doesn't handle file uploads natively. We kept REST endpoints for file operations:
// Still using REST for this
POST /api/upload/avatar
Content-Type: multipart/form-data
// Then update via GraphQL
mutation UpdateUserAvatar($avatarUrl: String!) {
updateUser(input: { avatarUrl: $avatarUrl }) {
id
avatarUrl
}
}
2. Real-time Updates Need WebSocket/Subscriptions#
subscription TaskUpdates($projectId: ID!) {
taskUpdated(projectId: $projectId) {
id
title
status
assignee {
name
}
}
}
Setting up GraphQL subscriptions added complexity, but real-time updates were worth it.
3. Query Optimization Requires Domain Knowledge#
Auto-generated resolvers are convenient but inefficient. We needed custom resolvers for complex queries:
// Bad: Auto-generated resolver (N+1 problem)
projects: (user) => Project.findByUserId(user.id);
// Good: Optimized resolver
projects: async (user, args, { userProjectsLoader }) => {
return await userProjectsLoader.load(user.id);
};
4. Schema Evolution is Harder Than REST Versioning#
With REST, you could version endpoints independently. GraphQL schema changes affect the entire API surface:
# Adding optional fields: Safe
type User {
name: String!
email: String!
avatarUrl: String # Safe addition
}
# Removing fields: Breaking change
type User {
name: String!
# email removed - breaks existing queries
}
# Changing field types: Breaking change
type User {
id: ID! # Was String!, now ID! - breaking
name: String!
}
Solution: Deprecation warnings and gradual migration:
type User {
name: String!
email: String! @deprecated(reason: "Use contactInfo.email")
contactInfo: ContactInfo!
}
The Framework: REST to GraphQL Migration Checklist#
Pre-Migration Assessment
Identify high-traffic endpoints
What hurts most?
Measure current API performance
Who needs GraphQL training?
Migration Planning
Model relationships, not endpoints
High impact, low risk first
Keep REST during transition
DataLoader, caching, complexity analysis
Implementation Phase
GraphQL Playground, documentation
Prevent expensive queries
Normalized cache for GraphQL
GraphQL-specific analytics
Post-Migration
Measure improvements
Validate experience improvements
Migration guides and best practices
Advanced GraphQL techniques
When NOT to Migrate to GraphQL#
GraphQL isn't always the answer. Stick with REST when:
- Simple CRUD operations with minimal relationships
- Public APIs where you can't control client queries
- Heavy file upload/download workflows
- Team lacks GraphQL expertise and timeline is tight
- Existing REST API works well and developer feedback is positive
The Personal Takeaway#
This migration taught me that API design is really UX design for developers. The technical implementation matters, but the developer experience transformation was the real success.
Before: Developers spent 60% of their time figuring out API calls
After: Developers spend 5% of their time on API integration, 95% building features
That ratio change was worth every hour of migration effort.
Next in the series: "API Design Patterns That Scale: From Startup to Enterprise"
Planning your own GraphQL migration? Share your challenges in the comments—I'll help you avoid our mistakes.
Want the complete migration playbook and code examples? Subscribe for detailed implementation guides.
