@apollo/datasource-rest (Node.js code-based) or Apollo Connectors @connect directive (declarative) or strawberry / ariadne (Python) to wrap REST endpoints without rewriting backends.graphql >= 16.11.0). [src8]maxDepth and maxComplexity limits on production GraphQL endpoints — unbounded query depth enables denial-of-service attacks. [src4]errors array. [src1]| REST Concept | GraphQL Equivalent | Example |
|---|---|---|
GET /users | query { users { id name } } | Single query, client picks fields |
GET /users/123 | query { user(id: "123") { name email } } | Arguments replace path params |
POST /users with JSON body | mutation { createUser(input: { name: "Alice" }) { id } } | Mutations for writes |
PUT /users/123 | mutation { updateUser(id: "123", input: {...}) { id } } | Partial updates via input types |
DELETE /users/123 | mutation { deleteUser(id: "123") { success } } | Return confirmation payload |
| Multiple endpoints for related data | Nested fields: user { posts { title } } | One query replaces N requests |
Query string filters ?status=active | Field arguments: users(status: ACTIVE) | Enum types for filter values |
Pagination via ?page=2&limit=10 | Cursor-based: users(first: 10, after: "cursor") | Relay-style connection pattern |
| HTTP status codes (404, 500) | errors array in response body | Errors coexist with partial data |
API versioning /v1/, /v2/ | Schema evolution — deprecate fields | @deprecated(reason: "Use newField") |
| Content negotiation (Accept header) | Field selection in query | Client requests exactly what it needs |
| HATEOAS links | Type relationships in schema | User.posts resolves related data |
| Rate limiting per endpoint | Query complexity analysis | Limit total cost per query |
| OpenAPI/Swagger spec | GraphQL SDL schema | Introspection replaces API docs |
| Webhook callbacks | Subscriptions | subscription { orderUpdated { id status } } |
START
├── Is the REST API stable with well-documented endpoints?
│ ├── YES → Layer GraphQL on top using RESTDataSource resolvers
│ └── NO → Stabilize REST API first, then migrate
├── How many clients consume the API?
│ ├── ONE (single SPA) → Migrate client + server together, feature by feature
│ └── MULTIPLE → Keep REST running, add GraphQL as new endpoint, migrate clients one at a time
├── Is over-fetching / under-fetching a real problem?
│ ├── YES → GraphQL provides immediate value — prioritize high-traffic endpoints
│ └── NO → Consider if migration complexity is worth it
├── Do you want a code-based or declarative approach?
│ ├── DECLARATIVE → Apollo Connectors @connect directive (no resolver code, GA Feb 2025)
│ └── CODE-BASED ↓
├── Are you using microservices?
│ ├── YES → Use Apollo Federation v2 or schema stitching to compose a unified graph
│ └── NO (monolith) → Single GraphQL server with resolvers calling internal services
├── Is your backend Node.js or Python?
│ ├── NODE.JS → Apollo Server 5 + @apollo/datasource-rest
│ ├── PYTHON → Strawberry 0.303+ (code-first) or Ariadne 0.23+ (schema-first)
│ └── OTHER → graphql-java, gqlgen (Go), Hot Chocolate (.NET)
└── DEFAULT → Start with one feature, wrap REST endpoints in resolvers, expand incrementally
Inventory all REST endpoints, their request/response shapes, and relationships between resources. Design a demand-oriented GraphQL schema based on how clients consume data, not how your backend stores it. [src1, src5]
# List all REST endpoints from an OpenAPI spec
cat openapi.json | jq '.paths | keys[]'
# Count endpoints by HTTP method
cat openapi.json | jq '[.paths[][]] | group_by(.operationId) | length'
Verify: The schema draft covers all client use cases. Compare the GraphQL type list against REST resource nouns.
Install a GraphQL server and create resolvers that delegate to your existing REST API. This is the zero-risk starting point — no backend changes needed. [src2, src4]
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { RESTDataSource } from '@apollo/datasource-rest';
class UsersAPI extends RESTDataSource {
baseURL = 'https://api.example.com/v1/';
async getUsers() { return this.get('users'); }
async getUser(id) { return this.get(`users/${encodeURIComponent(id)}`); }
async getUserPosts(userId) {
return this.get(`users/${encodeURIComponent(userId)}/posts`);
}
}
const typeDefs = `#graphql
type User { id: ID! name: String! email: String! posts: [Post!]! }
type Post { id: ID! title: String! content: String! }
type Query { users: [User!]! user(id: ID!): User }
`;
const resolvers = {
Query: {
users: (_, __, { dataSources }) => dataSources.usersAPI.getUsers(),
user: (_, { id }, { dataSources }) => dataSources.usersAPI.getUser(id),
},
User: {
posts: (parent, _, { dataSources }) =>
dataSources.usersAPI.getUserPosts(parent.id),
},
};
Verify: curl -X POST http://localhost:4000/ -H 'Content-Type: application/json' -d '{"query":"{ users { id name } }"}' returns data from your REST API.
Every resolver that fetches related data will cause N+1 requests to your REST API. Add DataLoader to batch and deduplicate. [src3, src6]
import DataLoader from 'dataloader';
async function batchUserPosts(userIds) {
const results = await fetch(
`https://api.example.com/v1/posts?userIds=${userIds.join(',')}`
).then(r => r.json());
return userIds.map(id => results.filter(post => post.userId === id));
}
// Create per-request DataLoader (MUST be per-request, not global)
function createLoaders() {
return { userPosts: new DataLoader(batchUserPosts) };
}
const resolvers = {
User: {
posts: (parent, _, { loaders }) => loaders.userPosts.load(parent.id),
},
};
Verify: Query { users { id posts { title } } } with 10 users — should see 2 total REST requests, not 11.
Map REST POST/PUT/DELETE endpoints to GraphQL mutations. Use input types for structured arguments and return the mutated object. [src4, src5]
const resolvers = {
Mutation: {
createUser: async (_, { input }, { dataSources }) =>
dataSources.usersAPI.post('users', { body: input }),
updateUser: async (_, { id, input }, { dataSources }) =>
dataSources.usersAPI.patch(`users/${encodeURIComponent(id)}`, { body: input }),
deleteUser: async (_, { id }, { dataSources }) => {
await dataSources.usersAPI.delete(`users/${encodeURIComponent(id)}`);
return { success: true, message: `User ${id} deleted` };
},
},
};
Verify: Execute a createUser mutation via GraphQL Playground and confirm the REST API received the POST request.
Update frontend code to use GraphQL queries instead of REST calls. Start with the highest-traffic pages. [src4]
// BEFORE: REST client code
const user = await fetch('/api/v1/users/123').then(r => r.json());
const posts = await fetch('/api/v1/users/123/posts').then(r => r.json());
// AFTER: GraphQL client code (@apollo/client)
import { gql, useQuery } from '@apollo/client';
const USER_WITH_POSTS = gql`
query UserWithPosts($id: ID!) {
user(id: $id) { id name email posts { id title content } }
}
`;
function UserProfile({ userId }) {
const { data, loading, error } = useQuery(USER_WITH_POSTS, {
variables: { id: userId },
});
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<h1>{data.user.name}</h1>
<ul>{data.user.posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
</div>
);
}
Verify: Network tab shows a single GraphQL POST replacing multiple REST GET requests.
Once the GraphQL schema is stable, remove the REST intermediary in resolvers. Call your database or internal services directly for better performance. [src2]
// BEFORE: Resolver calls REST API
users: (_, __, { dataSources }) => dataSources.usersAPI.getUsers(),
// AFTER: Resolver calls database directly
import { pool } from './db.js';
users: async () => {
const { rows } = await pool.query('SELECT id, name, email FROM users');
return rows;
},
Verify: GraphQL responses remain identical. Run integration tests to confirm no regressions.
Once all clients use GraphQL, deprecate REST endpoints with a sunset timeline. Monitor access logs to confirm zero traffic before removal. [src1, src4]
# Check for remaining REST API traffic
grep 'GET /api/v1/' access.log | wc -l
# Add deprecation headers to REST responses (Express middleware)
# res.set('Sunset', 'Sat, 01 Jun 2026 00:00:00 GMT');
# res.set('Deprecation', 'true');
Verify: REST endpoint access count is zero over a 30-day window before removing the code.
// Input: Existing REST API at https://api.example.com/v1/
// Output: GraphQL server that proxies and reshapes REST responses
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { RESTDataSource } from '@apollo/datasource-rest';
import DataLoader from 'dataloader';
class ProductsAPI extends RESTDataSource {
override baseURL = 'https://api.example.com/v1/';
async getProducts(category?: string): Promise<Product[]> {
const params = category ? { category } : {};
return this.get('products', { params });
}
async getProduct(id: string): Promise<Product> {
return this.get(`products/${encodeURIComponent(id)}`);
}
async getReviewsByProductIds(ids: string[]): Promise<Review[][]> {
const reviews = await this.get('reviews', {
params: { productIds: ids.join(',') },
});
return ids.map(id => reviews.filter((r: Review) => r.productId === id));
}
}
const typeDefs = `#graphql
type Product {
id: ID! name: String! price: Float! category: String!
reviews: [Review!]! averageRating: Float
}
type Review { id: ID! rating: Int! comment: String author: String! }
type Query {
products(category: String): [Product!]!
product(id: ID!): Product
}
type Mutation {
addReview(productId: ID!, rating: Int!, comment: String, author: String!): Review!
}
`;
const resolvers = {
Query: {
products: (_: unknown, args: { category?: string }, { dataSources }: Context) =>
dataSources.productsAPI.getProducts(args.category),
product: (_: unknown, { id }: { id: string }, { dataSources }: Context) =>
dataSources.productsAPI.getProduct(id),
},
Product: {
reviews: (parent: Product, _: unknown, { loaders }: Context) =>
loaders.reviews.load(parent.id),
averageRating: async (parent: Product, _: unknown, { loaders }: Context) => {
const reviews = await loaders.reviews.load(parent.id);
if (reviews.length === 0) return null;
return reviews.reduce((sum: number, r: Review) => sum + r.rating, 0) / reviews.length;
},
},
};
# Input: Existing Flask REST API
# Output: Strawberry GraphQL server wrapping the REST endpoints
import strawberry
import httpx
from typing import Optional
REST_BASE_URL = "https://api.example.com/v1"
@strawberry.type
class User:
id: strawberry.ID
name: str
email: str
@strawberry.input
class CreateUserInput:
name: str
email: str
@strawberry.type
class Query:
@strawberry.field
async def users(self) -> list[User]:
async with httpx.AsyncClient() as client:
resp = await client.get(f"{REST_BASE_URL}/users")
resp.raise_for_status()
return [User(**u) for u in resp.json()]
@strawberry.field
async def user(self, id: strawberry.ID) -> Optional[User]:
async with httpx.AsyncClient() as client:
resp = await client.get(f"{REST_BASE_URL}/users/{id}")
if resp.status_code == 404:
return None
resp.raise_for_status()
return User(**resp.json())
@strawberry.type
class Mutation:
@strawberry.mutation
async def create_user(self, input: CreateUserInput) -> User:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{REST_BASE_URL}/users",
json={"name": input.name, "email": input.email},
)
resp.raise_for_status()
return User(**resp.json())
schema = strawberry.Schema(query=Query, mutation=Mutation)
# Run with: uvicorn app:app
from strawberry.fastapi import GraphQLRouter
from fastapi import FastAPI
app = FastAPI()
app.include_router(GraphQLRouter(schema), prefix="/graphql")
# Input: Existing REST API
# Output: Ariadne schema-first GraphQL server
from ariadne import QueryType, MutationType, make_executable_schema
from ariadne.asgi import GraphQL
import httpx
REST_BASE_URL = "https://api.example.com/v1"
type_defs = """
type User { id: ID! name: String! email: String! }
type Query { users: [User!]! user(id: ID!): User }
type Mutation { createUser(name: String!, email: String!): User! }
"""
query = QueryType()
mutation = MutationType()
@query.field("users")
async def resolve_users(_, info):
async with httpx.AsyncClient() as client:
resp = await client.get(f"{REST_BASE_URL}/users")
return resp.json()
@query.field("user")
async def resolve_user(_, info, id):
async with httpx.AsyncClient() as client:
resp = await client.get(f"{REST_BASE_URL}/users/{id}")
if resp.status_code == 404:
return None
return resp.json()
@mutation.field("createUser")
async def resolve_create_user(_, info, name, email):
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{REST_BASE_URL}/users", json={"name": name, "email": email}
)
return resp.json()
schema = make_executable_schema(type_defs, query, mutation)
app = GraphQL(schema)
# Run with: uvicorn app:app
# Input: Existing REST API with OpenAPI spec
# Output: GraphQL schema mapped to REST via @connect (GA Feb 2025) [src9]
# Requires: Apollo GraphOS + Router 2.0
extend schema
@link(url: "https://specs.apollo.dev/connect/v0.1",
import: ["@connect", "@source"])
@source(name: "restAPI",
http: { baseURL: "https://api.example.com/v1" })
type Query {
users: [User!]!
@connect(source: "restAPI", http: { GET: "/users" })
user(id: ID!): User
@connect(source: "restAPI", http: { GET: "/users/{$args.id}" })
}
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
@connect(source: "restAPI",
http: { GET: "/users/{$this.id}/posts" })
}
// ❌ BAD — Schema mimics REST URL hierarchy and HTTP verbs
type Query {
getUsers: [User]
getUserById(id: ID!): User
postUser(name: String!, email: String!): User
putUser(id: ID!, name: String): User
deleteUser(id: ID!): Boolean
}
// ✅ GOOD — Demand-oriented schema with proper queries/mutations
type Query {
users(filter: UserFilter): [User!]!
user(id: ID!): User
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): DeleteResult!
}
// ❌ BAD — Each user triggers a separate REST call for posts
const resolvers = {
User: {
posts: async (parent) => {
// Called N times — once per user in the list
const res = await fetch(`/api/v1/users/${parent.id}/posts`);
return res.json();
},
},
};
// ✅ GOOD — All post lookups batched into a single request
import DataLoader from 'dataloader';
const postLoader = new DataLoader(async (userIds) => {
const res = await fetch(`/api/v1/posts?userIds=${userIds.join(',')}`);
const posts = await res.json();
return userIds.map(id => posts.filter(p => p.userId === id));
});
const resolvers = {
User: { posts: (parent) => postLoader.load(parent.id) },
};
// ❌ BAD — Exposes internal database structure and column names
// Auto-generated types include: user_created_at_utc, fk_department_id,
// is_deleted_flag, internal_audit_notes
const typeDefs = generateSchemaFromDB(database);
// ✅ GOOD — Clean client-facing types, resolvers handle mapping
const typeDefs = `#graphql
type User {
id: ID!
name: String!
department: Department! # Relationship, not FK
createdAt: DateTime! # Clean name, not column name
}
`;
const resolvers = {
User: {
department: (user) => getDepartment(user.fk_department_id),
createdAt: (user) => user.user_created_at_utc,
},
};
// ❌ BAD — Using HTTP semantics inside GraphQL
const resolvers = {
Query: {
user: async (_, { id }, { res }) => {
const user = await findUser(id);
if (!user) {
res.status(404); // GraphQL always returns HTTP 200
return null;
}
return user;
},
},
};
// ✅ GOOD — GraphQL-native error handling
import { GraphQLError } from 'graphql';
const resolvers = {
Query: {
user: async (_, { id }) => {
const user = await findUser(id);
if (!user) {
throw new GraphQLError('User not found', {
extensions: { code: 'NOT_FOUND', argumentName: 'id' },
});
}
return user;
},
},
};
// ❌ BAD — Apollo Server 4 is end-of-life as of 2026-01-26
import { ApolloServer } from '@apollo/server'; // v4
import { expressMiddleware } from '@apollo/server/express4'; // v4 built-in Express
// ✅ GOOD — Apollo Server 5 with explicit framework integration [src8]
import { ApolloServer } from '@apollo/server'; // v5
import { expressMiddleware } from '@as-integrations/express4'; // separate package
// Or for standalone (no longer uses Express internally):
import { startStandaloneServer } from '@apollo/server/standalone';
DataLoader to batch all IDs into a single request. Create a new DataLoader instance per request. [src3, src6]graphql-query-complexity or Apollo's built-in limits. [src4]@deprecated(reason: "Use newField") directive, monitor usage, remove only after 90+ days of zero usage. [src1]graphql >= 16.11.0). The upgrade is minimal. [src8]# Test GraphQL endpoint with curl
curl -X POST http://localhost:4000/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ __schema { types { name } } }"}'
# Introspect schema (get full SDL)
npx graphql-inspector introspect http://localhost:4000/graphql --write schema.graphql
# Check for breaking changes between schema versions
npx graphql-inspector diff old-schema.graphql new-schema.graphql
# Validate schema against best practices
npx graphql-schema-linter schema.graphql
# Test query performance
npx graphql-benchmark --endpoint http://localhost:4000/graphql \
--query '{ users { id name posts { title } } }'
# Check Apollo Server version (ensure not on EOL v4)
npm ls @apollo/server
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Apollo Server 5.x (2025) | Current | Express middleware → @as-integrations/*; Node.js 20+ required | Replace @apollo/server/express4 with @as-integrations/express4 |
| Apollo Server 4.x (2023) | EOL (2026-01-26) | Standalone package, new plugin API | Upgrade to v5 (minimal changes) |
| Apollo Server 3.x (2021) | EOL | apollo-datasource-rest → @apollo/datasource-rest | Skip to v5 directly |
| Apollo Connectors (2025) | GA | N/A (new feature) | Declarative @connect for REST-to-GraphQL |
| Strawberry 0.303+ (2026) | Current | Python 3.9 dropped (v0.268.0); CSRF default in Django view | Require Python 3.10+ |
| Strawberry 0.220+ (2024) | Supported | Pydantic v2, async by default | Update type annotations |
| Ariadne 0.23+ (2024) | Current | None significant | — |
| GraphQL.js 16.x (2022) | Current | Pure ESM, graphql-tools v9 | Use ESM imports; required by Apollo Server 5 |
| Apollo Client 4.0 (2025) | Current | Leaner bundles, TypeScript safety | Update client-side imports |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Multiple clients need different data shapes from the same API | Simple CRUD API with one client | Keep REST |
| Mobile + web clients with different bandwidth needs | File upload is the primary use case | REST with multipart/form-data |
| Frontend fetches from 3+ REST endpoints per page load | Backend-to-backend microservice communication | gRPC or REST |
| You need real-time subscriptions alongside queries | Simple webhooks suffice for async events | REST + webhooks |
| API consumers are internal teams you can coordinate with | Public API with thousands of external consumers | REST with OpenAPI spec |
| Over-fetching is measurably hurting mobile performance | All endpoints return <1 KB responses | REST is fine |
| You want declarative REST orchestration without custom resolvers | You need fine-grained control over every resolver | Apollo Connectors vs code-based resolvers |
errors array. Monitoring tools that rely on HTTP status codes need reconfiguration. Exception: Apollo Server 5 now returns HTTP 400 for variable coercion errors.graphql-upload package or handle uploads via a separate REST endpoint.maxDepth and maxComplexity limits in production.startStandaloneServer uses Node.js built-in HTTP server. For Express, install @as-integrations/express4 or @as-integrations/express5 separately.loader.load(), the batch window may close prematurely.@connect directive) require Apollo GraphOS and Router 2.0 — they are not available in self-hosted open-source Apollo Server alone.