← Complete Projects

阅读中文版本

Build a GraphQL Blog API with DataLoader

A complete GraphQL blog API with schema, resolvers, cursor pagination, per-request DataLoader batching, and N+1 regression tests.

advanced~6 hgraphqlnodepostgresqldataloaderapi

Project goal

Build a GraphQL blog API where clients query posts, authors, and comments in one request, paginate with cursors, and never trigger N+1 database reads.

Final features

  • Schema-first design
  • Resolvers and context
  • Cursor pagination
  • DataLoader batching
  • Mutation with validation

Architecture

A single POST /graphql endpoint parses operations with graphql-yoga, executes them against a typed schema, and resolves fields through service modules. Each request creates a DataLoader instance that batches author and comment lookups by id, collapsing N+1 reads into one query per relation.

Prerequisites

  • Node.js 20 LTS or later
  • pnpm or npm
  • PostgreSQL 15+ reachable via DATABASE_URL
  • Basic familiarity with GraphQL schemas

Dependencies

  • graphql-yoga ^5.6.0 — HTTP GraphQL server and request parsing
  • graphql ^16.9.0 — Schema, execution engine, and types
  • dataloader ^2.2.2 — Per-request batching to eliminate N+1
  • pg ^8.12.0 — PostgreSQL connection pool

Directory structure

graphql-blog-api/
├── src/schema.ts
├── src/resolvers.ts
├── src/dataloaders.ts
├── src/db.ts
├── src/context.ts
├── tests/api.test.ts
└── package.json

Phases

1. Schema-first design

Define Post, Author, Comment, and PageInfo types plus a cursor-paginated posts connection.

type Query {
  posts(first: Int!, after: String): PostConnection!
  post(id: ID!): Post
}
type Post { id: ID! title: String! author: Author! comments: [Comment!]! }

Expected behavior: The schema is the contract; invalid queries are rejected before a resolver runs.

2. Resolvers and context

Build a per-request context that owns the pool, a request id, and the loaders.

export function buildContext(req) {
  return { pool, requestId: req.id, loaders: createLoaders(pool) };
}

Expected behavior: Every resolver reads from context rather than globals, so requests stay isolated.

3. Cursor pagination

Encode the last id and created_at into an opaque cursor and fetch first+1 rows.

const rows = await pool.query(
  'SELECT * FROM posts WHERE (created_at, id) < ($1,$2) ORDER BY created_at DESC, id DESC LIMIT $3',
  [cursor.created_at, cursor.id, first + 1],
);

Expected behavior: A stable, index-friendly pagination replaces offset paging that drifts as rows are inserted.

4. DataLoader batching

Batch-load authors and comments by exposing a function that takes an array of keys.

export const createLoaders = (pool) => ({
  authorById: new DataLoader(async (ids) => {
    const { rows } = await pool.query('SELECT * FROM authors WHERE id = ANY($1)', [ids]);
    const byId = new Map(rows.map((r) => [r.id, r]));
    return ids.map((id) => byId.get(id) ?? null);
  }),
});

Expected behavior: Two posts by the same author trigger one author query, not two.

5. Mutation with validation

Validate input length and shape, then insert inside a transaction and return the new post.

if (title.trim().length < 3) throw new GraphQLError('title too short');
const { rows } = await pool.query('INSERT INTO posts(title, author_id) VALUES ($1,$2) RETURNING *', [title, authorId]);

Expected behavior: Malformed input returns a field-level error instead of a 500.

Testing

Boot the server on an ephemeral port, run operations with a typed client, and assert response shapes. A query counter wraps the pool so a test proves that fetching N posts performs a bounded number of author queries regardless of N.

Troubleshooting

A resolver forbids the N+1 test

The loader was created at module scope; move it into buildContext so each request batches independently.

Pagination returns duplicate rows

The cursor must include a tiebreaker (id) alongside the sort timestamp, and the ORDER BY must list both.

Deployment

Run behind a reverse proxy that forwards only /graphql, disable introspection in production, and set a depth and cost limit so a single query cannot exhaust the database. Cache public queries at the edge; never cache operations involving the authenticated viewer.

Known limitations

  • In-memory loaders are per-request; they do not cache across requests and must not be shared globally.
  • The example performs no schema stitching or federation across services.
  • Subscriptions are out of scope; the API serves queries and mutations only.

Project overview

GraphQL makes it easy to fetch exactly what a client needs and equally easy to accidentally run a query per field. This build ships a blog API where the common nested query does not become a storm of database reads.

You will define a typed schema, resolve fields through service modules, paginate with cursors, and batch lookups with a loader so fetching N posts does not perform N author queries.

The batching loader is the difference between an API that looks clean and one that scales. Without it, a query for posts with authors and comments does three kinds of fan-out instead of three queries.

Cursor pagination is chosen over offsets because a feed is read while it is being written, and an offset shifts when a new row lands on the front page.

Each phase adds one property: a typed schema, resolvers, cursors, batching, and validated mutations, so the API is correct at every stage.

The build is aimed at teams whose first GraphQL service slowed under a nested query, and it makes the reason visible by counting queries in a test. GraphQL hands composition to the client, so an API that ignores that fact is fast in a demo and slow in production.

It is written for a developer who has used REST and wants to understand what changes when the client composes the query, and it keeps the schema small enough that the whole contract is one readable file.

What you will build

The API provides five capabilities, each with a test that runs a real operation:

  • Schema-first design
  • Resolvers and context
  • Cursor pagination
  • DataLoader batching
  • Mutation with validation

A schema-first design fixes the contract; resolvers and a context carry dependencies; cursor pagination keeps pages stable; a loader batches writes into bounded reads; and mutations validate input before touching the store.

The design puts the query-count assertion in the test suite, because a performance property that is not tested is a performance property that will regress on the next resolver.

Architecture and data model

A single POST /graphql endpoint parses operations with graphql-yoga, executes them against a typed schema, and resolves fields through service modules. Each request creates a DataLoader instance that batches author and comment lookups by id, collapsing N+1 reads into one query per relation.

A single GraphQL endpoint parses and executes operations against a typed schema whose fields resolve through service modules rather than through raw access to the database.

Each request builds a loader instance that batches lookups by id, so a list of authors is fetched in one query regardless of length. The loader is per request so cached entities never leak between users.

Pagination uses a cursor derived from a stable sort key, so a page boundary does not move when new rows are inserted. The cursor encodes the position rather than the count.

Mutations go through the same service layer as queries, so validation and authorization are not duplicated in a second code path.

The loader is created per request and per entity type, so batching never crosses authentication boundaries and a cached entity cannot be served to a different user. That per-request scope is what makes the optimization safe rather than merely fast.

Prerequisites and dependencies

You need a database, a GraphQL server library, and a client that can send typed operations for the tests.

  • Node.js 20 LTS or later
  • pnpm or npm
  • PostgreSQL 15+ reachable via DATABASE_URL
  • Basic familiarity with GraphQL schemas

The dependencies cover the schema, the server, and the batched data access:

  • graphql-yoga (^5.6.0) — HTTP GraphQL server and request parsing
  • graphql (^16.9.0) — Schema, execution engine, and types
  • dataloader (^2.2.2) — Per-request batching to eliminate N+1
  • pg (^8.12.0) — PostgreSQL connection pool
pnpm init
pnpm add graphql-yoga graphql dataloader pg
pnpm add -D typescript tsx vitest

Set a query depth and cost limit in configuration, and keep introspection disabled in production, because an unbounded query is the GraphQL-specific denial-of-service vector.

Directory structure

Separate the schema, the resolvers, the service layer, and the loader so the access logic is testable without the GraphQL layer:

graphql-blog-api/
├── src/schema.ts
├── src/resolvers.ts
├── src/dataloaders.ts
├── src/db.ts
├── src/context.ts
├── tests/api.test.ts
└── package.json

Phase 1: Schema-first design

Define Post, Author, Comment, and PageInfo types plus a cursor-paginated posts connection.

type Query {
  posts(first: Int!, after: String): PostConnection!
  post(id: ID!): Post
}
type Post { id: ID! title: String! author: Author! comments: [Comment!]! }

The schema is the contract; invalid queries are rejected before a resolver runs.

Write the schema first and let it define the contract, so the API shape is reviewed as a document rather than discovered from resolver code.

Phase 2: Resolvers and context

Build a per-request context that owns the pool, a request id, and the loaders.

export function buildContext(req) {
  return { pool, requestId: req.id, loaders: createLoaders(pool) };
}

Every resolver reads from context rather than globals, so requests stay isolated.

Resolve every field through a context that carries the database and the loader, which keeps resolvers thin and makes the data access layer reusable.

Phase 3: Cursor pagination

Encode the last id and created_at into an opaque cursor and fetch first+1 rows.

const rows = await pool.query(
  'SELECT * FROM posts WHERE (created_at, id) < ($1,$2) ORDER BY created_at DESC, id DESC LIMIT $3',
  [cursor.created_at, cursor.id, first + 1],
);

A stable, index-friendly pagination replaces offset paging that drifts as rows are inserted.

Paginate with cursors over a stable sort key, so new rows do not shift a page and a client can resume without duplicates.

Phase 4: DataLoader batching

Batch-load authors and comments by exposing a function that takes an array of keys.

export const createLoaders = (pool) => ({
  authorById: new DataLoader(async (ids) => {
    const { rows } = await pool.query('SELECT * FROM authors WHERE id = ANY($1)', [ids]);
    const byId = new Map(rows.map((r) => [r.id, r]));
    return ids.map((id) => byId.get(id) ?? null);
  }),
});

Two posts by the same author trigger one author query, not two.

Batch the by-id lookups with a loader, and count the queries in a test, since the count is the property that proves the batching works.

Phase 5: Mutation with validation

Validate input length and shape, then insert inside a transaction and return the new post.

if (title.trim().length < 3) throw new GraphQLError('title too short');
const { rows } = await pool.query('INSERT INTO posts(title, author_id) VALUES ($1,$2) RETURNING *', [title, authorId]);

Malformed input returns a field-level error instead of a 500.

Validate mutations against the schema and the service rules before writing, returning typed errors so the client can distinguish a validation failure from a conflict.

Testing

Boot the server on an ephemeral port, run operations with a typed client, and assert response shapes. A query counter wraps the pool so a test proves that fetching N posts performs a bounded number of author queries regardless of N.

Boot the server on an ephemeral port and run real operations through a typed client, so the tests exercise the schema and the resolvers together.

Assert the response shape for a list query, because GraphQL’s contract is the shape and a shape change is a breaking change.

Wrap the pool with a query counter and assert that fetching many posts performs a bounded number of author queries, which is the only convincing test of the loader.

Insert rows between page fetches and assert pagination stability, since that is the case offsets get wrong and cursors get right.

The query-count assertion is the centerpiece: wrap the pool, fetch many posts with their authors, and assert the number of author queries is bounded and independent of the number of posts. Without that test a resolver can reintroduce a per-field read and every other test will still pass.

Boot the server on an ephemeral port so tests run in parallel without port clashes, and use a typed client so a schema change surfaces as a compile error rather than a runtime surprise.

pnpm vitest run

Deployment and operations

Run behind a reverse proxy that forwards only /graphql, disable introspection in production, and set a depth and cost limit so a single query cannot exhaust the database. Cache public queries at the edge; never cache operations involving the authenticated viewer.

Run behind a reverse proxy that forwards only the GraphQL path, so introspection and any development routes are not reachable in production.

Set depth and cost limits so a single query cannot exhaust the database, because GraphQL shifts the ability to compose expensive work to the client.

Cache only public queries at the edge, and never cache an operation that depends on the authenticated viewer, since that is how one user sees another’s data.

Monitor resolver-level latency, because a slow field is invisible in an aggregate request time and obvious in a per-field breakdown.

Disable introspection and any development playground in production, and forward only the GraphQL path at the proxy, because a publicly readable schema combined with an unbounded query is a reconnaissance tool. Cache only operations whose result does not depend on the viewer.

Emit a per-request query count alongside the request log, so a regression that reintroduces a per-field read is visible in production as a rising count rather than discovered only under load.

Security considerations

Authenticate at the transport boundary, authorize inside each resolver by ownership, and reject queries beyond a configured depth or complexity. Persist query cost per client and block abusive callers.

Authorize per field where the data is sensitive, because one query can traverse several relations that each need their own access rule.

Validate mutation input against the schema and the service rules, and never trust a client-supplied id for ownership.

Bound query complexity with depth and cost limits, since these are the standard defenses against an expensive-query attack on a GraphQL surface.

Disable introspection in production and keep error messages free of stack traces, because both leak schema and implementation detail.

Authorize at the field level for anything sensitive and never accept a client-supplied owner id as proof of ownership, because one GraphQL request can traverse several relations and each needs its own check. Keep error bodies free of stack traces so the schema is not leaked through failure.

Observability

Log one line per operation with the operation name, duration, resolver error count, and total database calls. Track a histogram of resolver time and alert when the p99 database-call count per request exceeds the schema baseline.

Track query count per request and per resolver, because the loader’s benefit is measured in queries and only visible when they are counted.

Chart the slowest resolvers, since a nested field is where a performance regression almost always appears first.

Log operations by their normalized shape rather than by raw text, so repeated expensive operations can be recognized without storing user data.

Alert on query depth and cost rejections, because a spike indicates either an abusive client or a legitimate query that needs a schema change.

Track queries per request, resolver latency, and rejected-for-complexity counts, because the first shows whether batching is holding, the second names the slow field, and the third indicates a client sending work it should not.

Troubleshooting

A resolver forbids the N+1 test. The loader was created at module scope; move it into buildContext so each request batches independently.

Pagination returns duplicate rows. The cursor must include a tiebreaker (id) alongside the sort timestamp, and the ORDER BY must list both.

If a list query is slow, count the queries first; an unbounded lookup almost always means a resolver bypassed the loader.

If a page returns duplicates or skips items, check the cursor key; a cursor over a non-unique field will reorder at page boundaries.

If a nested query is slow, count the queries before profiling the database, because an unbounded read is almost always a resolver that bypassed the loader.

Limitations and next steps

  • In-memory loaders are per-request; they do not cache across requests and must not be shared globally.
  • The example performs no schema stitching or federation across services.
  • Subscriptions are out of scope; the API serves queries and mutations only.

The API paginates by cursor on a single stable key and does not support arbitrary client-chosen ordering, which avoids unstable cursors at the cost of flexibility.

There is no persisted-query allowlist, so clients can still send arbitrary operations within the depth and cost limits.

Subscriptions are absent by design; live updates are handled by a separate service in this collection.

The service layer assumes a single database and does not abstract over multiple data sources, which keeps resolvers simple but bounds the architecture.

The API supports cursor pagination on one ordering only and does not expose a persisted-query allowlist, so clients can still send arbitrary operations within the depth and cost limits; an allowlist would tighten that without changing the schema.

There is no response-level caching inside the service; public-query caching is delegated to the edge, which keeps the API stateless but makes the edge configuration the source of cache behavior.