# REST to GraphQL Migration Guide --- ## When to Use This Guide **Migrate to GraphQL when:** - Multiple round-trips required for complex UI views - Over-fetching or under-fetching data is problematic - Supporting diverse client needs (mobile, web, desktop) - Team boundaries require federated API architecture - Real-time subscriptions are core requirements - Type safety across client-server boundary needed - API versioning complexity is growing **Success indicators:** - Client applications make many sequential REST calls - Different clients need different data shapes - Mobile apps suffer from bandwidth constraints - Frontend teams wait on backend API changes - Multiple REST versions exist concurrently ## When NOT to Use GraphQL **Stick with REST when:** - Simple CRUD operations with stable clients - File upload/download is primary use case - HTTP caching is critical (CDN, browser cache) - Team lacks GraphQL expertise and training budget - Existing REST API is well-designed and sufficient - Third-party integrations require REST endpoints - Query complexity would create security risks **Warning signs:** - Team of 1-2 developers (operational overhead) - Primarily server-to-server communication - Static content delivery is the main requirement - No complex data relationship navigation needed --- ## Concept Mapping: REST to GraphQL | REST Concept | GraphQL Equivalent | Notes | |--------------|-------------------|-------| | GET /users | Query users | Read operations | | GET /users/:id | Query user(id: ID!) | Single entity fetch | | POST /users | Mutation createUser | Create operations | | PUT /users/:id | Mutation updateUser | Update operations | | DELETE /users/:id | Mutation deleteUser | Delete operations | | PATCH /users/:id | Mutation updateUserPartial | Partial updates | | Query params (?filter=...) | Field arguments | Filtering/sorting | | URL path segments | Nested field selection | Data relationships | | Multiple endpoints | Single query | Eliminate round-trips | | Webhook callbacks | Subscriptions | Real-time updates | | HTTP status codes | Errors array + data | Partial success model | | API versioning | Schema evolution | Deprecation over versions | | /users?include=posts | users { posts } | Eager loading control | | Offset pagination | Cursor-based connections | Relay specification | | Accept header | Operation selection | Content negotiation | | OAuth/JWT tokens | Context authentication | Same auth patterns | --- ## Pattern 1: GET Endpoints to Queries ### REST Endpoint ```typescript // GET /api/users/:id interface UserResponse { id: string; name: string; email: string; created_at: string; posts: Array<{ id: string; title: string; published: boolean; }>; } app.get('/api/users/:id', async (req, res) => { const user = await db.users.findById(req.params.id); const posts = await db.posts.findByUserId(user.id); // N+1 risk res.json({ id: user.id, name: user.name, email: user.email, created_at: user.createdAt.toISOString(), posts: posts.map(p => ({ id: p.id, title: p.title, published: p.published })) }); }); ``` ### GraphQL Schema ```graphql type User { id: ID! name: String! email: String! createdAt: DateTime! posts: [Post!]! } type Post { id: ID! title: String! published: Boolean! author: User! } type Query { user(id: ID!): User users(filter: UserFilter, limit: Int = 20): [User!]! } input UserFilter { nameContains: String createdAfter: DateTime } scalar DateTime ``` ### GraphQL Resolver with DataLoader ```typescript import DataLoader from 'dataloader'; import { IResolvers } from '@graphql-tools/utils'; // Batch loading to prevent N+1 queries const createPostsByUserIdLoader = (db: Database) => new DataLoader(async (userIds) => { const posts = await db.posts.findByUserIds([...userIds]); // Group posts by userId const postsByUserId = userIds.map(id => posts.filter(post => post.userId === id) ); return postsByUserId; }); const createUserByIdLoader = (db: Database) => new DataLoader(async (ids) => { const users = await db.users.findByIds([...ids]); // Maintain order matching input ids return ids.map(id => users.find(user => user.id === id)); }); interface Context { db: Database; loaders: { userById: DataLoader; postsByUserId: DataLoader; }; } const resolvers: IResolvers = { Query: { user: async (_, { id }, { loaders }) => { return loaders.userById.load(id); }, users: async (_, { filter, limit }, { db }) => { return db.users.find(filter, { limit }); }, }, User: { posts: async (user, _, { loaders }) => { // DataLoader batches and caches these calls return loaders.postsByUserId.load(user.id); }, }, Post: { author: async (post, _, { loaders }) => { return loaders.userById.load(post.userId); }, }, }; // Apollo Server setup import { ApolloServer } from '@apollo/server'; import { startStandaloneServer } from '@apollo/server/standalone'; const server = new ApolloServer({ typeDefs, resolvers, }); const { url } = await startStandaloneServer(server, { context: async ({ req }) => { const db = createDatabaseConnection(); return { db, loaders: { userById: createUserByIdLoader(db), postsByUserId: createPostsByUserIdLoader(db), }, }; }, }); ``` ### Client Query Examples ```typescript // Flexible field selection - client controls response shape const MINIMAL_USER = gql` query GetUser($id: ID!) { user(id: $id) { id name } } `; const DETAILED_USER = gql` query GetUserWithPosts($id: ID!) { user(id: $id) { id name email createdAt posts { id title published } } } `; // Single query replacing multiple REST calls const DASHBOARD_DATA = gql` query Dashboard($userId: ID!) { user(id: $userId) { name posts { id title } } # Would require separate REST endpoint users(filter: { createdAfter: "2025-01-01" }, limit: 5) { id name } } `; ``` --- ## Pattern 2: POST/PUT/DELETE to Mutations ### REST Endpoints ```typescript // POST /api/users app.post('/api/users', async (req, res) => { const { name, email, password } = req.body; if (!name || !email) { return res.status(400).json({ error: 'Missing required fields' }); } const user = await db.users.create({ name, email, password }); res.status(201).json(user); }); // PUT /api/users/:id app.put('/api/users/:id', async (req, res) => { const user = await db.users.update(req.params.id, req.body); res.json(user); }); // DELETE /api/users/:id app.delete('/api/users/:id', async (req, res) => { await db.users.delete(req.params.id); res.status(204).send(); }); ``` ### GraphQL Schema ```graphql type Mutation { createUser(input: CreateUserInput!): CreateUserPayload! updateUser(input: UpdateUserInput!): UpdateUserPayload! deleteUser(id: ID!): DeleteUserPayload! } input CreateUserInput { name: String! email: String! password: String! } type CreateUserPayload { user: User errors: [UserError!]! } input UpdateUserInput { id: ID! name: String email: String } type UpdateUserPayload { user: User errors: [UserError!]! } type DeleteUserPayload { deletedId: ID errors: [UserError!]! } type UserError { field: String message: String! code: ErrorCode! } enum ErrorCode { VALIDATION_ERROR NOT_FOUND UNAUTHORIZED INTERNAL_ERROR } ``` ### GraphQL Mutation Resolvers ```typescript const resolvers: IResolvers = { Mutation: { createUser: async (_, { input }, { db, user }) => { try { // Validation if (!isValidEmail(input.email)) { return { user: null, errors: [{ field: 'email', message: 'Invalid email format', code: 'VALIDATION_ERROR', }], }; } // Check for duplicate const existing = await db.users.findByEmail(input.email); if (existing) { return { user: null, errors: [{ field: 'email', message: 'Email already registered', code: 'VALIDATION_ERROR', }], }; } const hashedPassword = await bcrypt.hash(input.password, 10); const newUser = await db.users.create({ name: input.name, email: input.email, password: hashedPassword, }); return { user: newUser, errors: [], }; } catch (error) { return { user: null, errors: [{ message: 'Failed to create user', code: 'INTERNAL_ERROR', }], }; } }, updateUser: async (_, { input }, { db, user }) => { if (!user || user.id !== input.id) { return { user: null, errors: [{ message: 'Unauthorized', code: 'UNAUTHORIZED', }], }; } const updated = await db.users.update(input.id, { ...(input.name && { name: input.name }), ...(input.email && { email: input.email }), }); return { user: updated, errors: [], }; }, deleteUser: async (_, { id }, { db, user }) => { if (!user || user.id !== id) { return { deletedId: null, errors: [{ message: 'Unauthorized', code: 'UNAUTHORIZED' }], }; } await db.users.delete(id); return { deletedId: id, errors: [], }; }, }, }; ``` ### Client Mutation Examples ```typescript const CREATE_USER = gql` mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { user { id name email createdAt } errors { field message code } } } `; // Usage with error handling const [createUser] = useMutation(CREATE_USER); const handleSubmit = async (formData) => { const { data } = await createUser({ variables: { input: formData, }, }); if (data.createUser.errors.length > 0) { // Handle validation errors data.createUser.errors.forEach(error => { setFieldError(error.field, error.message); }); } else { // Success - use the returned user navigate(`/users/${data.createUser.user.id}`); } }; ``` --- ## Pattern 3: Pagination Migration ### REST Offset Pagination ```typescript // GET /api/posts?page=2&limit=20 app.get('/api/posts', async (req, res) => { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 20; const offset = (page - 1) * limit; const posts = await db.posts.find({ limit, offset, }); const total = await db.posts.count(); res.json({ data: posts, pagination: { page, limit, total, totalPages: Math.ceil(total / limit), }, }); }); ``` ### GraphQL Cursor-Based Pagination (Relay Connections) ```graphql type Query { posts( first: Int after: String last: Int before: String filter: PostFilter ): PostConnection! } type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! totalCount: Int! } type PostEdge { node: Post! cursor: String! } type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String } input PostFilter { published: Boolean authorId: ID titleContains: String } ``` ### Cursor Pagination Resolver ```typescript import { encodeCursor, decodeCursor } from './cursor-utils'; const resolvers: IResolvers = { Query: { posts: async (_, args, { db }) => { const { first, after, last, before, filter } = args; // Validate pagination args if (first && last) { throw new Error('Cannot specify both first and last'); } const limit = first || last || 20; const isForward = !!first || !last; // Decode cursor to get offset let offset = 0; if (after) { offset = decodeCursor(after) + 1; } else if (before) { offset = Math.max(0, decodeCursor(before) - limit); } // Fetch one extra to determine hasNextPage const posts = await db.posts.find({ filter, limit: limit + 1, offset, orderBy: { createdAt: isForward ? 'DESC' : 'ASC' }, }); const hasMore = posts.length > limit; const nodes = hasMore ? posts.slice(0, limit) : posts; if (!isForward) { nodes.reverse(); } const edges = nodes.map((post, index) => ({ node: post, cursor: encodeCursor(offset + index), })); const totalCount = await db.posts.count(filter); return { edges, pageInfo: { hasNextPage: isForward ? hasMore : offset > 0, hasPreviousPage: !isForward ? hasMore : offset > 0, startCursor: edges[0]?.cursor, endCursor: edges[edges.length - 1]?.cursor, }, totalCount, }; }, }, }; // cursor-utils.ts export const encodeCursor = (offset: number): string => { return Buffer.from(`cursor:${offset}`).toString('base64'); }; export const decodeCursor = (cursor: string): number => { const decoded = Buffer.from(cursor, 'base64').toString('utf-8'); return parseInt(decoded.replace('cursor:', '')); }; ``` ### Client Pagination Query ```typescript const POSTS_QUERY = gql` query Posts($first: Int!, $after: String, $filter: PostFilter) { posts(first: $first, after: $after, filter: $filter) { edges { node { id title published author { name } } cursor } pageInfo { hasNextPage endCursor } totalCount } } `; // Infinite scroll implementation const PostList = () => { const { data, loading, fetchMore } = useQuery(POSTS_QUERY, { variables: { first: 20 }, }); const loadMore = () => { fetchMore({ variables: { after: data.posts.pageInfo.endCursor, }, updateQuery: (prev, { fetchMoreResult }) => { if (!fetchMoreResult) return prev; return { posts: { ...fetchMoreResult.posts, edges: [ ...prev.posts.edges, ...fetchMoreResult.posts.edges, ], }, }; }, }); }; return (
{data?.posts.edges.map(({ node }) => ( ))} {data?.posts.pageInfo.hasNextPage && ( )}
); }; ``` --- ## Pattern 4: Authentication Translation ### REST Authentication ```typescript // REST middleware app.use(async (req, res, next) => { const token = req.headers.authorization?.replace('Bearer ', ''); if (token) { try { const payload = jwt.verify(token, process.env.JWT_SECRET); req.user = await db.users.findById(payload.userId); } catch (error) { return res.status(401).json({ error: 'Invalid token' }); } } next(); }); ``` ### GraphQL Authentication Context ```typescript import { ApolloServer } from '@apollo/server'; import { GraphQLError } from 'graphql'; interface AuthContext { user: User | null; requireAuth: () => User; } const server = new ApolloServer({ typeDefs, resolvers, }); await startStandaloneServer(server, { context: async ({ req }) => { const token = req.headers.authorization?.replace('Bearer ', ''); let user: User | null = null; if (token) { try { const payload = jwt.verify(token, process.env.JWT_SECRET); user = await db.users.findById(payload.userId); } catch (error) { // Token invalid - continue with user = null } } return { user, db, loaders: createLoaders(db), // Helper to enforce authentication requireAuth: (): User => { if (!user) { throw new GraphQLError('Authentication required', { extensions: { code: 'UNAUTHENTICATED' }, }); } return user; }, }; }, }); ``` ### Field-Level Authorization ```typescript import { GraphQLFieldResolver } from 'graphql'; // Authorization directive const resolvers: IResolvers = { Query: { me: (_, __, { requireAuth }) => { const user = requireAuth(); return user; }, users: (_, __, { user }) => { // Optional auth - different data based on auth state if (user?.role === 'ADMIN') { return db.users.findAll(); } // Public view - limited fields return db.users.findPublic(); }, }, User: { email: (user, _, { user: currentUser }) => { // Field-level privacy if (currentUser?.id === user.id || currentUser?.role === 'ADMIN') { return user.email; } return null; }, }, }; ``` --- ## BFF (Backend for Frontend) Architecture ### Multi-Client GraphQL Gateway ```typescript // Schema stitching for different clients import { stitchSchemas } from '@graphql-tools/stitch'; // Mobile-optimized schema const mobileSchema = makeExecutableSchema({ typeDefs: ` type Query { # Denormalized for fewer round-trips dashboard: MobileDashboard! } type MobileDashboard { user: User! recentPosts: [Post!]! notifications: [Notification!]! # All data needed for mobile home screen } `, resolvers: mobileResolvers, }); // Web-optimized schema const webSchema = makeExecutableSchema({ typeDefs: ` type Query { # Granular for efficient caching user(id: ID!): User posts(filter: PostFilter): PostConnection! notifications(unreadOnly: Boolean): [Notification!]! } `, resolvers: webResolvers, }); // Client-specific servers const mobileServer = new ApolloServer({ schema: mobileSchema, introspection: true, }); const webServer = new ApolloServer({ schema: webSchema, introspection: true, }); // Route based on client header app.use('/graphql', (req, res) => { const client = req.headers['x-client-type']; if (client === 'mobile') { return mobileServer.handleRequest(req, res); } return webServer.handleRequest(req, res); }); ``` --- ## Incremental Migration Strategy ### Phase 1: GraphQL Wrapper (Weeks 1-2) ```typescript // Wrap existing REST endpoints with GraphQL const resolvers: IResolvers = { Query: { user: async (_, { id }) => { // Call existing REST API internally const response = await fetch(`http://localhost:3000/api/users/${id}`); return response.json(); }, }, }; // Allows GraphQL adoption without backend rewrites // Clients can start using GraphQL immediately ``` ### Phase 2: Parallel Implementation (Weeks 3-6) ```typescript // Implement GraphQL resolvers with direct DB access // Keep REST endpoints running const resolvers: IResolvers = { Query: { user: async (_, { id }, { db }) => { // New implementation - direct database return db.users.findById(id); }, }, }; // Feature flag to route traffic const USE_GRAPHQL = process.env.GRAPHQL_ENABLED === 'true'; app.get('/api/users/:id', async (req, res) => { if (USE_GRAPHQL) { // Forward to GraphQL const result = await graphqlServer.executeOperation({ query: `query GetUser($id: ID!) { user(id: $id) { ... } }`, variables: { id: req.params.id }, }); return res.json(result.data?.user); } // Legacy REST implementation const user = await db.users.findById(req.params.id); res.json(user); }); ``` ### Phase 3: Client Migration (Weeks 7-12) ```typescript // Gradual client migration with monitoring import { setContext } from '@apollo/client/link/context'; const migrationLink = setContext((_, { headers }) => { return { headers: { ...headers, 'x-graphql-migration': 'phase-3', }, }; }); // A/B test GraphQL vs REST in production // Monitor performance, errors, client satisfaction ``` ### Phase 4: REST Deprecation (Week 13+) ```typescript // Deprecate REST endpoints gradually app.get('/api/users/:id', (req, res) => { res.status(410).json({ error: 'This endpoint is deprecated', message: 'Please use GraphQL endpoint at /graphql', migrationGuide: 'https://docs.example.com/graphql-migration', sunsetDate: '2025-06-01', }); }); // Eventually remove REST entirely ``` --- ## Common Pitfalls ### Pitfall 1: N+1 Query Problem ```typescript // BAD - Causes N+1 queries const resolvers = { User: { posts: async (user, _, { db }) => { // Called once per user - N queries if you fetch N users return db.posts.findByUserId(user.id); }, }, }; // GOOD - Use DataLoader const resolvers = { User: { posts: async (user, _, { loaders }) => { // Batched and cached return loaders.postsByUserId.load(user.id); }, }, }; ``` ### Pitfall 2: Exposing Database Schema Directly ```typescript // BAD - Tightly coupled to database type User { user_id: Int! # Database column name first_name: String # Database structure leaks last_name: String created_at: String # Raw DB type } // GOOD - API-first design type User { id: ID! # Abstract identifier name: String! # Computed from first + last createdAt: DateTime! # Proper type } ``` ### Pitfall 3: Missing Error Handling ```typescript // BAD - Errors kill entire response const resolvers = { Query: { dashboard: async () => { const user = await fetchUser(); // Throws on error const posts = await fetchPosts(); // Never reached if user fails return { user, posts }; }, }, }; // GOOD - Partial success model const resolvers = { Query: { dashboard: async () => { return {}; // Return empty object }, }, Dashboard: { user: async (_, __, context) => { try { return await fetchUser(); } catch (error) { return null; // Client still gets posts } }, posts: async () => { try { return await fetchPosts(); } catch (error) { return []; // Graceful degradation } }, }, }; ``` ### Pitfall 4: Ignoring Query Complexity ```typescript // BAD - No limits on query depth/complexity // Client can write expensive queries that DOS the server // GOOD - Implement complexity limits import { createComplexityLimitRule } from 'graphql-validation-complexity'; const server = new ApolloServer({ typeDefs, resolvers, validationRules: [ createComplexityLimitRule(1000, { onCost: (cost) => { console.log('Query cost:', cost); }, }), ], }); // Assign costs to fields const typeDefs = ` type Query { users: [User!]! @cost(complexity: 10) user(id: ID!): User @cost(complexity: 1) } type User { posts: [Post!]! @cost(complexity: 5, multipliers: ["first"]) } `; ``` ### Pitfall 5: Over-Normalization ```typescript // BAD - Too granular, requires many queries type Query { userName(id: ID!): String userEmail(id: ID!): String userPosts(userId: ID!): [Post!]! } // GOOD - Logical grouping type Query { user(id: ID!): User } type User { name: String! email: String! posts: [Post!]! } ``` --- ## Cross-References **Related Skills:** - **graphql-architect/references/schema-design.md** - Type system patterns and schema structure - **graphql-architect/references/federation-guide.md** - Multi-service GraphQL architecture - **backend-developer** - REST API implementation patterns - **api-designer** - API design principles and consistency **When to Escalate:** - Federation across microservices → See federation-guide.md - Schema design questions → See schema-design.md - Complex subscription requirements → Consult graphql-architect - Performance optimization → Partner with performance-engineer --- ## Migration Checklist - [ ] Identify most-used REST endpoints - [ ] Map REST resources to GraphQL types - [ ] Design schema following best practices - [ ] Implement DataLoaders for all relations - [ ] Add authentication/authorization - [ ] Implement pagination (cursor-based) - [ ] Set up query complexity limits - [ ] Create client migration plan - [ ] Monitor performance metrics - [ ] Document GraphQL queries for clients - [ ] Train team on GraphQL patterns - [ ] Plan REST endpoint sunset timeline **Migration complete when:** - All critical paths use GraphQL - REST endpoints deprecated with sunset dates - Client applications fully migrated - Performance metrics meet or exceed REST baseline - Team confident in GraphQL maintenance