| name | forge-graphql-design |
| description | GraphQL schema design. Nullable defaults, connections (cursor pagination via Relay spec), errors as data not throws, DataLoader for N+1, persisted queries for caching, schema versioning via deprecation, no overfetch via fragments. Contains paste-ready schema patterns and a DataLoader recipe. Use when designing a GraphQL API. Use when this capability is needed. |
| metadata | {"author":"f4rkh4d"} |
forge-graphql-design
You are designing a GraphQL schema that clients will query for years. Default agent-written GraphQL schemas have non-null-everywhere fields that propagate errors up the tree, throw exceptions instead of returning typed errors, offset pagination, and N+1 queries on every list field. This skill exists to fix all of that before the schema ships.
The mental model: GraphQL is a typed contract. The schema is read by humans and codegens alike. Choices that look ergonomic in dev (everything non-null) cost you in production (one DB error nulls the entire query). Choices that look "RESTful" (offset pagination) make scaling painful.
Quick reference (the things you must never ship)
- Non-null everywhere - especially on fields that can fail at runtime.
- Offset/page-based pagination on list fields.
- Throwing exceptions from a resolver instead of returning a typed error union.
- Resolving a list of
User by calling getUser(id) inside a .map() (N+1).
String for IDs (use ID).
String for money (use a Money object with amountCents: Int! + currency: Currency!).
Date as a String without an explicit format scalar.
- Removing a field directly instead of
@deprecated first.
- Unlimited query depth (allows DoS via deeply nested queries).
- No persisted-query / allowlist mechanism for production.
Hard rules
Nullability
1. Default to nullable. Mark ! (non-null) only when you can guarantee the field is present even on a partial DB failure.
type Order {
id: ID!
customer: Customer!
items: [LineItem!]!
shipped_at: DateTime!
}
type Order {
id: ID!
customer: Customer
items: [LineItem!]!
shipped_at: DateTime
}
The cost of !: if the field resolves to null at runtime, GraphQL propagates the null up to the nearest nullable parent. A non-null leaf can null the whole query.
2. List fields: [X!]! (non-null list of non-null items) is usually right - you cannot have null elements in a list, and an empty list is [] not null. [X!] (nullable list) is fine if "no list" is meaningfully different from "empty list."
3. Scalar inputs: non-null required parameters, nullable optional.
type Query {
orders(
customerId: ID
status: OrderStatus
first: Int = 50
after: String
): OrderConnection!
}
Pagination (Relay Connection spec)
4. Cursor-based pagination via the Connection spec.
type Query {
orders(first: Int = 50, after: String, last: Int, before: String): OrderConnection!
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
totalCount: Int
}
type OrderEdge {
node: Order!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
5. totalCount is optional and expensive. Do not return it unless the client asked for it and you can compute it cheaply.
6. Cap first / last at 100-200. Reject larger requests with an argument_too_large typed error or default to the cap with a warning header.
Errors as data
7. Throwing exceptions from a resolver is reserved for unexpected failures. For expected business failures (validation, permission denied, not found), return a typed error union.
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderPayload!
}
union CreateOrderPayload =
| CreateOrderSuccess
| ValidationError
| CustomerNotFoundError
| OutOfStockError
type CreateOrderSuccess {
order: Order!
}
type ValidationError {
message: String!
fields: [FieldError!]!
}
type FieldError {
path: String!
code: String!
message: String!
}
type CustomerNotFoundError {
customerId: ID!
}
type OutOfStockError
String
Int
Client side:
mutation {
createOrder(input: { customerId: "...", items: [...] }) {
__typename
... on CreateOrderSuccess { order { id total_cents } }
... on ValidationError { fields { path code message } }
... on CustomerNotFoundError { customerId }
... on OutOfStockError { sku available }
}
}
8. Schema-level errors array is for the bad cases (validation, syntax, auth). Business outcomes live in the payload union.
N+1 and DataLoader
9. DataLoader for every "fetch by ID" path in a list field.
import DataLoader from "dataloader";
function createUserLoader(db: DB) {
return new DataLoader<string, User | null>(async (ids) => {
const users = await db.users.findManyByIds(ids as string[]);
const byId = new Map(users.map((u) => [u.id, u]));
return ids.map((id) => byId.get(id) ?? null);
});
}
const resolvers = {
Order: {
async customer(order, _args, ctx) {
return ctx.loaders.user.load(order.customer_id);
},
},
};
function buildContext(req: Request) {
{
: {
: (db),
: (db),
},
};
}
DataLoader batches all load(id) calls in the same tick into a single findManyByIds query.
10. Use field-level caching for hot, immutable data. DataLoader caches within a request; Redis caches across requests.
IDs and types
11. Use ID (the built-in scalar) for IDs, not String. GraphQL treats ID as opaque - clients should not parse it.
12. Custom scalars for typed values.
scalar DateTime
scalar UUID
scalar EmailAddress
scalar URL
type User {
id: ID!
email: EmailAddress!
created_at: DateTime!
}
13. Money as an object, not a Float.
type Money {
amountCents: Int!
currency: Currency!
}
enum Currency { USD EUR KZT }
Schema versioning
14. Never remove a field. Deprecate first.
type Order {
total: Money @deprecated(reason: "Use `totalMoney` instead. Will be removed 2026-12-01.")
totalMoney: Money!
}
15. Removal happens only after analytics show no client uses it. Most graph hosting (Apollo Studio, Hasura) provides this.
16. Additive changes are not breaking: new field on a type, new optional argument, new enum value (clients should treat unknown enum values as a fallback).
Query depth and complexity
17. Limit query depth and complexity. A pathological query like { a { a { a { ... } } } } 50 levels deep is a DoS.
import { maxDepthRule } from "@escape.tech/graphql-armor-max-depth";
import { costLimitRule } from "@escape.tech/graphql-armor-cost-limit";
const validationRules = [
maxDepthRule({ n: 10 }),
costLimitRule({ maxCost: 5000, scalarCost: 1, objectCost: 2, listFactor: 10 }),
];
18. Persisted queries in production. Clients send a hash; server resolves to a pre-registered query. Defeats arbitrary-query DoS and shrinks request size.
Auth
19. Field-level auth checks in resolvers. Not "validate the JWT in the gateway and trust everything below."
type Order @auth(requires: ["orders:read"]) {
id: ID!
total: Money!
customer: Customer
internalNotes: String @auth(requires: ["orders:admin"]) # admin only
}
20. Auth context lives in the request context, not in arguments. Same rule as MCP: never trust the model/client to pass tenant_id.
Caching
21. Cache by query + variables hash. Persisted queries make this trivial.
22. @cacheControl directive for field-level TTL.
type Product @cacheControl(maxAge: 300) {
id: ID!
name: String!
inventory: Int @cacheControl(maxAge: 30)
}
Subscriptions
23. Subscriptions for live updates, NOT for one-shot reads. Use a query.
24. Subscription payloads are typed like mutation payloads (often unions).
Telemetry
25. Trace at the resolver level. Each resolver becomes a span. Apollo, Yoga, Mercurius all support this.
26. Log slow queries. A 2-second resolver buried inside a { orders { items { product { variant { ... } } } } } is hard to find.
Common AI-output patterns to reject
| Pattern | Why wrong | Fix |
|---|
Every field marked ! | One null cascades up | Deliberate nullability |
orders(page: Int, perPage: Int) | Offset pagination | Relay Connection (first/after) |
throw new Error("not found") from resolver | Untyped failure | Typed error union in the payload |
users.map((u) => getUser(u.id)) | N+1 | DataLoader |
String IDs | Loses graph semantics | ID |
Float for money | Rounding | Money { amountCents, currency } |
Date as String | Format ambiguity | DateTime scalar |
| Removing field directly | Breaking change | @deprecated first, remove later |
| No max-depth rule | DoS surface | maxDepthRule({ n: 10 }) |
| Arbitrary queries in production | DoS + cache miss | Persisted queries |
| Auth at gateway only | Field-level leaks | @auth directive or resolver checks |
subscription { order } for one-shot | Wrong primitive | query |
Worked example: a Connection-paginated mutation-payload-union schema
"""
Order with deliberate nullability.
A row failure on `customer` should not null the whole order.
"""
type Order {
id: ID!
customer: Customer
items: [LineItem!]!
status: OrderStatus!
total: Money!
createdAt: DateTime!
shippedAt: DateTime
}
type LineItem {
sku: String!
quantity: Int!
unitPrice: Money!
}
type Money {
amountCents: Int!
currency: Currency!
}
enum Currency { USD EUR KZT
OrderStatus PENDING PAID FULFILLED CANCELLED REFUNDED
orders
Int
String
ID
OrderStatus
OrderConnection
OrderConnection
OrderEdge
PageInfo
OrderEdge
Order
String
PageInfo
Boolean
Boolean
String
String
createOrder CreateOrderInput CreateOrderPayload
CreateOrderInput
ID
LineItemInput
Currency
LineItemInput
String
Int
Int
CreateOrderPayload
CreateOrderSuccess
ValidationError
CustomerNotFoundError
OutOfStockError
CreateOrderSuccess Order
ValidationError String FieldError
FieldError String String String
CustomerNotFoundError ID
OutOfStockError String Int
DateTime
const resolvers = {
Mutation: {
async createOrder(_root, { input }, ctx) {
const parsed = CreateOrderSchema.safeParse(input);
if (!parsed.success) {
return {
__typename: "ValidationError",
message: "Validation failed.",
fields: parsed.error.issues.map((i) => ({ path: i.path.join("."), code: i.code, message: i.message })),
};
}
const customer = await ctx.loaders.customer.load(parsed.data.customerId);
if (!customer) {
return { __typename: "CustomerNotFoundError", customerId: parsed.data.customerId };
}
for (const item of parsed.data.items) {
const stock = await ctx...(item.);
(!stock || stock. < item.) {
{ : , : item., : stock?. ?? };
}
}
order = ctx...(parsed.);
{ : , order };
},
},
: {
() {
ctx...(order.);
},
},
};
What this demonstrates: deliberate nullability (customer nullable, items list non-null) (rule 1-2); Connection pagination (rule 4); typed error union in the mutation payload (rule 7); DataLoader-based customer resolver to avoid N+1 (rule 9); scalar DateTime (rule 12); Money object (rule 13); ID not String (rule 11).
Workflow
When designing a GraphQL schema:
- List the types in prose first. Order, Customer, LineItem. Plus their relationships.
- Pick nullability deliberately. Default nullable; mark
! only on truly-stable fields.
- Pick pagination once: Connection spec everywhere.
- Decide on the typed-error-union pattern. Apply consistently to all mutations that can fail in expected ways.
- Identify N+1 risk on every list field. Set up DataLoaders.
- Add depth/complexity limits before going to production.
- Plan for deprecation, not removal.
Verification
Manual checklist:
When to skip this skill
- REST APIs (see
forge-api-design).
- Internal service-to-service RPC where gRPC or REST is simpler.
- Prototype schemas that will not survive the demo.
Related skills
Source: f4rkh4d/forge-skill — distributed by TomeVault.