GraphQL to REST Migration: A Practical, Low‑Risk Guide

Step-by-step guide to migrate APIs from GraphQL to REST: design mapping, caching, auth, rollout, and pitfalls to avoid.

ASOasis
8 min read
GraphQL to REST Migration: A Practical, Low‑Risk Guide

Image used for representation purposes only.

Why Migrate from GraphQL to REST?

GraphQL and REST solve many of the same problems in different ways. GraphQL excels at client-driven data composition and single-endpoint flexibility. REST shines when you want strong HTTP semantics, cacheability, and simpler operational tooling. Teams consider moving from GraphQL to REST when:

  • Most clients request similar shapes of data, making GraphQL’s field-level flexibility underused.
  • You want to lean on CDN/HTTP caching, conditional requests, and observability built around URLs, methods, and status codes.
  • Query cost control and authorization have become complex due to arbitrary client queries.
  • You need to simplify the platform for partner developers or align with enterprise governance and API gateways optimized for REST and OpenAPI.

This guide walks you through a pragmatic, low-risk migration that preserves client value while improving operability.

Principles for a Safe Migration

  • Favor evolution over revolution: migrate incrementally, with parallel runs and strong observability.
  • Map intent, not syntax: translate user value from GraphQL operations to REST resources and affordances.
  • Use the Strangler Fig pattern: introduce REST alongside GraphQL, move traffic feature-by-feature, and retire GraphQL only when metrics prove parity.
  • Be explicit about semantics: leverage HTTP methods, status codes, caching, and content negotiation to express behavior clearly.

Phase 0: Inventory, Telemetry, and Risk Assessment

Before writing a single endpoint, understand your real usage.

  1. Collect real traffic

    • Enable operation-level tracing in your GraphQL server.
    • Capture: operation name, variables, response size, latency, error rates, and auth context.
    • Rank by call volume and business criticality (e.g., checkout, payments, identity).
  2. Classify operations

    • Queries → candidates for GET endpoints.
    • Mutations → POST/PUT/PATCH/DELETE.
    • Subscriptions → webhooks, Server-Sent Events (SSE), or long polling.
  3. Identify data shapes

    • Common field selections suggest canonical REST representations.
    • Detect over-fetching/under-fetching patterns to guide sparse fieldsets and compound responses.
  4. Non-functional requirements

    • Cache behavior, rate limits, SLOs, regionalization, PII flows, and audit requirements.

Deliverable: a prioritized backlog of REST resources and behaviors to design first.

Designing Your REST Surface

Strong REST designs start with nouns (resources) and state transitions.

  1. Resource modeling

    • Nouns: customers, orders, items, invoices, shipments.
    • Hierarchies: /customers/{id}/orders, /orders/{id}/items.
    • Canonical identifiers: stable IDs in URLs; avoid opaque global IDs unless necessary.
  2. Methods and idempotency

    • GET for retrieval (safe, cacheable).
    • POST for create and non-idempotent actions.
    • PUT/PATCH for updates (define idempotency precisely).
    • DELETE for deletion (idempotent side effects encouraged).
  3. Status codes and errors

    • 2xx for success with nuance: 200 (OK), 201 (Created), 202 (Accepted), 204 (No Content).
    • 4xx for client issues: 400, 401, 403, 404, 409, 422, 429.
    • 5xx for server issues.
    • Error body: include a machine-readable code, human message, and trace id.
{
  'error': {
    'code': 'invalid_parameter',
    'message': 'limit must be between 1 and 100',
    'trace_id': 'b1c6...'
  }
}
  1. Pagination, filtering, sorting

    • Prefer cursor-based pagination for large datasets: page[cursor], page[size].
    • Filtering via query params: filter[customerId]=…, filter[status]=….
    • Sorting: sort=-createdAt, status.
  2. Sparse fieldsets and includes

    • fields[orders]=id,total,createdAt
    • include=items,customer to avoid chattiness while keeping discoverability.
  3. Caching and conditional requests

    • Use Cache-Control, ETag, Last-Modified, and conditional headers (If-None-Match) to unlock CDN and browser caches.
  4. Versioning

    • Start with header-based (Accept: application/vnd.company.v1+json) or URL (/v1/…).
    • Reserve room for non-breaking evolution via field addition and soft-deprecations.
  5. Content and formats

    • Consistent JSON envelope, timestamps in RFC 3339, money in integer minor units or ISO 4217, and locale-agnostic formats.
  6. Hypermedia (optional)

    • Provide links for discoverability (self, related) while keeping responses compact.

Mapping GraphQL to REST: Patterns

  1. Queries → GET endpoints

GraphQL

query GetOrders($customerId: ID!, $limit: Int!, $cursor: String) {
  orders(customerId: $customerId, first: $limit, after: $cursor) {
    edges { node { id total createdAt items { id sku quantity } } }
    pageInfo { hasNextPage endCursor }
  }
}

REST

GET /orders?filter[customerId]=123&fields[orders]=id,total,createdAt&include=items&page[size]=20&page[cursor]=abc
Accept: application/json
  1. Mutations → POST/PUT/PATCH/DELETE

GraphQL

mutation AddItem($orderId: ID!, $input: AddItemInput!) {
  addItem(orderId: $orderId, input: $input) { id total items { id sku quantity } }
}

REST

POST /orders/987/items
Content-Type: application/json

{
  'sku': 'SKU-123',
  'quantity': 2
}

For partial updates, prefer PATCH with JSON Merge Patch or JSON Patch and document exact semantics.

  1. Subscriptions → Webhooks or SSE
  • Subscriptions to order status can become:
    • Webhooks: POST to partner endpoints when status changes.
    • SSE: GET /orders/events?filter[customerId]=123 with a streaming response.
  1. Interfaces and unions
  • Use a discriminant field (type) in REST payloads or OpenAPI oneOf to model variant shapes.
  1. Batching and composition
  • Replace GraphQL composition with either:
    • Server-composed compound documents using include=… to fetch related resources.
    • BFF (backend-for-frontend) layer that orchestrates multiple REST calls for a specific client.

Example: End-to-End Mapping

GraphQL create-then-read pattern:

mutation CreateOrder($input: CreateOrderInput!) {
  createOrder(input: $input) { id }
}

query GetOrder($id: ID!) {
  order(id: $id) { id total createdAt items { id sku quantity } }
}

REST equivalent:

POST /orders
Content-Type: application/json

{ 'customerId': '123', 'currency': 'USD' }

201 Created
Location: /orders/789

GET /orders/789?include=items&fields[orders]=id,total,createdAt

Performance and Caching

GraphQL responses are often non-cacheable at the edge due to POST and variable query shapes. With REST you can:

  • Make list/detail GETs cacheable with Cache-Control: public, max-age=60, s-maxage=300.
  • Use ETags for validation. Clients send If-None-Match; server responds 304 if unchanged.
  • Exploit URL-based caching keys: identical URLs should yield identical representations.
  • Provide bulk endpoints for analytics (e.g., POST /orders/queries:async to request a large export → 202 Accepted + polling URL).
  • Avoid N+1 at the client by offering include=… and well-designed sub-resources.

Security and Authorization

  • Authentication: Bearer tokens or mTLS; keep it consistent across GraphQL and REST during transition.
  • Authorization: Map GraphQL field-level rules to resource- or attribute-level checks in REST; consider scopes aligned to resources (orders:read, orders:write).
  • Input validation: Strong server-side validation with precise 4xx codes; prefer 422 for semantic errors.
  • CORS and CSRF: For browser clients, set CORS policies and use same-site cookies or double-submit tokens as needed.
  • Audit trails: Log method, path, principal, resource id, and correlation id.

Tooling for REST Success

  • OpenAPI: the contract of record for endpoints, parameters, and schemas.
  • Mock servers: speed up client integration.
  • Code generation: produce SDKs from OpenAPI for common languages.
  • Contract tests: ensure server and clients remain compatible.
  • Linting and style guides: enforce consistency in naming, pagination, and errors.

OpenAPI (YAML) snippet:

openapi: 3.1.0
info:
  title: Orders API
  version: 1.0.0
paths:
  /orders:
    get:
      parameters:
        - name: filter[customerId]
          in: query
          schema: { type: string }
        - name: page[size]
          in: query
          schema: { type: integer, minimum: 1, maximum: 100 }
      responses:
        '200':
          description: List orders
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderList'

Rollout and Migration Pattern

  1. Build a REST façade alongside GraphQL

    • Implement high-traffic read endpoints first for quick cache wins.
    • Keep the GraphQL server as an orchestrator calling your new REST services during the transition, or vice versa with an adapter.
  2. Shadow and canary

    • Shadow REST with production GraphQL traffic to compare payloads and performance.
    • Canary small percentages of client traffic to REST; watch latency, error rates, and cache hit ratios.
  3. Communicate and document

    • Publish OpenAPI, changelogs, and migration cookbook examples.
    • Provide SDKs and sample requests.
  4. Dual-write for mutations (optional, time-bounded)

    • Temporarily write via both paths to ensure data parity; remove once verified.
  5. Deprecation strategy

    • Announce dates early and repeatedly with absolute dates (e.g., “GraphQL endpoint EOL on February 28, 2027”).
    • Provide automated warnings: deprecation headers or error messages after the freeze date.
  6. Rollback plan

    • Keep feature flags to route traffic back to GraphQL quickly if needed.
    • Maintain operational parity (monitoring, alerts) on both paths during overlap.

Testing and Quality Gates

  • Golden tests: deterministic fixtures comparing GraphQL vs REST payloads for parity.
  • Consumer-driven contract tests: verify client expectations against the OpenAPI contract.
  • Property-based tests: validate pagination, sorting, and filtering invariants.
  • Load tests: confirm REST can handle surges and that caches behave as expected.

Observability and Reliability

  • Structured logs: method, path, user id, status, latency, cache status (HIT/MISS), trace id.
  • Metrics: P50/P95/P99 latency, 4xx/5xx rates, request volume per endpoint, ETag validation rate, rate-limit utilization.
  • Tracing: propagate correlation ids across REST calls and downstream services.
  • Error budgets and SLOs: e.g., 99.9% availability for GET /orders.

Common Pitfalls (and How to Avoid Them)

  • One-to-one field mapping: Resist creating an endpoint per former GraphQL field; design coherent resources.
  • Overly generic ‘/search’ endpoints: Define typed filters and predictable shapes.
  • Ignoring idempotency: Specify exactly when retries are safe; provide idempotency keys for POST where appropriate.
  • Pagination mismatches: Document cursor translation rules; don’t silently switch clients to offset paging for large datasets.
  • Breaking changes hidden as “fixes”: Use versioning; don’t repurpose fields.

Client Migration Playbook

  • Provide a migration matrix that maps GraphQL operations to REST endpoints.
  • Ship example snippets for each client platform.
  • Offer a compatibility adapter in SDKs that calls REST under the hood while exposing old GraphQL-like helpers during a grace period.

Example curl

curl -s \
  -H 'Authorization: Bearer <token>' \
  'https://api.example.com/v1/orders?filter[customerId]=123&fields[orders]=id,total,createdAt&include=items&page[size]=20'

Measuring Success

  • Reduced median latency for common reads (e.g., -30%).
  • High cache hit ratio at edge and origin for GETs.
  • Lower error rates due to clearer validation and status codes.
  • Developer adoption: SDK downloads, time-to-first-success.
  • Support volume: fewer tickets related to query design.

Checklist

Pre-launch

  • OpenAPI defined, linted, and reviewed
  • Error model consistent and documented
  • Caching strategy (ETag/Cache-Control) verified
  • Auth scopes mapped and tested
  • Observability dashboards ready

Launch

  • Shadow traffic comparison within tolerances
  • Canary ramp with SLOs guarded by feature flags
  • Client SDKs and examples published

Post-launch

  • Communicate deprecation milestones with absolute dates
  • Monitor adoption and cache metrics
  • Remove unused GraphQL operations progressively

Conclusion

Migrating from GraphQL to REST is not a step backward; it’s a strategic realignment to embrace the strengths of the web’s native application protocol. By auditing real usage, designing resourceful endpoints, leveraging HTTP semantics, and rolling out with discipline, you can reach a simpler, faster, and more cache-friendly API—without surprising your consumers. Treat the migration as a product, not just a refactor, and your developers—and your users—will feel the difference.

Related Posts