REST to GraphQL: Design the Schema Yourself, Delegate the Resolvers
The schema is an hour of design. The resolvers are two days of typing. Delegating the wrong one of those produces a graph shaped exactly like the REST API you were leaving.
Why this migration suits an agent and still goes wrong
REST-to-GraphQL is unusually well suited to automation. Both ends are described by schemas, the mapping is largely mechanical, and the work is voluminous and dull — dozens of resolvers that each wrap an existing endpoint.
It goes wrong in a specific way. A resolver generated per REST endpoint
produces a GraphQL API shaped exactly like the REST API, which is the one
outcome the migration was supposed to avoid. You end up with
getUserById, getUserByEmail and
getUserWithOrders as three separate queries, because that is what
the three endpoints were.
Decide the schema yourself
The schema is the design work, and it is the part not to delegate. An agent given a set of endpoints will faithfully transcribe their shape; it has no way to know which fields belong on which type, where the natural edges are, or which endpoints exist only because of a framework limitation you no longer have.
type Query {
getUserById(id: ID!): User
getUserByEmail(email: String!): User
getUserOrders(userId: ID!): [Order!]!
getOrderItems(orderId: ID!): [Item!]!
}
type Query {
user(by: UserLookup!): User
}
type User {
id: ID!
email: String!
orders(first: Int, after: String): OrderConnection!
}
type Order {
id: ID!
items: [Item!]!
}
The second is a graph. The first is a list of endpoints with different punctuation. Write the second yourself, then hand it over — the schema is an hour of design and the resolvers are two days of typing.
Give it both schemas
With the target schema decided, the task becomes a mapping between two machine-readable descriptions, which is exactly the shape an agent handles well.
## Migration inputs
- `schema.graphql` — the TARGET. Authoritative. Never edit it.
- `openapi.yaml` — the CURRENT REST surface, generated from code.
- `src/resolvers/` — where new resolvers go, one file per type.
## Rules
- Every resolver calls the existing REST handler. Do not reimplement
business logic — call `src/api/handlers/`, do not copy from it.
- If the target schema needs a field REST does not expose, stop and
say so. Do not invent a data source.
- Resolvers are thin. Any logic beyond shape-mapping is a signal
that the schema is wrong. Flag it.
The second rule prevents the migration's worst outcome: business logic duplicated into resolvers, which then drifts from the REST implementation and produces two systems that disagree about the same rule.
The N+1 problem is the whole technical risk
A naive resolver per field produces a query pattern that hammers your backend. Requesting fifty users with their orders issues one call for the users and fifty for the orders, and nothing about the resolver looks wrong.
const resolvers = {
User: {
// One HTTP call per user. Fifty users, fifty calls.
orders: (user) => api.getUserOrders(user.id),
},
};
import DataLoader from "dataloader";
// One loader per request, never global — it caches.
const makeOrderLoader = () =>
new DataLoader(async (userIds: readonly string[]) => {
const all = await api.getOrdersForUsers([...userIds]);
const byUser = new Map<string, Order[]>();
for (const o of all) {
(byUser.get(o.userId) ?? byUser.set(o.userId, []).get(o.userId)!)
.push(o);
}
return userIds.map((id) => byUser.get(id) ?? []);
});
const resolvers = {
User: {
orders: (user, _args, ctx) => ctx.loaders.orders.load(user.id),
},
};
An agent will write the naive version unless told otherwise, because it is correct, simpler, and passes every test you are likely to have. Put the batching requirement in your instructions, and add a test that asserts call counts — it is the only thing that catches a regression here.
test("fetching 50 users with orders issues 2 backend calls", async () => {
const spy = jest.spyOn(api, "request");
await execute(gql`{ users(first: 50) { id orders { id } } }`);
expect(spy).toHaveBeenCalledTimes(2); // not 51
});
Running both, then retiring one
The migration that succeeds runs both surfaces against one implementation until consumers have moved.
1. Target schema, designed by a human, reviewed.
2. Resolvers wrapping existing handlers. REST untouched.
3. Both live. New consumers use GraphQL; old ones do not move.
4. Instrument REST endpoints — who still calls them, how often.
5. Retire endpoints with no callers, one at a time.
Step four is the one to build early. Without per-endpoint caller instrumentation, step five never happens — nobody can prove an endpoint is unused, so it stays forever and you maintain two APIs indefinitely. That is the common failure of this migration, and it is an observability problem rather than a code problem.
What the agent is genuinely good at here
Three parts of this migration are worth delegating without hesitation, because they are voluminous, mechanical and verifiable.
| Task | Why it delegates well |
|---|---|
| Type definitions from OpenAPI components | Pure translation; the type checker verifies it |
| Resolver stubs for every schema field | Exhaustive and dull; missing ones fail at startup |
| Input validation from OpenAPI constraints | Transcribing rules that already exist elsewhere |
| Integration tests per query | Compare GraphQL response to the REST response |
The last row deserves emphasis: a test that calls both surfaces and asserts the same data comes back is the strongest verification available in this migration, and it is exactly the tedious work nobody wants to write.
test.each(FIXTURE_USER_IDS)("user %s matches REST", async (id) => {
const rest = await api.getUserById(id);
const { data } = await execute(
gql`query($id: ID!) { user(by: {id: $id}) { id email name } }`,
{ id },
);
expect(data.user).toEqual({
id: rest.id, email: rest.email, name: rest.name,
});
});
What to verify per resolver
- It calls the handler, not a reimplementation. Grep the resolver directory for business-logic imports; there should be none.
- Nullability matches reality. A field typed
String!that the REST endpoint can return null for will throw at runtime. Agents get this wrong routinely, because OpenAPI nullability is often unspecified. - Errors map sensibly. A 404 from REST should become a null field or a typed error, not an exception that nulls the whole response.
- Auth is enforced per field. REST enforced it per endpoint. Field-level access is a new surface, and the default is to forget it entirely.
The last is the one with security consequences. In REST, an endpoint the caller cannot reach is inaccessible. In GraphQL, a field reachable through another type's edge is reachable unless something checks — and the migration is where that check gets missed.
Design the schema yourself; delegating it produces a graph shaped like your endpoint list. Give the agent both schemas and require resolvers to call existing handlers rather than reimplement them. Specify batching explicitly and assert call counts in a test. Instrument REST callers early, or you will never retire anything.