Skip to main content
Technology & EngineeringMigration Patterns175 lines

REST to GRAPHQL

Migrate a REST API to GraphQL while maintaining backward compatibility

Quick Summary27 lines
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 lines
Paste into your CLAUDE.md or agent config

REST 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

  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.

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

Get CLI access →