REST to GRAPHQL
Migrate a REST API to GraphQL while maintaining backward compatibility
You are an expert in migrating REST APIs to GraphQL for flexible querying, reduced over-fetching, and unified data access.
## Key Points
1. **Schema Design** — model the GraphQL schema from the client's perspective, not as a 1:1 mirror of REST endpoints.
2. **Gateway Layer** — stand up a GraphQL server that resolves queries by calling existing REST services internally.
3. **Client Migration** — move frontend consumers to GraphQL one screen/feature at a time.
4. **Direct Resolvers** — once clients are migrated, replace REST-backed resolvers with direct database/service calls.
5. **Deprecate REST** — remove legacy endpoints after all consumers have transitioned.
- Design the schema around client use cases, not around existing REST endpoint shapes.
- Use DataLoader for every resolver that fetches related entities to avoid N+1 problems.
- Implement cursor-based pagination from the start — it scales better than offset pagination.
- Add persisted queries in production to prevent arbitrary query abuse.
- Monitor resolver performance with tracing (Apollo Studio, GraphQL Mesh, or custom middleware).
- Version the schema through deprecation annotations (`@deprecated(reason: "Use newField")`), not URL versioning.
- **1:1 REST mapping** — wrapping every endpoint as a single query produces a poor schema; think in terms of a graph of related entities.
## Quick Example
```typescript
// Phase 2: direct database access instead of REST call
user: async (_: unknown, { id }: { id: string }) => {
return db.query('SELECT id, name, email FROM users WHERE id = $1', [id]);
},
```skilldb get migration-patterns-skills/REST to GRAPHQLFull skill: 175 linesREST to GraphQL — Migration Patterns
You are an expert in migrating REST APIs to GraphQL for flexible querying, reduced over-fetching, and unified data access.
Core Philosophy
Overview
GraphQL does not have to replace REST overnight. The proven approach is to run GraphQL alongside existing REST endpoints, delegate to existing service logic, and migrate consumers gradually. The REST API remains available until all clients have switched.
Migration Strategy
- Schema Design — model the GraphQL schema from the client's perspective, not as a 1:1 mirror of REST endpoints.
- Gateway Layer — stand up a GraphQL server that resolves queries by calling existing REST services internally.
- Client Migration — move frontend consumers to GraphQL one screen/feature at a time.
- Direct Resolvers — once clients are migrated, replace REST-backed resolvers with direct database/service calls.
- Deprecate REST — remove legacy endpoints after all consumers have transitioned.
Step-by-Step Guide
1. Define the GraphQL schema
# schema.graphql
type User {
id: ID!
name: String!
email: String!
posts(first: Int = 10): [Post!]!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
createdAt: DateTime!
}
type Query {
user(id: ID!): User
posts(authorId: ID, first: Int = 20, after: String): PostConnection!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
}
2. Set up GraphQL server wrapping REST
// resolvers.ts
import axios from 'axios';
const REST_BASE = process.env.REST_API_URL;
export const resolvers = {
Query: {
user: async (_: unknown, { id }: { id: string }) => {
const { data } = await axios.get(`${REST_BASE}/users/${id}`);
return data;
},
posts: async (_: unknown, { authorId, first, after }: any) => {
const params = new URLSearchParams();
if (authorId) params.set('author_id', authorId);
if (first) params.set('limit', String(first));
if (after) params.set('cursor', after);
const { data } = await axios.get(`${REST_BASE}/posts?${params}`);
return data;
},
},
User: {
posts: async (parent: { id: string }, { first }: { first: number }) => {
const { data } = await axios.get(`${REST_BASE}/users/${parent.id}/posts?limit=${first}`);
return data;
},
},
Mutation: {
createPost: async (_: unknown, { input }: any) => {
const { data } = await axios.post(`${REST_BASE}/posts`, input);
return data;
},
},
};
3. Add DataLoader to prevent N+1 queries
import DataLoader from 'dataloader';
function createLoaders() {
return {
userLoader: new DataLoader<string, User>(async (ids) => {
const { data } = await axios.get(`${REST_BASE}/users?ids=${ids.join(',')}`);
const userMap = new Map(data.map((u: User) => [u.id, u]));
return ids.map(id => userMap.get(id) ?? new Error(`User ${id} not found`));
}),
};
}
// In resolver
Post: {
author: (parent, _, { loaders }) => loaders.userLoader.load(parent.authorId),
}
4. Migrate a client page from REST to GraphQL
// Before — multiple REST calls
const user = await fetch(`/api/users/${id}`).then(r => r.json());
const posts = await fetch(`/api/users/${id}/posts?limit=5`).then(r => r.json());
// After — single GraphQL query
const { data } = await client.query({
query: gql`
query UserWithPosts($id: ID!) {
user(id: $id) {
name
email
posts(first: 5) {
title
createdAt
}
}
}
`,
variables: { id },
});
5. Replace REST-backed resolvers with direct data access
// Phase 2: direct database access instead of REST call
user: async (_: unknown, { id }: { id: string }) => {
return db.query('SELECT id, name, email FROM users WHERE id = $1', [id]);
},
Best Practices
- Design the schema around client use cases, not around existing REST endpoint shapes.
- Use DataLoader for every resolver that fetches related entities to avoid N+1 problems.
- Implement cursor-based pagination from the start — it scales better than offset pagination.
- Add persisted queries in production to prevent arbitrary query abuse.
- Monitor resolver performance with tracing (Apollo Studio, GraphQL Mesh, or custom middleware).
- Version the schema through deprecation annotations (
@deprecated(reason: "Use newField")), not URL versioning.
Common Pitfalls
- 1:1 REST mapping — wrapping every endpoint as a single query produces a poor schema; think in terms of a graph of related entities.
- N+1 queries — without DataLoader, nested resolvers fire separate requests for each item in a list.
- Over-fetching in resolvers — even though the client requests specific fields, resolvers may still fetch entire objects from REST. Use field-level resolvers or projections to limit backend work.
- No rate limiting — a single GraphQL query can fan out to hundreds of backend calls. Implement query depth limits and complexity analysis.
- Ignoring error handling — REST status codes do not map cleanly to GraphQL errors. Use structured error extensions (
extensions.code) for client-actionable error types.
Anti-Patterns
Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.
Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.
Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.
Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.
Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.
Install this skill directly: skilldb add migration-patterns-skills
Related Skills
Class to Functional React
Convert React class components to functional components with hooks
Cra to Nextjs
Migrate a Create React App project to Next.js for server-side rendering and file-based routing
Javascript to Typescript
Migrate a JavaScript codebase to TypeScript incrementally with minimal disruption
Jest to Vitest
Migrate a test suite from Jest to Vitest for faster execution and native ESM support
Monolith to Microservices
Decompose a monolithic application into microservices using the strangler fig pattern
SQL to Nosql
Migrate from a relational SQL database to a NoSQL document or key-value store