Terminal.skills
Use Cases/Migrate from REST to GraphQL Without Breaking Existing Clients

Migrate from REST to GraphQL Without Breaking Existing Clients

Incrementally adopt GraphQL alongside your REST API so existing clients keep working while new ones get the benefits of a unified graph.

Development#graphql#api#apollo#schema#resolvers
Works with:claude-codeopenai-codexgemini-clicursor
$

The Problem

Your backend has dozens of REST endpoints accumulated over years. Mobile clients over-fetch data -- the orders endpoint returns 6 KB per item when the app needs 400 bytes, and on a cellular connection that wasted bandwidth is the difference between a snappy list and a loading spinner. The frontend team maintains a fragile Backend-for-Frontend layer that stitches together 3-4 REST calls per screen, and every time the backend adds a field, the BFF needs a matching update or the mobile app shows stale data.

Every new feature requires coordinating endpoint changes across backend, BFF, and multiple clients. The mobile team asks for a "light" version of the orders endpoint, the backend team creates /api/orders/summary, and six months later there are 4 variants of the same endpoint with subtly different response shapes. You want GraphQL -- let clients declare exactly what they need -- but you cannot flip a switch. Production clients depend on every existing route, third-party integrations have hardcoded URLs, and a big-bang rewrite would stall feature work for months.

The Solution

Using an incremental strangler-fig approach with the graphql, api-tester, and code-migration skills, the agent stands up a GraphQL gateway that wraps existing REST service functions, migrates consumers one screen at a time, and deprecates old routes only after traffic drops to zero. Existing clients never break because the REST endpoints keep working unchanged.

Step-by-Step Walkthrough

Step 1: Audit Existing REST Endpoints

Scan the routes directory in src/routes/ and list every REST endpoint with its
HTTP method, path, request params, and response shape. Group them by domain
(users, orders, products). Flag endpoints that return nested resources — those
benefit most from GraphQL.

The audit produces a structured inventory of 34 REST endpoints across 4 domains:

DomainEndpointsNotes
Users8GET /users, GET /users/:id, POST /users, ...
Orders12GET /orders?status=pending, GET /orders/:id/items, ...
Products9Heavy nesting -- GET /products/:id returns seller, reviews, variants
Auth5login, refresh, logout, verify, reset-password

The high-value migration candidates are the endpoints with the worst over-fetch ratios. GET /products/:id returns 4.2 KB on average, but clients typically use about 800 bytes -- the rest is nested seller data, all review objects, and variant details the client doesn't need for most views. GET /orders/:id bundles line items, shipping details, and payment info into one blob even when the client only needs the order status.

These endpoints cause the most pain for mobile clients on slow connections and are exactly where GraphQL's field selection pays off immediately.

Step 2: Generate the GraphQL Schema from REST Contracts

Based on the endpoint audit, generate a GraphQL schema (schema.graphql) that
covers Users, Orders, and Products. Map each REST response shape to a GraphQL
type. Add relay-style pagination for list endpoints. Keep field names consistent
with the existing JSON keys so serialization stays stable.

The schema maps each REST response shape to a GraphQL type, preserving field names so the data layer doesn't change -- only the query interface. List endpoints get relay-style pagination with first, after, last, before arguments and PageInfo types. Relationships that required multiple REST calls (products to reviews, orders to line items) become natural graph edges:

graphql
type Product {
  id: ID!
  name: String!
  price: Int!
  seller: User!              # Was a nested object in REST, now lazily resolved
  reviews(first: Int): ReviewConnection!  # Was always included, now opt-in
  variants: [Variant!]!
}

type Order {
  id: ID!
  status: OrderStatus!
  items: [LineItem!]!        # Only fetched when requested
  shipping: ShippingInfo     # Only fetched when requested
  payment: PaymentInfo       # Only fetched when requested
}

A mobile client that only needs order.id and order.status now gets exactly those two fields -- not the full 6 KB payload.

Step 3: Create the Resolver Layer with DataLoader Batching

For each GraphQL type, generate resolvers in src/graphql/resolvers/ that call
the existing REST service functions directly (not over HTTP). Use DataLoader
for batching — especially for orders->products and products->reviews
relationships. Add error mapping from REST status codes to GraphQL errors.

This is where the strangler-fig pattern shines. Resolvers call the existing service functions -- the same getOrderById(), getProductsByIds(), and getUserById() that the REST handlers call. No HTTP round-trips, no duplicated business logic. The GraphQL layer is a thin query interface on top of proven code.

DataLoader solves the N+1 problem that would otherwise kill performance. When a query requests 50 orders with their products, DataLoader batches those 50 individual getProductById() calls into a single getProductsByIds([...]) call. Without it, a list of 50 orders with products would fire 50 separate database queries.

Error mapping translates REST conventions to GraphQL -- and this is trickier than it sounds. A REST 404 becomes a null field with an optional error in the errors array (because in GraphQL, a missing product in a list shouldn't fail the whole query). A 401 becomes an UNAUTHENTICATED error code that triggers the client's auth refresh flow. A 422 maps validation errors to field-level error extensions so the UI can highlight the exact form field that failed.

The resolver layer also handles the authentication pass-through: the GraphQL context extracts the JWT from the request headers and passes the authenticated user to every resolver, exactly like the REST middleware does today.

Step 4: Set Up the Compatibility Proxy

Configure the API gateway so that existing REST routes still work unchanged.
Add a /graphql endpoint alongside them. Write an Express middleware that checks
the Accept header — requests with application/json hit REST, requests with
application/graphql hit the new layer. Add request logging to track which
clients still use REST.

Both interfaces run side by side on the same server. Existing REST routes work exactly as before -- no client changes needed. The new /graphql endpoint accepts queries from clients that have migrated. An Express middleware routes based on the Accept header, so the cutover is per-client and per-request.

Request logging tracks REST usage by endpoint and by client (via API key or User-Agent). A simple dashboard shows which clients still hit which REST endpoints and how frequently. When traffic on a REST endpoint drops to zero for 30 days, it's safe to deprecate. Until then, it stays live and fully functional.

This is the key advantage of strangler-fig over big-bang: zero coordination required between teams, zero downtime, zero risk to existing clients. The React web app can migrate to GraphQL this sprint while the mobile apps stay on REST for another quarter, and the third-party integrations can take as long as they need. Each client migrates at its own pace.

Step 5: Validate with Parallel Testing

Write integration tests that call both the old REST endpoint and the equivalent
GraphQL query, then diff the responses. Run them against GET /products/:id,
GET /orders?status=pending, and GET /users/:id. Report any field mismatches
or missing data.

Parallel tests call both the REST endpoint and the equivalent GraphQL query for the same data, then diff the responses field by field:

Endpoint PairResultIssue
GET /products/:id vs query { product(id: "p-42") { ... } }Match--
GET /orders?status=pending vs query { orders(status: PENDING) { ... } }MismatchREST returns created_at, GraphQL returns createdAt
GET /users/:id vs query { user(id: "u-1") { ... } }MatchAfter alias fix

The created_at vs createdAt mismatch is exactly the kind of subtle inconsistency that parallel testing catches before it reaches production. The REST API uses snake_case (common in Ruby and Python ecosystems), while the GraphQL schema followed JavaScript convention with camelCase. A field alias in the resolver resolves it -- the database column stays created_at, the REST response stays created_at, but the GraphQL field is createdAt with the resolver mapping transparently.

After the alias fix, all three endpoint pairs produce identical data. These parallel tests run in CI on every PR that touches resolvers, so future changes can't introduce field mismatches without immediate detection.

Real-World Example

A lead backend engineer at a mid-size e-commerce company manages an API consumed by a React web app, two native mobile apps, and three third-party integrations. The mobile team has been complaining for months about slow list screens -- the orders endpoint returns 6 KB per item when they need 400 bytes, and on cellular connections the wasted bandwidth is visible in scroll jank and loading spinners.

She asks the agent to audit all 34 endpoints and rank them by over-fetch ratio. The agent generates a GraphQL schema covering the top three domains in 20 minutes. Resolvers are scaffolded with DataLoader batching, reusing the existing service functions that have been battle-tested in production for years. The compatibility proxy routes old clients to REST and new clients to GraphQL -- both running on the same server.

Parallel tests catch the one field-naming inconsistency before anything ships. After deploying, the mobile team migrates the orders screen to GraphQL. Payload drops 82% -- from 6 KB per order to 1.1 KB. List screens load in under a second on 3G. No existing client breaks. The REST endpoints stay live, gradually losing traffic as teams migrate screen by screen.

Three months later, 60% of traffic flows through GraphQL. The BFF layer that stitched together multiple REST calls is gone -- GraphQL queries handle the composition natively. The backend team ships features faster because a single schema change serves all clients, instead of coordinating three endpoint updates across backend, BFF, and mobile.

The REST endpoints for Users and Products still serve the three third-party integrations, which have no incentive to migrate. That's fine -- the compatibility proxy keeps them working indefinitely with zero maintenance cost. The REST traffic dashboard shows a slow decline as integrators update at their own pace. One of them migrated to GraphQL voluntarily after seeing the mobile team's payload reduction numbers.

The biggest surprise: backend development velocity increased. Adding a new field to the API used to require updating the REST endpoint, the BFF transformation, and notifying mobile to update their parsing. Now it's one line in the schema, one line in the resolver, and every client gets it immediately -- or ignores it if they don't need it. The schema itself serves as living API documentation, replacing the Swagger docs that were perpetually out of date.

The mobile team's bandwidth savings translate directly to better user experience: order list screens that used to take 3 seconds on 3G now load in under 1 second. Users on the mobile app notice the difference and the app store ratings tick up from 3.8 to 4.3 stars over the quarter.