module-36/n-plus-one-sentinel.mdDELIVERY STANDARD

The N+1 Sentinel

Expressive queries for the frontend. Survivable queries for the database.

One innocent GraphQL query fanned out into four thousand database calls — flexibility for the frontend, a meltdown for the database.
DataLoader batching by default, query depth and complexity budgets enforced at the gateway — expressive queries stay expressive, and pathological ones never reach the database.

The incident

The GraphQL migration was a frontend triumph — one flexible endpoint, no more bespoke REST routes. Then someone shipped a screen that asked for posts, each post's author, and each author's followers. One innocent query. Four thousand database calls. The database didn't distinguish between a feature and an attack, because at that shape, there isn't one.

Why the obvious approach fails

GraphQL's power is that clients compose queries the backend never anticipated — which is also its threat model. A naive resolver fetches each nested entity individually, turning one query into N+1 (then N×M+1) database calls, and query depth becomes a lever anyone can pull: a deeply nested request is a denial-of-service whether or not it meant to be. The flexibility isn't the mistake; shipping it without batching and budgets is. DataLoader collapses the N calls into batched fetches; depth and complexity limits put a ceiling on what any single query may cost — so the frontend keeps its expressiveness and the database keeps its life.

The standard

  • Every relational resolver batches through DataLoader (or equivalent); per-entity fetch loops are banned.
  • Query depth and computed complexity budgets are enforced at the gateway — over-budget queries are rejected with a clear error, before execution.
  • Persisted/allowlisted queries are the default for public clients; arbitrary query composition is a privilege, not a right.
  • Resolver-level database-call counts are observable per query shape — the N+1 that slips through is caught by metrics, not by an outage.
  • Pagination is mandatory on every list field; unbounded collections do not exist in the schema.

How Cortex validates it

At plan time

GraphQL blueprints declare depth/complexity budgets and the persisted-query policy.

At validation

assert: nested_query(posts→authors→followers) → db_calls == batched(3), not N×Massert: query(depth > budget) → rejected_pre_executionassert: list_fields.paginated == 100%

Assertion syntax is illustrative.

At review

the approver sees the hostile-query run: pathological shapes fired at the gateway, rejections and batching both proven, database call counts on the record.

Composes with

See where your codebase stands against this standard.