Skip to content
~sami-ullah

Concept project

GraphQL API with NestJS

A concept project with a working demo: a code-first GraphQL API for a recipe catalogue, with a parser, validator and executor I wrote to run in the browser. It shows cursor pagination, auth errors, a depth limit and, side by side, the N+1 problem and how DataLoader batching removes it.

Screenshot of the GraphQL API with NestJS demo

Overview#

GraphQL makes it easy for a client to ask for a list of recipes, each with its author, its reviews and each reviewer's name, in one request. It also makes it easy for the server to answer that request with dozens of small SQL queries. That is the N+1 problem, and it is the first thing I check in any GraphQL API I join.

This concept is a recipe catalogue API designed for NestJS and PostgreSQL. The live demo runs a real GraphQL pipeline in your browser: it parses your operation, validates it against the schema, runs the resolvers and logs every SQL statement the repository would send. A Compare button runs the same query with and without DataLoader.

My role#

Full build: schema design, resolvers, the DataLoader setup, the parser and executor that make the demo self-contained, the UI and this write-up. It is a concept project; the authors and recipes are fictional.

The problem#

A resolver only knows about its own parent object. Recipe.author receives one recipe and returns one author. When a list of 8 recipes is resolved, that resolver runs 8 times, and if each call does its own SELECT, the database gets 8 queries for authors, 8 for reviews, then one per review for the reviewer. The query looks innocent in the client and gets slower with every page size increase.

There are other GraphQL-specific risks:

  • Clients can write deeply nested queries (author → recipes → author → recipes …) that are cheap to send and expensive to answer.
  • Errors are returned with HTTP 200, so auth and validation failures need clear extensions.code values or clients cannot tell them apart.
  • Offset pagination breaks when rows are inserted between pages.

Approach & architecture#

◆ One GraphQL request
  1. POST /graphqlthen
  2. parsethen
  3. validate (fields, args, depth <= 6)then
  4. execute resolvers
  1. execute resolversthen
  2. DataLoader queuethen
  3. one batched SQL per relation and levelthen
  4. PostgreSQL

Code-first schema. In the production design every type is a class with @ObjectType() and every relation is a @ResolveField(). The demo keeps the same idea: types and resolvers are defined together in schema.ts, and the SDL in the schema tab is printed from that definition rather than written by hand.

ts
@Resolver(() => Recipe)
export class RecipeResolver {
  @ResolveField(() => Author)
  author(@Parent() recipe: Recipe, @Context() { loaders }: GqlContext) {
    return loaders.author.load(recipe.authorId);
  }
}

DataLoader per request. A new set of loaders is created for each request, so the cache never leaks data between users. The loader in the demo is short enough to read in full: load(key) queues the key and returns a promise, and once the current wave of resolvers has queued its keys, one batch function runs.

ts
load(key: K): Promise<V> {
  const hit = this.cache.get(key);
  if (hit) return hit;
  const p = new Promise<V>((resolve, reject) => {
    this.queue.push({ key, resolve, reject });
    if (this.queue.length === 1) setTimeout(() => this.dispatch(), 0);
  });
  this.cache.set(key, p);
  return p;
}

The batch functions turn many keys into one ANY query and return results in key order, which is the contract DataLoader requires:

sql
SELECT * FROM authors WHERE id = ANY('{1,2,3,4,5,6}');
SELECT * FROM reviews WHERE recipe_id = ANY('{1,2,3,4,5,6,7,8}') ORDER BY id;

Cursor pagination. recipes(first, after, filter) returns a Relay-style connection. The cursor encodes the last id seen, and the SQL uses WHERE id > $after ORDER BY id LIMIT first + 1; the extra row tells the server whether hasNextPage is true without a second count query.

Limits and errors. Validation rejects unknown fields and arguments with the same wording graphql-js uses, and refuses any operation deeper than six levels before a single resolver runs. Resolver errors carry codes: UNAUTHENTICATED, FORBIDDEN, BAD_USER_INPUT, NOT_FOUND.

Key challenges#

  • Batching at the right moment. If the loader dispatches too early, each level of the query is split into several batches. I schedule the dispatch with setTimeout(0), which runs after all pending promise callbacks, so every key from one level of the tree lands in the same batch.
  • Sharing work between fields. averageRating needs the same reviews as reviews. Both read through the reviews loader, so asking for both costs one query, not two.
  • Null propagation. If a non-null field fails, GraphQL nulls the nearest nullable parent and still returns the rest of the data. Getting that right is what lets the FORBIDDEN example return a clean error instead of a half-built object.
  • Writing a parser only as large as needed. I support operations, variables with defaults, aliases, fragments and all literal types, and reject directives and subscriptions with a clear message rather than pretending to handle them.

Tech stack#

  • Demo: TypeScript, React and Next.js. The GraphQL parser, validator, executor, DataLoader and repository are plain TypeScript modules with no GraphQL library, readable under "View source" on the demo page.
  • Production design: NestJS with @nestjs/graphql and the Apollo driver, the dataloader package, PostgreSQL through Prisma, a GqlAuthGuard reading the JWT, graphql-depth-limit and a complexity plugin, and e2e tests that send real operations.

Results#

  • With the default "Recipes with authors and reviews" example (8 recipes), I counted the statements the demo logs: 42 SQL statements without DataLoader and 3 with it. The resolver count is 42 in both runs, which shows that DataLoader changes how data is fetched, not how much resolver code runs.
  • Validation, depth limiting, cursor pagination, fragments, and authenticated mutations with FORBIDDEN checks all work in the demo.
  • The schema shown in the demo is generated from the resolver definitions.

These numbers are counts of logged statements in the simulator, not timings from a database. This is a concept build without real users.

Try the demo#

Open the GraphQL API demo. Things to try:

  1. Keep the first example and press Compare N+1 vs DataLoader. Read the two SQL lists side by side.
  2. Pick "Update someone else's recipe" and run it as Lena Park. You get FORBIDDEN and data: null.
  3. Pick "Too deep" to see the depth limit stop the query before any SQL runs.
  4. Run "Create a recipe" as anonymous, then as an author, and look at the recipes table.

What I would do next#

  • Add a query complexity score that weighs list fields by their first argument.
  • Support persisted queries so public clients can only run operations that were reviewed.
  • Port the resolvers to real Nest modules and keep the "SQL count per example" as an automated test.
Full screenshot of the GraphQL API with NestJS demo running in the browser
Screenshot of the live demo with its sample data. Click to open it.
▶ live demoWrite GraphQL queries and mutations, then compare the SQL count with and without DataLoader batching.

Skills used