REST to GraphQL Migrator
Incrementally migrate REST APIs to GraphQL without breaking existing clients.
Core Workflow
-
Analyze REST endpoints: Document existing API
-
Design GraphQL schema: Map REST to types
-
Create REST data source: Wrap existing endpoints
-
Implement resolvers: Connect to REST
-
Migrate incrementally: One endpoint at a time
-
Deprecate REST: Gradual sunset
Migration Strategies
Strategy Comparison
Strategy Best For Complexity
Wrapper Quick start, no backend changes Low
Gradual Large APIs, production systems Medium
Rewrite Greenfield opportunity High
REST Data Source Wrapper
Apollo RESTDataSource
// datasources/users.datasource.ts import { RESTDataSource } from '@apollo/datasource-rest';
export class UsersAPI extends RESTDataSource { override baseURL = process.env.REST_API_URL;
// Add auth header override willSendRequest(_path: string, request: AugmentedRequest) { request.headers['Authorization'] = this.context.token; }
// GET /api/users async getUsers(params?: { page?: number; limit?: number }) { const query = new URLSearchParams(); if (params?.page) query.set('page', String(params.page)); if (params?.limit) query.set('limit', String(params.limit));
return this.get<User[]>(`/api/users?${query}`);
}
// GET /api/users/:id
async getUser(id: string) {
return this.get<User>(/api/users/${id});
}
// POST /api/users async createUser(input: CreateUserInput) { return this.post<User>('/api/users', { body: input }); }
// PATCH /api/users/:id
async updateUser(id: string, input: UpdateUserInput) {
return this.patch<User>(/api/users/${id}, { body: input });
}
// DELETE /api/users/:id
async deleteUser(id: string) {
await this.delete(/api/users/${id});
return true;
}
// GET /api/users/:id/posts
async getUserPosts(userId: string) {
return this.get<Post[]>(/api/users/${userId}/posts);
}
}
Map REST to GraphQL Types
// REST Response interface RESTUser { id: number; user_name: string; email_address: string; created_at: string; profile_image_url: string | null; }
// GraphQL Type (camelCase, proper types) interface User { id: string; username: string; email: string; createdAt: Date; avatar: string | null; }
// Transformer function transformUser(restUser: RESTUser): User { return { id: String(restUser.id), username: restUser.user_name, email: restUser.email_address, createdAt: new Date(restUser.created_at), avatar: restUser.profile_image_url, }; }
Data Source with Caching
// datasources/products.datasource.ts export class ProductsAPI extends RESTDataSource { override baseURL = process.env.REST_API_URL;
// Cache for 1 hour private cacheOptions = { ttl: 3600 };
async getProduct(id: string) {
const data = await this.get<RESTProduct>(/api/products/${id}, {
cacheOptions: this.cacheOptions,
});
return transformProduct(data);
}
async getProducts(filters: ProductFilters) { const params = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { if (value !== undefined) params.set(key, String(value)); });
const data = await this.get<RESTProduct[]>(`/api/products?${params}`);
return data.map(transformProduct);
}
// Bust cache on mutation
async updateProduct(id: string, input: UpdateProductInput) {
const data = await this.patch<RESTProduct>(/api/products/${id}, {
body: transformToREST(input),
});
// Invalidate cache
this.delete(`/api/products/${id}`);
return transformProduct(data);
} }
GraphQL Schema Design
Schema from REST Endpoints
Map REST endpoints to GraphQL
REST: GET /api/users
REST: GET /api/users/:id
REST: POST /api/users
REST: PATCH /api/users/:id
REST: DELETE /api/users/:id
type User { id: ID! username: String! email: String! avatar: String createdAt: DateTime!
Nested resource: GET /api/users/:id/posts
posts: [Post!]!
Nested resource: GET /api/users/:id/comments
comments: [Comment!]! }
type Query {
GET /api/users
users(page: Int, limit: Int): [User!]!
GET /api/users/:id
user(id: ID!): User }
type Mutation {
POST /api/users
createUser(input: CreateUserInput!): User!
PATCH /api/users/:id
updateUser(id: ID!, input: UpdateUserInput!): User!
DELETE /api/users/:id
deleteUser(id: ID!): Boolean! }
input CreateUserInput { username: String! email: String! password: String! }
input UpdateUserInput { username: String email: String avatar: String }
Resolvers with REST Backend
// resolvers/user.resolvers.ts import { Resolvers } from '../generated/types';
export const userResolvers: Resolvers = { Query: { users: async (_, { page, limit }, { dataSources }) => { const users = await dataSources.usersAPI.getUsers({ page, limit }); return users.map(transformUser); },
user: async (_, { id }, { dataSources }) => {
try {
const user = await dataSources.usersAPI.getUser(id);
return transformUser(user);
} catch (error) {
if (error.extensions?.response?.status === 404) {
return null;
}
throw error;
}
},
},
Mutation: { createUser: async (_, { input }, { dataSources }) => { const user = await dataSources.usersAPI.createUser( transformInputToREST(input) ); return transformUser(user); },
updateUser: async (_, { id, input }, { dataSources }) => {
const user = await dataSources.usersAPI.updateUser(
id,
transformInputToREST(input)
);
return transformUser(user);
},
deleteUser: async (_, { id }, { dataSources }) => {
return dataSources.usersAPI.deleteUser(id);
},
},
User: { // Resolve nested resources posts: async (parent, _, { dataSources }) => { const posts = await dataSources.usersAPI.getUserPosts(parent.id); return posts.map(transformPost); },
comments: async (parent, _, { dataSources }) => {
const comments = await dataSources.usersAPI.getUserComments(parent.id);
return comments.map(transformComment);
},
}, };
DataLoader for N+1 Prevention
// loaders/user.loader.ts import DataLoader from 'dataloader'; import { UsersAPI } from '../datasources/users.datasource';
export function createUserLoader(usersAPI: UsersAPI) { return new DataLoader<string, User>(async (ids) => { // Batch REST calls or use batch endpoint if available // Option 1: Parallel individual calls const users = await Promise.all( ids.map((id) => usersAPI.getUser(id).catch(() => null)) ); return ids.map((id) => users.find((u) => u?.id === id) || null);
// Option 2: Use batch endpoint if available
// const users = await usersAPI.getUsersByIds([...ids]);
// return ids.map(id => users.find(u => u.id === id) || null);
}); }
// Usage in resolver User: { author: async (parent, _, { loaders }) => { return loaders.users.load(parent.authorId); }, }
Incremental Migration
Phase 1: Wrapper Layer
// Start with GraphQL wrapping REST // All data still flows through REST API
const server = new ApolloServer({ typeDefs, resolvers, dataSources: () => ({ usersAPI: new UsersAPI(), postsAPI: new PostsAPI(), commentsAPI: new CommentsAPI(), }), });
Phase 2: Direct Database Access
// Migrate critical paths to direct DB // Keep REST as fallback
const userResolvers: Resolvers = { Query: { user: async (_, { id }, { dataSources, db }) => { // Try direct DB first const user = await db.user.findUnique({ where: { id } }); if (user) return user;
// Fallback to REST
return dataSources.usersAPI.getUser(id);
},
}, };
Phase 3: Full Migration
// All data from database // REST endpoints deprecated
const userResolvers: Resolvers = { Query: { user: async (_, { id }, { db }) => { return db.user.findUnique({ where: { id }, }); },
users: async (_, { page = 1, limit = 20 }, { db }) => {
return db.user.findMany({
skip: (page - 1) * limit,
take: limit,
});
},
},
User: { posts: async (parent, _, { loaders }) => { return loaders.postsByAuthor.load(parent.id); }, }, };
Error Handling
// Map REST errors to GraphQL errors import { GraphQLError } from 'graphql';
function handleRESTError(error: any): never { const status = error.extensions?.response?.status; const body = error.extensions?.response?.body;
switch (status) { case 400: throw new GraphQLError(body?.message || 'Bad request', { extensions: { code: 'BAD_USER_INPUT' }, });
case 401:
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
case 403:
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
case 404:
throw new GraphQLError('Resource not found', {
extensions: { code: 'NOT_FOUND' },
});
case 409:
throw new GraphQLError(body?.message || 'Conflict', {
extensions: { code: 'CONFLICT' },
});
default:
throw new GraphQLError('Internal server error', {
extensions: { code: 'INTERNAL_SERVER_ERROR' },
});
} }
// Usage in resolver async getUser(id: string) { try { return await this.dataSources.usersAPI.getUser(id); } catch (error) { handleRESTError(error); } }
Schema Stitching
Combine Multiple REST Services
// Stitch multiple REST backends import { stitchSchemas } from '@graphql-tools/stitch';
const usersSchema = makeExecutableSchema({ typeDefs: usersTypeDefs, resolvers: usersResolvers, });
const productsSchema = makeExecutableSchema({ typeDefs: productsTypeDefs, resolvers: productsResolvers, });
const ordersSchema = makeExecutableSchema({ typeDefs: ordersTypeDefs, resolvers: ordersResolvers, });
const gatewaySchema = stitchSchemas({ subschemas: [ { schema: usersSchema }, { schema: productsSchema }, { schema: ordersSchema }, ], typeMergingOptions: { // Configure type merging }, });
Deprecation Strategy
type Query {
Old endpoint - deprecated
getUser(id: ID!): User @deprecated(reason: "Use user(id:) instead")
New endpoint
user(id: ID!): User }
type User {
Deprecated field with migration path
userName: String @deprecated(reason: "Use username instead") username: String! }
Best Practices
-
Start with wrapper: Don't rewrite, wrap
-
Migrate incrementally: One endpoint at a time
-
Use DataLoader: Prevent N+1 queries
-
Transform data shapes: Improve API design
-
Add caching: Reduce REST API calls
-
Handle errors properly: Map REST errors to GraphQL
-
Deprecate gradually: Give clients time to migrate
-
Monitor both: Track REST and GraphQL usage
Output Checklist
Every REST to GraphQL migration should include:
-
REST endpoints documented
-
GraphQL schema designed
-
RESTDataSource implementations
-
Data transformers (REST ↔ GraphQL)
-
DataLoader for batching
-
Error handling and mapping
-
Caching strategy
-
Incremental migration plan
-
Deprecation annotations
-
Client migration guide