Building a GraphQL server
Schema-first versus code-first, wiring an executable schema, building context per request, shaping errors and useful development tooling.
From SDL to a running server
// schema.graphql
const typeDefs = `
type Query {
book(id: ID!): Book
books(first: Int = 20, after: String): BookConnection!
}
type Book {
id: ID!
title: String!
author: Author!
}
type Author {
id: ID!
name: String!
books: [Book!]!
}
`;
// server.js
const resolvers = {
Query: {
book: (_parent, { id }, ctx) => ctx.loaders.bookById.load(id),
books: (_parent, args, ctx) => ctx.db.book.findPage(args),
},
Book: {
author: (book, _args, ctx) => ctx.loaders.authorById.load(book.authorId),
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => ({
db,
user: authenticate(req.headers.authorization),
loaders: createLoaders(),
}),
});- Schema-first keeps the SDL as the contract and the resolvers as an implementation detail; code-first builds the SDL from typed classes and suits a TypeScript-first team.
- Resolvers receive
(parent, args, context, info). Only the root fields are given arguments in the usual sense; nested fields receive the parent object. - Build context once per request. It is your dependency container, and the only safe place to hold the authenticated user.
- A resolver returning a promise is fine - the executor awaits it and resolves the rest of the tree concurrently.
⚠️
Change the schema from the outside in. Adding a non-null field to an existing type breaks every client that does not select it. Add nullable fields, deprecate old ones, remove them only after clients stop using them.
Transport, execution and errors
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formatted) => {
const code = formatted.extensions?.code;
// never expose internals to clients
if (code === "INTERNAL_SERVER_ERROR") {
return { message: "Internal server error", extensions: { code } };
}
return { message: formatted.message, extensions: { code, ...formatted.extensions } };
},
});
// a domain error with a stable code
class BookingClosedError extends GraphQLError {
constructor() {
super("Booking window has closed", { extensions: { code: "BOOKING_CLOSED" } });
}
}| Layer | Status | Meaning |
|---|---|---|
| Transport | 400 | Malformed JSON or a missing query document |
| Validation | 200 with errors | Unknown field, wrong argument type - nothing executed |
| Execution | 200 with partial data and errors | One resolver failed, others succeeded |
| Auth | 401 / 403, or a GraphQL error with a code | Depends on whether the whole request or one field failed |
| Internal | 200 with a masked error | Never leak a stack trace |
GraphQL returns 200 for most failures because the response is a document, not a single outcome. Clients must check errors even when data is present - a partial response with a null field is the normal representation of a failed resolver.
Development tooling
- Serve an explorer in development (Apollo Sandbox, GraphiQL) and turn it off in production.
graphql-code-generatorproduces typed hooks and types from the schema, so a schema change becomes a compile error rather than a runtime surprise.- Log the operation name, the query hash and the duration for every request - never the raw variables, which often contain personal data.
- Run the schema through a linter in CI to enforce naming and descriptions, and check for breaking changes between the deployed schema and the branch.
// a plugin that measures per-field timing cheaply
const timing = {
async requestDidStart() {
return {
async willSendResponse({ response, contextValue }) {
metrics.observe({
operation: contextValue.operationName,
errors: response.errors?.length ?? 0,
});
},
};
},
};FAQ
Schema-first or code-first?
Schema-first for a shared, language-agnostic contract that other teams review as a document. Code-first for a TypeScript monorepo where the types are the source of truth. Both produce the same SDL; pick the workflow your team will actually maintain.
Should I expose one endpoint or several schemas?
One schema per product boundary. Splitting the schema per client recreates the endpoint sprawl GraphQL was meant to remove. When different teams own different domains, use federation rather than separate gateways.
Related
Schema and type system Authentication and field-level authorization
Last refreshed 2026-09-18.