GraphQL Schema Design: Types, Resolvers, and N+1
Your GraphQL schema is the API contract, and designing it well matters more than clever resolvers. This article covers type-first design, descriptions, Connection pagination, enums vs. strings, DataLoader for N+1, safe schema evolution and deprecation, and when GraphQL is or is not the right choice.
Key Points
- Topic Focus: Next.js、TypeScript
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
GraphQL Schema Design: Types, Resolvers, and N+1
People who have worked with GraphQL usually go through two phases. At first, they think, "The frontend can query whatever it wants. This is great." After six months of hard lessons, they realize that what determines whether a GraphQL service can survive over the long term is not how clever its resolvers are, but how well its schema is designed.
A REST contract is hidden in code and documentation, so teams can delay or blur it. A GraphQL contract is the schema itself. It is strictly constrained by the type system, consumed directly by clients, and validated character by character by tools such as Codegen, Playground, and ESLint. Once a schema goes live, it is difficult to change. A casually named field can be called out in every code review for the next three years.
This article explains six things: why the schema is a contract, how to design precise types, why pagination must use Connections, how DataLoader solves N+1, how to evolve a schema safely without versions, and when GraphQL should not be used at all. The examples use SDL (Schema Definition Language) with TypeScript resolvers, making the comparison easy to follow.
1. The Schema Is the Contract: Design It Before Writing Resolvers
Many teams implement GraphQL like this: the DBA creates tables, the backend copies those tables into types, resolvers are added along the way, and everything goes live. Three months later, the schema is shaped like the database rather than the business or frontend. This is the first and largest cause of decay in a GraphQL system.
The correct order is to design the schema first and discuss implementation second. A schema is not a projection of the database; it is a declaration of capabilities. It answers "What can the client do?" rather than "What tables exist in the database?" REST often conflates these questions, but GraphQL must keep them separate.
Consider this anti-pattern, a typical type copied directly from a table:
# Anti-pattern: copy the database table structure verbatim
type User {
id: Int!
first_name: String!
last_name: String!
email: String!
pwd_hash: String!
created_at: String!
is_deleted: Int!
}
type Query {
getUserById(id: Int!): User
getAllUsers: [User!]!
}
This schema appears usable, but it embeds at least five problems in the contract:
first_nameandlast_nameuse snake_case, violating the official GraphQL naming convention, which requires camelCase.pwd_hashleaks a sensitive implementation detail to clients.created_atusesString, losing precision and timezone semantics.is_deletedusesIntfor a Boolean value, leaving its meaning to an informal agreement.getAllUsershas no pagination and will fail as the dataset grows.
These are not minor issues that can be "handled in the resolver." They are part of the contract. Frontends will depend on them, documentation generators will render them, and codegen will turn them into TypeScript types. Renaming one field is a breaking change.
For the same business domain, a carefully designed schema looks like this:
type User {
id: ID!
displayName: String!
email: EmailAddress!
createdAt: DateTime!
status: UserStatus!
}
enum UserStatus {
ACTIVE
SUSPENDED
DELETED
}
type Query {
user(id: ID!): User
users(first: Int = 20, after: String): UserConnection!
}
The difference is not aesthetics; it is evolvability. The latter keeps implementation details such as password hashes and soft-deletion flags inside the backend, explicitly declares semantics through the status enum, and makes nullability, pagination, and scalar precision part of the contract. Six months later, you can add fields, change implementations, or replace a data source without changing a single line of frontend code.
Rule of thumb: The schema is for clients, not for diagramming the database. When designing a schema, pretend you do not know what the database looks like. Ask only one question: "What capabilities does the frontend need to complete this task?"
2. Type Design: Precision Over Convenience
GraphQL's type system is stricter than TypeScript's, but it will not think for you. The more precise the types, the safer the client, the clearer the documentation, and the fewer arguments occur between frontend and backend teams. This section presents four hard rules.
1. Prefer Enums to Strings: Turn Conventions into Constraints
String types are the universal dumping ground of schema design. They accept everything and guarantee nothing.
# Anti-pattern: status is a black box
type Order {
id: ID!
status: String!
}
When a client receives status: "paid", how does it know which values are valid? Read the documentation? Ask the backend team? Inspect the code? GraphQL provides enums specifically to eliminate this uncertainty:
enum OrderStatus {
PENDING
PAID
SHIPPED
DELIVERED
CANCELED
REFUNDED
}
type Order {
id: ID!
status: OrderStatus!
}
Enums provide structural benefits:
- Compile-time validation: If a client query contains an invalid enum value, the build fails immediately.
- Type inference: The generated TypeScript type is
'PENDING' | 'PAID' | ..., allowing automatic union-type narrowing. - Self-documentation: In GraphiQL and Apollo Studio, the enum includes its list of possible values, so documentation always stays synchronized with the code.
- Controlled evolution: Adding an enum value is a non-breaking change as long as resolvers can handle it. Removing one is breaking.
Only two situations justify String: the value domain is genuinely open, such as usernames or free-form text; or the structure is too complex for GraphQL to express, such as a JSON blob, in which case use a JSON scalar and document it clearly in the description. "There are only two or three values now, so we will use String and fix it later" means that "later" will never come.
2. Descriptions Are Documentation: Treat description as a First-Class Feature
GraphQL has a built-in documentation system, and descriptions are its medium. A schema without descriptions has no documentation, and it is worse than having no documentation because it looks documented.
# Anti-pattern: clients can only guess
type Product {
price: Float!
stock: Int!
sku: String!
}
Does price include tax? What is the currency? Is stock sellable inventory or physical inventory? Is sku unique? Every question requires asking someone.
"Sellable product information. All prices include tax; see the currency field for the currency."
type Product {
"Tax-inclusive selling price, in the smallest unit of the currency specified by currency."
price: Int!
"Price currency, expressed as a three-letter ISO 4217 code such as CNY or USD."
currency: CurrencyCode!
"Currently available inventory after reservations have been deducted; never negative."
stock: Int!
"Unique product code. Globally unique and suitable for deduplication."
sku: String!
}
Follow these practical rules when writing descriptions:
- Describe fields, arguments, enum values, and types, without exception. Enum values are especially easy to overlook.
- Use language that defines constraints: specify units, ranges, uniqueness, null semantics, timezones, and formats.
- Explain when a value is returned. Nullable fields in particular should state when they are null.
- Descriptions support Markdown, so use lists and code blocks for complex fields.
The toolchain renders these descriptions directly in Playground, includes them in codegen-generated JSDoc, and displays them in Apollo Studio's Explorer. One description is consumed in three places with no additional maintenance cost. This is one of GraphQL's most underrated advantages over REST.
3. Naming: camelCase Is the Law, and Inputs and Outputs Must Stay Separate
GraphQL naming conventions are not suggestions. All identifiers except enum values, including field and argument names, must use camelCase, while type names must use PascalCase. Enum values conventionally use uppercase letters. Once you cross this line, the entire ecosystem becomes awkward: codegen produces underscored TypeScript fields, ESLint reports violations, and client code becomes cumbersome.
# Anti-pattern: snake_case fields
type Article {
published_at: DateTime!
author_id: ID!
}
# Correct: camelCase
type Article {
publishedAt: DateTime!
authorId: ID!
}
The database may use snake_case column names. Map them in the resolver. The schema should always use camelCase because it is a client-facing contract, not a database mirror.
The second naming rule is even more important: input and output types must be separate. A common beginner mistake is trying to use one type for both return values and input:
# Anti-pattern: mixing Input and Output
input UserInput {
id: ID! # Where would an id come from during creation?
createdAt: DateTime! # Why should the client set the creation time?
status: UserStatus!
displayName: String!
email: EmailAddress!
}
A returned User has id and createdAt, but clients should never provide those fields when creating a user. The correct approach is to define a dedicated input type for each write operation:
input CreateUserInput {
displayName: String!
email: EmailAddress!
password: String!
}
input UpdateUserInput {
displayName: String
email: EmailAddress
status: UserStatus
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}
The nullability of input fields indicates whether they must be provided. Making every field optional in UpdateUserInput represents a partial update. This discipline of separating inputs and outputs may seem verbose, but it is the foundation of a schema that can evolve over the long term.
4. Use Precise Scalars: Do Not Put Everything in String
GraphQL has five built-in scalars: Int, Float, String, Boolean, and ID. Production schemas almost always define custom scalars because the built-in types are too coarse:
scalar DateTime
scalar EmailAddress
scalar URL
scalar UUID
scalar Decimal
scalar JSON
Custom scalars do more than provide a different name. They can attach parsing logic and validation rules. DateTime can enforce ISO 8601, Decimal can require strings to avoid floating-point precision loss because money should never use Float, and EmailAddress can validate its format at the boundary. Defining these constraints in the schema lets every client share them and is far better than writing validation manually in every resolver.
type Money {
"Amount in the smallest currency unit to avoid floating-point precision issues."
amount: Int!
currency: CurrencyCode!
}
For money, Int in the smallest currency unit plus a currency enum is much safer than Float. Store CNY 99.99 as 9999 and let the frontend format it for display. This is basic backend engineering, but GraphQL's explicit types make it even more important to get right.
3. Pagination: Connections Are the Industry Standard
Any list field that may grow must be paginated. An unpaginated [User!]! is a time bomb. It may contain ten thousand rows today and one million next year, until a frontend query retrieves the entire table and causes the database to run out of memory.
GraphQL pagination has a de facto standard called the Connection pattern, originating from the Relay specification. It looks like this:
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
users(first: Int = 20, after: String, last: Int, before: String): UserConnection!
}
This structure looks verbose at first. Why not return [User!]! with an offset? There are three reasons:
1. Cursors are more robust than offsets. Offset pagination shifts when data changes. If someone inserts a row while you move to page 2, that page can repeat or omit records from the previous result. A cursor is based on a specific record, usually an encoded id or createdAt. Even when rows are inserted or deleted, the meaning of "fetch 20 rows after this one" remains stable.
2. Edges provide an attachment point. An Edge is not merely a wrapper around node; it can carry metadata about that node in the current context. For example, when querying a user's articles, ArticleEdge can include the cursor and relationMeta, such as the article's publication order for that user or whether it is pinned. Returning [Article] directly cannot express this.
type ArticleEdge {
node: Article!
cursor: String!
"The article's position in the author's works, starting at 1."
rankInAuthor: Int!
"Whether this article is pinned by the author."
isPinned: Boolean!
}
3. pageInfo standardizes pagination state. Fields such as hasNextPage and endCursor are supported natively by GraphQL client libraries including Apollo, Relay, and urql, so frontend pagination UI logic can be reused completely. If you invent a proprietary format such as { items, offset, total }, every consumer must implement it again.
To be direct: Do not invent your own pagination format. Use Connections even when you only have ten rows. The consistency and toolchain reuse are worth far more than saving a few type definitions. When the list eventually grows, cursor pagination is already supported and no refactor is necessary.
Here is a simple Node implementation:
const DEFAULT_FIRST = 20;
const MAX_FIRST = 100;
export const resolvers = {
Query: {
users: async (_parent, args, ctx) => {
const limit = Math.min(args.first ?? DEFAULT_FIRST, MAX_FIRST);
const after = args.after
? Buffer.from(args.after, 'base64').toString('utf8')
: null;
// Fetch one extra row to determine hasNextPage
const rows = await ctx.prisma.user.findMany({
take: limit + 1,
skip: after ? 1 : 0,
cursor: after ? { id: after } : undefined,
orderBy: { id: 'asc' },
});
const hasNextPage = rows.length > limit;
const nodes = hasNextPage ? rows.slice(0, limit) : rows;
const endCursor = nodes.at(-1)?.id;
return {
edges: nodes.map((node) => ({
node,
cursor: Buffer.from(node.id).toString('base64'),
})),
totalCount: await ctx.prisma.user.count(),
pageInfo: {
hasNextPage,
hasPreviousPage: Boolean(after),
startCursor: nodes[0] ? Buffer.from(nodes[0].id).toString('base64') : null,
endCursor: endCursor ? Buffer.from(endCursor).toString('base64') : null,
},
};
},
},
};
Notice the small take: limit + 1 technique. Fetching one extra row to determine hasNextPage is much cheaper than running a separate count. The cursor is wrapped in base64 so clients treat it as an opaque string rather than decoding it and making assumptions about its structure.
4. N+1: The Invisible Resolver Killer
GraphQL uses a field-level execution model, meaning every field can have its own resolver. This provides enormous flexibility, but it also creates GraphQL's most famous performance problem: N+1 queries.
Consider a resolver that almost everyone writes at some point:
const resolvers = {
Query: {
articles: () => db.article.findMany({ take: 20 }), // 1 query
},
Article: {
author: (parent) => db.user.findUnique({ where: { id: parent.authorId } }), // 1 query per article
},
};
The frontend query is:
query {
articles {
title
author {
name
}
}
}
The execution process is:
- The
articlesresolver runs one SQL query and returns 20 articles. - For each article, the
Article.authorresolver runs another SQL query for its author. - That is 1 + 20 = 21 SQL queries in total. If
author.postsis expanded inside each author, it becomes 1 + 20 + 200.
This is N+1. It is not a bug but a natural consequence of GraphQL's field-resolution model. Every resolver is independent, fields execute in parallel, and nothing coordinates the queries.
REST usually avoids this problem because it commonly means "one request, one join, with SQL written explicitly." GraphQL gives the query structure to the frontend, so responsibility for joins moves into the backend resolver layer. The standard treatment is DataLoader.
DataLoader: Request-Scoped Batching and Caching
Facebook created DataLoader specifically for this problem. It does two core things:
- Batching: Collects every
load(id)call within one event-loop tick and passes them to a batch function at once. - Caching: Loads the same key only once within a request.
Here is the revised resolver:
import DataLoader from 'dataloader';
export function createLoaders(ctx) {
return {
userLoader: new DataLoader<string, User>(async (ids) => {
// ids contains every user ID collected during this tick; deduplicate and query once
const rows = await db.user.findMany({ where: { id: { in: [...ids] } } });
const byId = new Map(rows.map((u) => [u.id, u]));
// Results must follow the input order exactly; use null for missing values
return ids.map((id) => byId.get(id) ?? null);
});
};
}
const resolvers = {
Query: {
articles: async (_p, _a, ctx) => {
const articles = await db.article.findMany({ take: 20 });
// Collect authorId values up front to trigger batching
for (const a of articles) ctx.loaders.userLoader.prime(a.authorId, undefined);
return articles;
},
},
Article: {
author: (parent, _a, ctx) => ctx.loaders.userLoader.load(parent.authorId),
},
};
The execution process becomes:
articlesruns one query for 20 articles.- GraphQL runs 20
Article.authorresolvers in parallel, each callinguserLoader.load(authorId). - These loads are collected in the same tick. DataLoader combines the 20 IDs, which may represent only five distinct authors after deduplication, into one
WHERE id IN (...)query. - The total is 1 + 1 = 2 SQL queries, regardless of the number of articles.
This is the standard solution to N+1. Several engineering details are critical:
1. DataLoader must be request-scoped, never a global singleton. The cache means "do not query the same value twice within this request." Reusing it across requests causes stale data and authorization leakage. Create a new loader instance for every request and attach it to the context:
// In the GraphQL context factory
export function buildContext({ req }) {
return {
loaders: createLoaders(),
user: req.user,
prisma,
};
}
2. The batch function must return results in exactly the same order as its inputs. DataLoader aligns results by index, so an incorrect order assigns data to the wrong keys. Beginners often get the ids.map((id) => byId.get(id) ?? null) step wrong. Returning rows directly does not work because the database does not guarantee result order.
3. Define clear cache boundaries. DataLoader's default behavior of loading a key only once per request is usually exactly right. If you read after a mutation, remember to clear the cache or rebuild the loader:
Mutation: {
updateUser: async (_p, args, ctx) => {
const user = await db.user.update({ where: { id: args.id }, data: args.input });
ctx.loaders.userLoader.clear(args.id); // Clear the stale value
return user;
},
}
4. DataLoader is not limited to databases. It can batch any asynchronous source, including RPC calls, third-party APIs, and internal services. Use it whenever N independent requests can be combined into one batch request. Calling an HTTP endpoint such as /users/batch?ids=... follows exactly the same pattern.
Finally, remember that N+1 rarely appears in development because the dataset is small and everything seems normal. It emerges in production on a popular list page, often on a Friday afternoon, as a performance incident. Before release, use EXPLAIN or request-level logging to count SQL queries. Apollo Server provides @apollo/server/plugin for resolver timing. Combined with DataLoader batch logs, it can identify nearly every N+1 issue.
5. Versioning and Evolution: Add, Never Remove, and Retire Gracefully with @deprecated
GraphQL does not use REST-style version paths such as /v2/users. Its official philosophy is a "versionless schema": avoid breaking changes through continuous evolution, with the goal of using one schema for ten years. That sounds ideal, but only if you strictly follow two rules.
Rule 1: Add, never remove. Any field that clients already depend on cannot be deleted or have its type changed directly. Changing String to Int, renaming a field, or changing nullable to non-null, which is more restrictive, are all breaking changes.
Rule 2: Use @deprecated to mark retirement. When a field must be retired, do not delete it directly. Mark it with @deprecated, tell the entire toolchain not to use it for new work, and provide a replacement:
type User {
id: ID!
name: String! @deprecated(reason: "Use displayName instead. name will be removed on 2026-10-01.")
displayName: String!
}
@deprecated has structural effects:
- Apollo Studio, GraphiQL, and similar tools gray out the field and display it with a strikethrough.
- ESLint and codegen rules can warn about or prohibit using deprecated fields in new code.
- GraphQL introspection exposes dedicated
isDeprecatedanddeprecationReasonfields, allowing scripts to report deprecations across the entire schema.
This is an observable retirement mechanism. You can determine exactly how many queries still use a deprecated field, who uses it, and when it is safe to remove. Apollo Server can use usage reporting to count real calls by field. Once a deprecated field has received no calls for 30 consecutive days, it can be removed safely.
Typical Evolution Scenarios
Scenario 1: Renaming a field. To change name to displayName, add displayName first, which is non-breaking. Then mark name as deprecated and have its resolver return the same value as displayName to preserve compatibility. Remove it only after monitoring shows that nobody uses name.
// During the transition, name still works
const resolvers = {
User: {
displayName: (user) => user.name,
name: (user) => user.name, // Equivalent alias retained only for compatibility
},
};
Scenario 2: Changing a field type. To change price: Float to price: Money, GraphQL does not allow changing the type directly. Add a new field instead:
type Product {
price: Float! @deprecated(reason: "Use moneyPrice instead to avoid floating-point precision issues.")
moneyPrice: Money!
}
Scenario 3: Adjusting enum values. Adding an enum value is non-breaking, although clients may not recognize it. Removing an enum value is breaking. Therefore, enum values should be added but not removed; deprecate old values while retaining them.
enum OrderStatus {
PENDING
PAID @deprecated(reason: "Use PROCESSING instead to standardize the state machine.")
PROCESSING
SHIPPED
DELIVERED
CANCELED
}
Important: Clients must handle unknown enum values. Older clients have never seen newly added enum values. Configure GraphQL client codegen to fall back to null or a default value for unknown values so that a new value does not crash the frontend. This is a related responsibility of schema evolution.
Schema Governance: Turn Rules into Automated Checks
Relying on people to follow evolution rules is unreliable. As the team grows, someone will eventually break the contract. Encode the rules as automated checks:
graphql-eslint: Require descriptions for fields, prohibit usingStringwhere an enum belongs, and enforce naming conventions.- Schema diff: In CI, compare the production schema with the schema in the current PR. Fail immediately when a breaking change is detected, such as deleting a field, narrowing a type, or removing an enum value.
- Deprecated-field monitoring: Run a scheduled script that counts calls to deprecated fields and produces a list of candidates for removal.
# Use graphql-inspector to diff schemas
npx graphql-inspector diff 'git:origin/main:schema.graphql' './schema.graphql' \
--rule 'breaking'
This is the dividing line for mature GraphQL engineering. Without schema governance, every GraphQL project eventually becomes an unmaintainable mess that nobody dares touch.
6. When to Use GraphQL and When Not to Use It
After discussing all these benefits, one point must be clear: GraphQL is not a silver bullet, and it makes many use cases worse. The value of an experienced engineer lies not only in knowing what to use, but also in knowing when not to use it.
When to Use GraphQL
1. Multiple clients consume the same data in different forms. A typical system serves Web, iOS, Android, and mini-program clients, each requiring different fields. In this situation, REST either overfetches, wasting mobile bandwidth, or explodes into endpoints such as /users/:id/profile, /users/:id/avatar, and /users/:id/brief. GraphQL lets each client declare what it needs, allowing one schema to serve every platform with substantial benefits.
2. The frontend needs autonomy and flexible data composition. Dashboard products often combine different slices of data on different pages. A content platform may need an article page with author details, related recommendations, and a comment summary. GraphQL lets the frontend adjust the data shape without a backend release, so the backend does not need a new endpoint for every page.
3. Multiple backend services must be aggregated. In a BFF (Backend for Frontend) architecture, GraphQL acts as an aggregation layer, combining data from several microservices or RPC endpoints into a frontend-friendly view. Resolvers call multiple downstream systems while the frontend sees only one tree. For this kind of data orchestration, GraphQL is far more elegant than a REST aggregation layer.
4. The team can realize the value of strong types and the toolchain. If the team uses codegen, Apollo Studio, and schema governance effectively, GraphQL's type system can substantially improve frontend-backend collaboration. Types become contracts, documentation becomes code, and changes become detectable. These are concrete engineering gains.
When Not to Use GraphQL
1. Simple CRUD where REST is entirely sufficient. Consider an internal admin dashboard with five tables and a dozen endpoints, built end to end by one developer. GraphQL creates unnecessary work. You must set up a server, configure loaders, write a schema, and maintain codegen, while nobody consumes the added flexibility. REST with OpenAPI is faster and more reliable at this scale.
2. Performance-sensitive systems with fixed query patterns. GraphQL's parsing layer, loader layer, and field-level resolvers all add overhead. For high-frequency operations with fixed payloads, such as log ingestion, analytics event reporting, or payment callbacks, REST or gRPC is faster. Parsing the GraphQL query language is not cheap, and very high request volumes amplify that cost.
3. Teams without GraphQL experience or governance capabilities. GraphQL's complexity is real. Schema design, N+1, versionless evolution, field-level authorization, and complexity limits against malicious nested queries all require specialized knowledge. If nobody on the team has handled these issues before, forcing GraphQL into the system creates something fashionable-looking that nobody dares change. REST's simplicity has real value; do not adopt GraphQL for its own sake.
4. Use cases that depend heavily on HTTP semantics. GraphQL sends everything through POST /graphql, losing HTTP's semantic layer: CDN caching with GET and Cache-Key, status codes where 401, 403, and 404 all become 200, ETag, and content negotiation. If public content requires strong HTTP caching, either use GraphQL persisted queries with GET or choose REST for a simpler solution. A mixed BFF approach, using GraphQL for reads and REST for writes, is also common.
5. Write-heavy, transaction-intensive systems. GraphQL's mutation model is relatively coarse. Complex transactions, cross-table consistency, and idempotency controls are often expressed more clearly with REST or RPC. GraphQL's main advantage is on the read side: flexible composition and on-demand fetching. Its benefits for writes are limited.
A Practical Decision Checklist
Before choosing GraphQL, ask these five questions:
- Will more than three clients consume the same data?
- Will the frontend frequently change which fields it fetches, with releases independent of the backend?
- Does the data come from multiple downstream systems that require aggregation?
- Can the team support schema governance through linting, diffs, and codegen?
- Can the team invest dedicated engineering effort in N+1 prevention and complexity controls?
With four or more "yes" answers, GraphQL offers clear benefits. With two or fewer, REST is more economical. In between, consider a hybrid architecture that uses GraphQL for reads and REST for writes rather than forcing everything into one approach.
The worst mistake in technology selection is assuming that newer means better. GraphQL solves one class of problems, including flexible data composition, typed contracts, and frontend autonomy, while introducing another class, including N+1, performance overhead, and governance complexity. Understanding what you are buying matters far more than chasing novelty.
Summary
GraphQL's central tension is always between flexibility and constraints. The schema carries those constraints. Design it well and flexibility becomes an advantage; design it carelessly and flexibility becomes debt. The key points are:
- The Schema is the contract, and its design comes before resolvers. Design the schema before writing the implementation. Treat it as a declaration of client capabilities, not a mirror of the database.
- Make types precise through enums, scalars, and nullability. Enums are always better than strings for closed value sets, custom scalars are better than an all-purpose String, and input and output types must be separate.
- Use Connections for pagination instead of inventing a proprietary format. Cursors are more stable than offsets, edges provide attachment points, and
pageInfois the shared language of the entire toolchain. - N+1 is GraphQL's default disease, and DataLoader is the standard remedy. Loaders must be request-scoped, batch functions must preserve input order, and production releases need query-count logging as a guardrail.
- Evolution depends on "add, never remove" plus @deprecated, enforced through schema governance. Versionless schemas are the ideal; schema diffs and deprecated-field monitoring in CI are the practical reality.
- Be unsentimental about adoption: use GraphQL for multiple clients, aggregation, and frontend autonomy; avoid it for simple CRUD, strong HTTP semantics, or teams without the necessary experience.
One final thought: With a well-designed schema, resolvers are routine work. With a poorly designed schema, resolvers become the fire brigade, and there is always another fire. The extra hour spent designing a schema can save countless late nights over the next three years.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
How Should a Technical Community Design Its Tag System and Recommendation Feed?
Centered on independent development, Next.js technology trends, engineering practices, and the indie developer perspective.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.