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" } });
  }
}
LayerStatusMeaning
Transport400Malformed JSON or a missing query document
Validation200 with errorsUnknown field, wrong argument type - nothing executed
Execution200 with partial data and errorsOne resolver failed, others succeeded
Auth401 / 403, or a GraphQL error with a codeDepends on whether the whole request or one field failed
Internal200 with a masked errorNever 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-generator produces 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.

Schema and type system Authentication and field-level authorization

Last refreshed 2026-09-18.