Workflow Architecture

REST to GraphQL: Design the Schema Yourself, Delegate the Resolvers

David Guzenburg/ / 9 min read

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.

GraphQLmigrationAPI designresolvers

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),
  },
};
State this explicitly or it will not happen

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.

TaskWhy it delegates well
Type definitions from OpenAPI componentsPure translation; the type checker verifies it
Resolver stubs for every schema fieldExhaustive and dull; missing ones fail at startup
Input validation from OpenAPI constraintsTranscribing rules that already exist elsewhere
Integration tests per queryCompare 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

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.

Takeaway

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.

Keep reading
Workflow Architecture

Java to Kotlin: The Converter Does Syntax, the Agent Does Semantics

Why mechanical conversion produces Kotlin that reads like Java, recovering nullability intent from database constraints and call sites, the staged sequence, and where JVM interop bites.

Workflow Architecture

Designing Migration Instructions: Modernising Without Changing Behaviour

Why framework migrations go wrong with agents, splitting mechanical from semantic work, the instructions that preserve behaviour, and batching so review stays possible.

Codex vs Claude

Fleet-Wide Maintenance: Generating Twenty Pull Requests Is the Easy Part

Batch updates across many repositories are where agents change what a small team can attempt. The constraint is merging, not generating, and the same change is usually three different changes. Surveying, piloting, and measuring repositories migrated.

Workflow Architecture

A DSL Fine-Tuning Pipeline: Validator First, Training Script Second

The QLoRA pipeline for a proprietary DSL once you have established the case: compiler-backed dataset validation, AST mutation for expansion, error-correction pairs, and evaluating on parse rate rather than loss.

← Structural Refactoring: Have the Agent Write the Transformation, Not the Edits  ·  CI Gating for Agent-Generated Pull Requests →

All workflow architecture articles  ·  Every article