StackPractices
intermediate By Mathias Paulenko

Implement a GraphQL API

Build a production-ready GraphQL API with typed schemas, resolvers, and mutations in Python, JavaScript, and Java.

Topics: api

Overview

GraphQL is a query language and runtime for APIs where the client declares exactly which fields it needs. Instead of REST’s fixed response shapes, one /graphql endpoint serves arbitrary field combinations — which is why it fits apps whose screens pull different slices of the same data.

This recipe builds a working GraphQL API in three stacks: Python with Strawberry and FastAPI, JavaScript with Apollo Server 4, and Java with Spring for GraphQL. Each example carries both a Query and a Mutation, so the whole contract shows up in code.

When to Use

Use this resource when:

  • Your clients need flexible data fetching (mobile apps with limited bandwidth, dashboards with varying layouts).
  • You want a strongly typed API contract with free introspection-based documentation.
  • You need to aggregate data spread across microservices — for service-to-service calls inside your own cluster, gRPC is usually a better fit.
  • Your API consumers frequently request different field combinations.

Solution

Python

Strawberry + FastAPI is the modern pairing: types come from decorators and the router mounts under /graphql.

import strawberry
from fastapi import FastAPI
from strawberry.fastapi import GraphQLRouter

BOOKS = [
    {"title": "Clean Code", "author": "Robert C. Martin", "pages": 464},
    {"title": "The Pragmatic Programmer", "author": "Andy Hunt", "pages": 352},
]

@strawberry.type
class Book:
    title: str
    author: str
    pages: int

@strawberry.type
class Query:
    @strawberry.field
    def books(self) -> list[Book]:
        return [Book(**b) for b in BOOKS]

@strawberry.type
class Mutation:
    @strawberry.mutation
    def add_book(self, title: str, author: str, pages: int) -> Book:
        BOOKS.append({"title": title, "author": author, "pages": pages})
        return Book(title=title, author=author, pages=pages)

schema = strawberry.Schema(query=Query, mutation=Mutation)
app = FastAPI()
app.include_router(GraphQLRouter(schema), prefix="/graphql")

Run uvicorn main:app --reload and open http://localhost:8000/graphql — Strawberry ships GraphiQL at that route.

JavaScript

Apollo Server 4 splits transport from core: startStandaloneServer gives you a complete server without a web framework.

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

const typeDefs = `#graphql
  type Book {
    title: String!
    author: String!
    pages: Int!
  }

  type Query {
    books: [Book!]!
  }

  type Mutation {
    addBook(title: String!, author: String!, pages: Int!): Book!
  }
`;

const books = [
  { title: 'Clean Code', author: 'Robert C. Martin', pages: 464 },
  { title: 'The Pragmatic Programmer', author: 'Andy Hunt', pages: 352 },
];

const resolvers = {
  Query: {
    books: () => books,
  },
  Mutation: {
    addBook: (_, { title, author, pages }) => {
      const book = { title, author, pages };
      books.push(book);
      return book;
    },
  },
};

const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
});
console.log(`Server ready at ${url}`);

Save as index.mjs (top-level await needs ESM) and run node index.mjs.

Java

Spring for GraphQL is the current standard — the schema lives in src/main/resources/graphql/schema.graphqls and resolvers are controller methods:

// src/main/resources/graphql/schema.graphqls
// type Book { title: String!, author: String!, pages: Int! }
// type Query { books: [Book!]! }
// type Mutation { addBook(title: String!, author: String!, pages: Int!): Book! }

@Controller
public class BookController {

    private final List<Book> books = new ArrayList<>(List.of(
        new Book("Clean Code", "Robert C. Martin", 464),
        new Book("The Pragmatic Programmer", "Andy Hunt", 352)
    ));

    @QueryMapping
    public List<Book> books() {
        return books;
    }

    @MutationMapping
    public Book addBook(@Argument String title,
                        @Argument String author,
                        @Argument int pages) {
        Book book = new Book(title, author, pages);
        books.add(book);
        return book;
    }
}

record Book(String title, String author, int pages) {}

Add spring-boot-starter-graphql to the project; the endpoint lands on /graphql by default. Avoid the old graphql-java-servlet / com.coxautodev libraries — they’re unmaintained and still carry javax.servlet.

Subscriptions

Subscriptions ride a WebSocket transport, but the shape is the same everywhere: an async stream that yields values as events happen. In Strawberry a subscription is just an async generator:

@strawberry.type
class Subscription:
    @strawberry.subscription
    async def book_added(self) -> AsyncGenerator[Book, None]:
        while True:
            yield await BOOK_EVENTS.get()  # asyncio.Queue fed by add_book

In JavaScript the convention is graphql-ws under the WS protocol; Spring wires @SubscriptionMapping methods to its WebSocket endpoint the same way. If you don’t need live push, skip it — polling a query is often enough.

Explanation

Under the hood a GraphQL API is three pieces:

  • Schema: the typed contract — types, queries, mutations, subscriptions — written in SDL (the typeDefs / schema.graphqls file) or generated from code.
  • Resolvers: one function per field that actually returns data. They receive (parent, args, context, info); keep them stateless.
  • Server: accepts the HTTP request, parses the query, validates it against the schema, then executes resolvers and assembles data + errors.

The request lifecycle is worth having in your head:

flowchart diagram: Client -->|POST /graphql| HTTP[HTTP layer

Two details the lifecycle diagram explains:

  • A 200 can still be a failure. Validation and execution errors come back inside errors with HTTP 200 — always check that array client-side (more patterns in GraphQL error handling).
  • Resolvers run per field, not per request. A query touching books { author { books { ... } } } calls one resolver per book — that’s where the N+1 problem lives and why DataLoader batching exists.

Language differences worth knowing:

  • Python (Strawberry): code-first — decorators generate the SDL from your classes.
  • JavaScript (Apollo): schema-first — SDL in typeDefs, resolvers as a plain object.
  • Java (Spring): schema-first — .graphqls file plus @QueryMapping/@MutationMapping methods.

Variants

TechnologyLibraryApproachNotes
PythonStrawberryCode-firstDecorators, FastAPI integration
PythonGrapheneCode-firstDjango integration, mature ecosystem
JavaScriptApollo ServerSchema-firstFederation, subscriptions, most-used
JavaScriptNexusCode-firstTypeScript-first, type inference
JavaSpring for GraphQLSchema-firstSpring Boot starter, current standard
JavaDGS FrameworkCode-firstNetflix open-source, Spring integration

When Not to Use This Approach

  • Simple CRUD with a stable schema: for 5-10 endpoints with predictable shapes, REST is simpler, easier to debug, and caches for free.
  • CDN-cached public APIs: GraphQL POSTs bypass edge caching by default. For read-heavy public data, REST GETs plus a CDN deliver 10-100x cheaper throughput.
  • Bandwidth-constrained clients: schema introspection and larger payloads add real overhead on 2G/3G or IoT links.

Security Considerations

GraphQL’s flexibility is also its attack surface. These are the checks that matter:

  • Query depth limiting: nested queries like user { friends { friends { ... } } } exhaust memory. Cap depth at 7-10 levels with graphql-depth-limit or your framework’s equivalent.
  • Query complexity analysis: assign a cost per field and reject queries over a budget — a single client query can fan out into 5-15 internal calls in a federated setup.
  • Batch query attacks: one POST carrying many operations bypasses per-request rate limits. Cap the batch count (5 is sane) and rate-limit per operation.
  • Introspection in production: disable it (introspection: false) or gate it behind auth — it maps your entire API surface for an attacker.
  • Persisted queries: in production, serve only server-side approved queries by hash. It removes arbitrary query execution entirely.
  • Errors must not leak internals: strip stack traces and extensions detail in production builds.
  • Mutations and CSRF: if the endpoint accepts cookies, require a custom header or move mutations to token auth.

Best Practices

  • Use DataLoader for N+1 queries: batch and dedupe per-request DB calls — it turns 1 + N queries into 2. See the batching recipe for mechanics.
  • Paginate collections: use cursor-based pagination (Relay Connection spec) for lists beyond a page size.
  • Validate input early: custom scalars and directives catch bad input before resolvers run.
  • Deprecate, don’t version: evolve the schema with @deprecated fields instead of /v2 endpoints.
  • Keep resolvers thin: put business logic in a service layer; resolvers only adapt HTTP/GraphQL plumbing to it.
  • Don’t leak DB models: map domain objects to schema types instead of exposing ORM entities directly.

Common Mistakes

  • Ignoring the errors array: clients that check only data silently drop failures — GraphQL returns 200 with errors: [...] for validation and resolver failures alike.
  • No depth or complexity limits: one hostile query can exhaust the server. Set maxDepth and maxComplexity in validation rules from day one.
  • N+1 resolvers: fetching related objects per-row instead of batching — a query for 100 users with their posts becomes 101 DB hits.
  • Storing state in resolvers: resolvers are shared across requests; request-scoped data belongs in context.
  • Returning errors with stack traces: production error extensions should hide internals.
  • Skipping a domain layer: wiring ORM entities straight into the schema couples your API contract to your tables.

Monitoring and Observability

  • Track per-resolver latency: p50/p95/p99 per field — slow resolvers (p95 >100ms) are the usual bottleneck, not the HTTP layer.
  • Log query complexity scores: alert when the weekly average jumps >20%; it usually means a client started pulling deeper graphs.
  • Watch the batch ratio: DataLoader batches vs. total calls under ~80% signals N+1 creeping back.
  • Cap subscription connections: WebSocket subscriptions hold memory per client; alert before you hit FD or heap limits.

Testing Strategy

  • Resolvers in isolation: call them with a mock context and args; assert return values and error paths.
  • Schema introspection tests: assert the deployed schema exposes only intended fields — catches accidental exposure before clients find it.
  • Complexity-limit tests: fire deeply nested and high-field queries; verify the analyzer rejects them with a clear error.
  • Subscription lifecycle: connect, receive updates, disconnect, confirm the server frees the subscription.

Performance and Cost Notes

GraphQL adds parsing + validation + per-field resolution overhead over REST — typically 2-5x latency for equivalent reads with no caching. The levers that close the gap, in order of effort:

  1. Persisted queries — skip parsing entirely on repeat traffic.
  2. Response caching — cache by query hash in Redis for read-heavy endpoints (60s TTL is a reasonable start).
  3. DataLoader — collapse per-field DB calls into batches.
  4. Federated gateway caching — cache sub-resolver results at the gateway to avoid duplicate subgraph calls.

Cost-wise, the self-hosted story is cheap: Apollo Server or Strawberry run on commodity instances, and the managed tiers (Apollo Cloud, Hasura Cloud) mostly buy you the schema registry and managed caching rather than raw throughput.

Production Checklist

  • Depth limit (7-10) and complexity budget enforced in validation rules
  • Introspection disabled or gated in production
  • Persisted queries enforced; arbitrary queries rejected
  • DataLoader batching on every resolver that touches a DB
  • Rate limiting per operation and per batch count, not just per request
  • Error responses strip stack traces and internal extensions
  • Pagination (cursor-based) on all collection fields
  • Subscription connection caps and heartbeat timeouts configured
  • Schema changes reviewed for breaking fields before deploy
  • Schema registered in Apollo Studio or equivalent registry

Troubleshooting

  • Client gets data but errors is non-empty: partial failure — some resolvers failed while others succeeded. Read errors[].path to find the field that broke; check its resolver’s logs, not the HTTP status.
  • “Cannot query field” in production but works locally: introspection is off but the client relies on schema download, or the query uses a field that doesn’t exist in the deployed schema — regenerate client types from the registry.
  • Memory climbs under nested queries: depth/complexity limits missing or too generous. Profile the execution plan size of the offending query.
  • Slow responses despite fast DB: resolvers awaiting serially per row — that’s N+1; add DataLoader or a findByIds batch.
  • CORS errors from the browser: the GraphQL endpoint needs Access-Control-Allow-Origin on preflight like any API route — see CORS handling.

See Also

Frequently Asked Questions

Should I migrate my REST API to GraphQL?

Not necessarily. GraphQL pays off when clients need flexibility — varying field sets, deep nesting, one round-trip for a complex screen. If your consumers are few and stable, REST stays simpler and cheaper to cache.

How do I handle file uploads in GraphQL?

Use the GraphQL multipart request spec (Apollo Server supports it; Strawberry via Upload scalar) or keep a small REST endpoint for uploads and return the URL through GraphQL. Most teams take the second route and keep uploads out of the graph.

What is GraphQL federation?

Federation lets independent GraphQL services each own part of one unified schema, stitched by a gateway. It's how microservice teams expose a single graph — see the federation gateway setup recipe.

Why does GraphQL return 200 even when it fails?

Errors are first-class data, not transport failures. Validation, resolver and partial failures all come back as HTTP 200 with an errors array — that's by spec design so partial data can still be delivered. Treat errors as your real status.

How do I stop expensive queries?

Three layers: set maxDepth (7-10) so queries can't nest infinitely, add complexity analysis so each field carries a cost with a total budget, and enforce persisted queries in production so only pre-approved queries run at all.

When is GraphQL the wrong choice?

When the flexibility isn't needed: a handful of stable CRUD endpoints, public APIs that live on CDN edge caching, or clients on constrained networks. In those cases REST's simplicity and cacheability win outright.