Migrating a REST API to a Federated Subgraph

Wrapping an existing REST service as an Apollo Federation v2 subgraph lets you bring legacy data into the supergraph without rewriting the backend — you model its resources as entities, resolve them through RESTDataSource, and migrate clients off the REST endpoints one field at a time. This guide covers that pattern as part of Migrating and Versioning Federated Schemas.

When to Use This Pattern

  • You have a stable REST service that owns real business data and you want it queryable from the federated graph without a full GraphQL rewrite.
  • You are running a strangler-fig migration: a GraphQL subgraph fronts the REST API, clients move to GraphQL incrementally, and the REST endpoints are retired only once traffic drains.
  • The REST resources map cleanly onto entities other subgraphs already reference by key (a User, Product, or Order that lives in the supergraph).

Prerequisites

Modelling REST Resources as Entities

The first design step is deciding what is an entity and what is a plain type. A REST resource that has a stable identifier and is referenced from elsewhere in the graph — /users/:id, /products/:id — becomes a federation entity with @key matching that identifier. Nested or embedded REST objects that have no independent identity become regular object types.

Map the JSON shape to GraphQL deliberately rather than mechanically: REST APIs often expose snake_case fields, denormalised blobs, and string timestamps. The subgraph is your chance to present a clean, typed contract — camelCase field names, proper scalar types, and nullability that reflects what the REST API actually guarantees. A REST field that is sometimes absent should be a nullable GraphQL field; do not promise String! for something the upstream can omit.

When the entity already exists in another subgraph (say User is owned by an identity service), your REST subgraph extends it: it declares the entity with @key and contributes only the fields it owns, marking the key field @external. This is how the REST data joins the rest of the graph. The entity-resolution mechanics here are the same as any subgraph — see Implementing Entity Resolvers with @key Directives for the general pattern.

Implementation Walkthrough

The example wraps a REST users service. User is an entity keyed on id; the subgraph owns profile fields sourced from GET /users/:id. RESTDataSource handles the HTTP calls, request deduplication, and per-request caching, so a single query that touches the same user twice issues one upstream call.

# users-rest.graphql — Federation v2 subgraph SDL wrapping a REST service
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])

type User @key(fields: "id") {
  id: ID!
  # Mapped from REST snake_case to a clean GraphQL contract:
  fullName: String!          # from `full_name`
  email: String!
  # Nullable because the REST API may omit it:
  avatarUrl: String          # from `avatar_url`
}

type Query {
  user(id: ID!): User
}
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { buildSubgraphSchema } from '@apollo/subgraph';
import { RESTDataSource } from '@apollo/datasource-rest';
import { gql } from 'graphql-tag';
import { readFileSync } from 'node:fs';

// Shape of the upstream REST payload (snake_case, as the API returns it).
interface RestUser {
  id: string;
  full_name: string;
  email: string;
  avatar_url: string | null;
}

class UsersAPI extends RESTDataSource {
  override baseURL = 'https://internal-rest.svc/api/v1/';

  // Per-field auth: forward the caller's token to the REST service.
  constructor(private token?: string) {
    super();
  }
  override willSendRequest(_path: string, request: { headers: Record<string, string> }) {
    if (this.token) request.headers['authorization'] = this.token;
  }

  // RESTDataSource dedupes + caches GETs within a request automatically.
  async getUser(id: string): Promise<RestUser | null> {
    try {
      return await this.get<RestUser>(`users/${encodeURIComponent(id)}`);
    } catch (err: any) {
      if (err.extensions?.response?.status === 404) return null; // map 404 -> null
      throw err;
    }
  }
}

// Translate the REST payload into the GraphQL contract in one place.
const toUser = (r: RestUser) => ({
  id: r.id,
  fullName: r.full_name,
  email: r.email,
  avatarUrl: r.avatar_url,
});

const resolvers = {
  Query: {
    user: async (_: unknown, { id }: { id: string }, ctx: Ctx) => {
      const r = await ctx.dataSources.usersAPI.getUser(id);
      return r ? toUser(r) : null;
    },
  },
  User: {
    // The federation reference resolver: the router calls this with { id }
    // when another subgraph references a User. Same REST fetch, reused.
    __resolveReference: async (ref: { id: string }, ctx: Ctx) => {
      const r = await ctx.dataSources.usersAPI.getUser(ref.id);
      return r ? toUser(r) : null;
    },
  },
};

interface Ctx { dataSources: { usersAPI: UsersAPI } }

const typeDefs = gql(readFileSync('./users-rest.graphql', 'utf8'));
const server = new ApolloServer({ schema: buildSubgraphSchema({ typeDefs, resolvers }) });

await startStandaloneServer(server, {
  listen: { port: 4005 },
  context: async ({ req }) => {
    const token = req.headers.authorization;
    // New data source instance per request -> per-request cache + auth scope.
    return { dataSources: { usersAPI: new UsersAPI(token) } };
  },
});

The __resolveReference resolver is what makes this a true federation entity rather than a standalone GraphQL API. When another subgraph’s query references a User by id, the router sends an _entities request to this subgraph, and __resolveReference translates that into the same GET /users/:id call. Because RESTDataSource caches within a request, a query that resolves the same user from both the root field and an entity reference still hits REST once.

Incremental Strangler-Fig Migration

The point of fronting REST with a subgraph is to retire the REST endpoints gradually. The strangler-fig sequence:

  1. Stand up the subgraph read-only. Wrap the REST reads first. Clients keep calling REST, but the data is now also available through the graph.
  2. Publish and route a slice of clients. Add the subgraph to the supergraph and move one client surface (say, a new feature) to query GraphQL while everything else stays on REST.
  3. Expand field coverage. Add fields and entities to the subgraph as clients need them, each one an additive change validated by rover subgraph check.
  4. Move writes when ready. Add mutations that proxy to REST POST/PATCH, or — once the subgraph is the system of record — point them at the real datastore directly, bypassing REST.
  5. Drain and retire. When operation metrics show no client calling a given REST endpoint, decommission it. The graph absorbed the resource without a flag day.

This mirrors the staged approach in the parent guide, Migrating and Versioning Federated Schemas — additive growth, traffic-driven decommissioning, no big-bang cutover.

Publishing to the Supergraph

Once the subgraph composes locally, check it against the published supergraph and publish.

# Validate composition + breaking-change analysis against production.
rover subgraph check my-graph@prod --name users-rest --schema ./users-rest.graphql

# Publish; managed federation recomposes and the router hot-reloads.
rover subgraph publish my-graph@prod \
  --name users-rest \
  --schema ./users-rest.graphql \
  --routing-url https://users-rest.svc/graphql

Propagation to the router is handled by managed federation — see Schema Registry and Managed Federation for how published schemas reach running routers.

Verification

Confirm the entity resolves both as a root field and as a federation reference. First, a direct query through the router:

query { user(id: "u_123") { fullName avatarUrl } }

Then verify the _entities path that other subgraphs rely on by posting a representation directly to the subgraph:

curl -s https://users-rest.svc/graphql -H 'content-type: application/json' -d '{
  "query": "query($r:[_Any!]!){ _entities(representations:$r){ ... on User { fullName } } }",
  "variables": { "r": [{ "__typename": "User", "id": "u_123" }] }
}'
# Expect: { "data": { "_entities": [ { "fullName": "Ada Lovelace" } ] } }

A non-null _entities[0] confirms __resolveReference is wired correctly and the REST mapping is working end to end.

Common Mistakes & Gotchas

Sharing one RESTDataSource instance across requests. Create a fresh instance per request in context. A singleton leaks per-request cache and auth between callers — confidential data from one user can surface for another.

Returning String! for fields the REST API may omit. If the upstream sometimes drops avatar_url, the GraphQL field must be nullable. A non-null promise the REST API can’t keep turns into runtime Cannot return null for non-nullable field errors.

Forgetting that __resolveReference must hit the same source as the root resolver. If the root field reads from REST but the reference resolver reads stale cache or a different endpoint, entity joins return inconsistent data. Route both through the same data-source method, as in the example.

Frequently Asked Questions

Do I need to rewrite my REST service to add it to the supergraph?

No — that is the entire point of this pattern. The subgraph is a thin GraphQL layer over the existing REST API using RESTDataSource. You rewrite the backend later, if ever, after clients have migrated off the REST endpoints.

How does request caching work with RESTDataSource in a subgraph?

RESTDataSource deduplicates and caches GET requests within a single GraphQL request, and can use a shared cache (e.g. Redis) across requests when configured with HTTP cache headers. Within one query, resolving the same entity from a root field and an _entities reference issues a single upstream call.

Can other subgraphs reference an entity that’s backed by REST?

Yes. Once the entity has a @key and a __resolveReference that fetches from REST, it behaves like any federation entity. Other subgraphs reference it by key and the router fans out to the REST-backed subgraph through _entities — the REST origin is invisible to them.