Schema Design (8 Questions)
Duration: ~25 minutes | Difficulty: Intermediate | Prerequisites: Core Concepts (10 Questions)
Schema design is where GraphQL expertise becomes most visible and most consequential. The decisions made in a schema are difficult to reverse. Clients may be relying on the exact shape of a type months or years after it was first shipped. Changing it then means either a breaking change, which breaks those clients, or a versioning story, which adds complexity. The questions in this chapter test whether you treat schema design as a discipline with lasting consequences, not as a preliminary sketch before "real" coding begins.
Question 1: When should a field be nullable vs. non-null?
Short answer: A field should be non-null (!) only when the server guarantees it will always have a value, that it will never be null in any production scenario, including partial failures in other resolvers. When in doubt, lean nullable, because changing a non-null field to nullable is a non-breaking change, but changing a nullable field to non-null is breaking.
Why this is right / what interviewers want to hear:
This is one of the most contested areas of GraphQL schema design, and the interviewer is likely asking because they have opinions. The answer that demonstrates real understanding acknowledges that the question has two distinct dimensions: what the field semantically could be, and what the runtime implications of non-null are.
The semantic dimension is the one most candidates address: use String! when the value is always present (a movie's title), use String when the value might legitimately be absent (a movie's tagline, which some movies don't have). That answer is correct, and it addresses only one of the two dimensions the question is really about.
The runtime dimension is the one that trips up candidates who have not been burned by it in production. When a non-null field's resolver throws an exception, GraphQL's null-propagation rules force the null to propagate up to the nearest nullable ancestor. If your schema has a deeply nested non-null field inside a non-null chain (Movie.cast: [Person!]!, Person.name: String!), and Person.name's resolver throws an exception for one person, the null propagates up through Person, which is non-null, so the person object becomes null. It carries on up through cast, also non-null, and potentially up to Movie itself. A single resolver failure can null out an entire subtree.
Every hop upward happens because the position above cannot hold a null. The propagation stops at the first position that can, which is why one nullable ancestor is worth so much.
This is why our schema design advice is to default to nullable for types and connections that you don't fully control, especially when there are complex multi-resolver chains involved. An exception or network failure in one leaf resolver should not destroy the entire response. Non-null is a promise you are making to the client, and breaking it is worse than never making it.
The breaking-change asymmetry is the practical rule to memorize: nullable → non-null is breaking (existing clients may be handling the null case; now you are promising a value that could still fail at runtime). Non-null → nullable is safe (existing clients that assumed a value is present still work; new clients just need to handle the null case). When in doubt, ship nullable and tighten later, never ship non-null and loosen later.
The Spring GraphQL implementation adds one more consideration: Spring maps Java's Optional<T> to a nullable GraphQL field, and a non-null Java return type (no Optional) maps to a non-null field with the @NonNull annotation on the schema mapping method. The language-level nullability of your resolver method affects the schema contract.
The answer that marks a candidate as current adds where this is heading. Defaulting to nullable protects the response and costs the client honesty: a field that is nullable only because a resolver might fail looks the same, in the schema, as a field that is genuinely optional. Two pieces of work address that. @semanticNonNull annotates a field as never semantically absent while leaving it nullable on the wire, so a failure stays local and code generators can still emit a non-null type for clients that opt in. Alongside it, the specification is working on client-controlled error behavior, a request-level onError setting that lets a client ask for a null in place of propagation. Neither is merged into the specification yet, and knowing that they exist, and what problem they solve, is what the question is really probing at senior level.
Question 2: How do you handle pagination in GraphQL, what are the trade-offs between offset, cursor, and Relay connections?
Short answer: Offset pagination (first: 10, offset: 20) is simple to implement and supports random access but breaks when items are inserted or deleted between pages. Cursor-based pagination (first: 10, after: "cursor") is stable across mutations and is the foundation of the Relay connection spec. Relay connections are the most complete standard, adding edges, node, pageInfo, and cursor to give clients full traversal capability, at the cost of a more verbose schema.
Why this is right / what interviewers want to hear:
Pagination is a practical trade-off question, and interviewers ask it to see whether you know the real costs, not just the definitions.
Offset pagination works by asking for page N of a fixed page size. It is easy to implement (LIMIT 10 OFFSET 20 in SQL), it supports jumping to page 5 without traversing pages 1 through 4, and it maps naturally to how most database query layers work. The problem: if a new item is inserted before the offset while the client is paginating, items shift. The client that asked for offset 20 now gets what was offset 21. Items can be skipped. If items are deleted, items can appear twice. For product lists that are being constantly updated (an e-commerce catalog, a social feed), offset pagination produces a flickering, unreliable pagination experience.
Cursor-based pagination avoids this by using a pointer (cursor) that references a specific item instead of a position. The server remembers where the client is in terms of a real record identifier, not a count. When new items are added before the cursor, the cursor still points to the same place. The cursor is typically an opaque base64-encoded string containing enough information for the server to reconstruct the query (WHERE id > 12345 ORDER BY id ASC LIMIT 10), though the exact contents are implementation-defined. The cost: no random access. A client cannot jump to "page 5" without traversing pages 1 through 4. Also, the cursor must be valid, if the server's sort order or data model changes in a way that invalidates cursor semantics, old cursors become meaningless.
Relay connections are the standardized schema pattern for cursor pagination, specified in the Relay cursor connections spec and summarized on graphql.org's pagination page. A MovieConnection type carries edges: [MovieEdge!]!, where each edge has node: Movie! and cursor: String!, plus a pageInfo object with hasNextPage, hasPreviousPage, startCursor and endCursor. The connection and edge types take the singular node type's name, so Movie gives MovieConnection and MovieEdge. Spring for GraphQL's built-in connection support depends on that, deriving the node type by stripping the Connection suffix from the return type name. The pageInfo fields give clients the information they need to render "load more" buttons and to know when they have reached the end of the list.
The verbosity is the trade-off. A Relay connection requires three types per paginated relationship (the connection type, the edge type, and the node type), which can significantly expand a schema. For APIs that use many paginated fields, this is manageable. For simple internal APIs, it can feel over-engineered.
For interviews, the guideline is short. Relay connections suit any public or partner-facing API, because they give clients the most stable and interoperable contract, and many GraphQL client libraries support the Relay pagination protocol already. For internal APIs where you control both the client and the server and the data changes rarely, offset pagination is acceptable and simpler to implement.
Question 3: What is the userErrors pattern and why is it better than using the errors array for mutation failures?
Short answer: The userErrors pattern returns expected domain errors (validation failures, constraint violations) inside the mutation payload itself as a typed field, instead of via the top-level errors array. This gives clients a schema-enforced, typed contract for expected failure modes, they can match on an enum code instead of parsing an opaque message string.
Why this is right / what interviewers want to hear:
This question is closely related to Question 10 in the Core Concepts chapter but goes deeper into the why. The interviewer is asking you to evaluate a design pattern, not just define it.
The core problem with the top-level errors array for mutation failures is that it is not typed. The spec defines errors as an array of objects with message: String, locations, path, and optionally extensions. The extensions field is untyped (Map<String, Object> in the Java world). A client code generator cannot produce a type-safe enum from the possible error codes, because those codes are not defined in the schema. A client has to parse error.extensions.code as a string and hope the server always uses the same strings, which creates an undocumented contract that breaks silently.
The userErrors pattern fixes this by treating expected failures as first-class schema types. The mutation return type carries userErrors: [UserError!]! where UserError is defined in the schema, with a code: UserErrorCode! field and UserErrorCode is a proper enum. Code generators can produce type-safe switch statements. API documentation tools can list all the possible error codes. A breaking change to the error contract is visible as a schema change.
The distinction that separates great answers: the pattern is specifically for expected user-facing failures, not for unexpected system failures. If the database goes down during a createMovie mutation; that is not a UserError. It is an unexpected system failure, and it should appear in the top-level errors array as a generic error. The mutation payload userErrors list will be empty or absent. A client cannot meaningfully handle an infrastructure failure in a programmatic switch statement, and pretending it can by adding an enum case DATABASE_UNAVAILABLE is false abstraction.
The practical implementation in Spring GraphQL: define the UserError interface in the schema, implement it on domain-specific error classes in Java, and return the errors from the mutation resolver as part of the payload object. The mapping from exception to UserError happens in the service layer, not in the resolver, which keeps the resolver thin.
Shopify's public graphql-design-tutorial documents this pattern extensively and is worth reading before any interview about GraphQL schema design.
Question 4: When should you use input types vs. inline scalar arguments?
Short answer: Use a named input type when a mutation or query takes more than two or three arguments, when the arguments logically form a cohesive unit, or when you anticipate that the argument list will evolve over time. Use inline scalar arguments for simple, stable lookups (such as movie(id: ID!)) where the argument count is unlikely to grow.
Why this is right / what interviewers want to hear:
This is a pragmatic design question. The answer that interviewers want is one grounded in real consequences, not just style preferences.
The primary argument for input types over inline scalars for complex mutations is evolution. A createMovie(title: String!, releaseYear: Int!, directorId: ID!) mutation is fine when it ships. A year later, the team needs to add tagline: String, genre: String, studioId: ID, and productionBudget: Int. Each addition is a schema change. With inline scalar arguments, the resolver method signature gets longer and more fragile, and the query documents clients must send grow in complexity with each addition. With an input type, CreateMovieInput can add nullable fields without changing the mutation's argument signature, the mutation still takes input: CreateMovieInput!, and the change is non-breaking.
The second argument for input types: they are reusable. If you have createMovie(input: CreateMovieInput!) and updateMovie(id: ID!, input: UpdateMovieInput!), and you define CreateMovieInput with all required fields and UpdateMovieInput with all optional fields (for partial updates), the input type structure communicates intent clearly. Clients know immediately which fields are required for creation vs. update.
The case for inline scalars: for simple read queries (movie(id: ID!)), creating an input type (movie(input: MovieLookupInput!)) is over-engineered. The argument is small, stable, and semantically simple. Wrapping it in an input type would require clients to write { movie(input: { id: "123" }) { ... } } instead of { movie(id: "123") { ... } }. The extra nesting only adds cognitive overhead.
The rule of thumb that plays well in interviews: input types for mutations, inline scalars for simple queries. If a query takes more than two scalar arguments, consider an input type there too.
Question 5: How do you deprecate a field without breaking existing clients?
Short answer: Add the @deprecated(reason: "Use newField instead") directive to the field in the schema. The field remains in the schema and continues to work for existing clients, but it is flagged in documentation and tooling. Coordinate with clients to migrate away, then remove it after all clients have stopped using it, using schema analytics or persisted query data to confirm zero usage.
Why this is right / what interviewers want to hear:
Deprecation in GraphQL is a process, not a single action, and the interviewer wants to hear that you understand the process.
The @deprecated directive does several things. It causes the field to be excluded from documentation tools by default (GraphiQL hides deprecated fields unless the user explicitly asks to show them). Code generators for typed GraphQL clients can emit compiler warnings when deprecated fields are used. Apollo GraphOS and similar tools can track usage of deprecated fields across registered client queries.
What the directive does not do: it does not prevent the field from being queried. Clients that were using the deprecated field before you added the directive continue to work unchanged. This is intentional, the point of deprecation is to signal intent without breaking anyone.
The practical process for safe deprecation:
- Add
@deprecatedto the field with a meaningful reason that tells clients what to use instead. - Add the replacement (the new field, renamed field, or improved type) to the schema.
- Monitor field usage using query analytics. Apollo GraphOS, GraphQL Hive, the Spring GraphQL tracing actuator, or custom instrumentation can show which clients are querying the deprecated field and at what volume.
- Notify clients (internal teams, API partners) of the deprecation timeline.
- Once usage reaches zero (confirmed by monitoring for a suitable observation window), remove the field.
The removal step is the only breaking change in this process, and it should be gated on confirmed zero usage. "We announced the deprecation six months ago" is not the same as "we confirmed zero usage." In practice, clients do not always respond to deprecation notices, and removing a field that still has active usage will break those clients regardless of how politely you deprecated it.
A complete answer should mention that for public APIs, the deprecation window should be documented and enforced consistently. GitLab documents a six-release window in its GraphQL style guide, and graphql.org's versioning page sets out the change taxonomy this section uses. This builds trust with API consumers: they know that fields will not be removed without warning, and they know the warning will be meaningful.
Question 6: What is a breaking change in GraphQL? Give three examples.
Short answer: A breaking change in GraphQL is any schema change that could cause a currently valid client query to fail, return unexpected results, or lose access to data. Three examples: removing a field, changing a nullable field to non-null, and removing an enum value that clients may be sending as input or matching in responses.
Why this is right / what interviewers want to hear:
This question is testing whether you have internalized the client-impact perspective. Interviewers want candidates who think about the downstream effect of a schema change, not just the server-side correctness.
Here is a taxonomy of breaking changes that a strong answer should cover:
Removals are always breaking. Removing a field, a type, an argument, or a directive that clients may be using breaks those clients. Removing an enum value that clients send as input breaks mutations that use that value. Removing a type that is referenced in a client's fragment breaks that fragment.
Type narrowing on outputs is breaking. Changing movie: Movie to movie: Film (where Film is a different type name) breaks clients that query movie { ... }. Changing String to Int for a field breaks clients that expect a string. Changing a nullable field to non-null (String → String!) is technically breaking because it is a semantic promise the server may not be able to keep, and tools like GraphQL Inspector flag it as breaking for this reason.
Type widening on inputs is breaking. If a mutation argument changes from String to Int, existing clients sending string values will get validation errors. If a nullable argument becomes required (non-null), clients that were omitting it now fail validation.
Renaming is equivalent to removal and addition. Renaming Movie.releaseYear to Movie.year is a breaking change because existing clients querying releaseYear will get a validation error. The migration path is to add year (aliased to the same data), deprecate releaseYear, and remove releaseYear after migration.
Changing union membership can be breaking. Adding a type to a union is safe. Removing a type from a union breaks clients that handle that type in inline fragments.
Tools that automate breaking change detection: GraphQL Inspector, Apollo's contract checks, Rover CLI. These tools compare two schema versions and flag breaking changes. Integrating one of these into a CI pipeline is the standard practice for any API with external clients.
Question 7: How do you design for polymorphism, when do you use an interface vs. a union?
Short answer: Use an interface when the polymorphic types share meaningful fields and you want clients to be able to query those fields without type-specific inline fragments. Use a union when the types do not share fields, or when the grouping is purely for the client's ability to handle multiple concrete types from one field.
Why this is right / what interviewers want to hear:
This question extends Question 6 from Chapter 1 into the design decision frame, which is what the interviewer is really after. The definitions are background; the judgment call is the foreground.
The interface decision rule: start by asking what the shared semantics are. If you have a Notification type that can be a CommentNotification, a LikeNotification, and a MentionNotification, and all three have id, createdAt, and recipient fields, an interface is the right choice. A client rendering a notification list can display the common metadata without needing to know the concrete type, and can then use type-specific fragments to render the notification body.
The union decision rule: if the types being grouped lack meaningful shared fields, or if the shared fields are so generic (id: ID!) that selecting them doesn't actually simplify client code, use a union. A SearchResult union that includes Movie, Person, and Genre makes sense as a union because a movie and a genre do not share meaningful display fields.
A real-world pattern worth mentioning: the "result union" pattern for mutations, where a mutation returns union CreateMovieResult = CreateMovieSuccess | ValidationError | AuthorizationError. This is an alternative to userErrors that uses GraphQL's type system more fully: each error type can have its own fields. ValidationError might have field: String and message: String. AuthorizationError might have requiredRole: String. Clients write ... on ValidationError { field message } for type-safe error handling. The trade-off is that the client must handle every union member explicitly, which is verbose but exhaustive. Some teams prefer this to userErrors because it more strictly enforces error handling in client code.
The practical signal in an interview: if you describe this "result union" pattern as an alternative to userErrors, you demonstrate awareness of the current state of the field and the ongoing discussion about the best approach.
Question 8: What naming conventions do you follow for GraphQL types, fields, and mutations?
Short answer: Types are PascalCase (Movie, CreateMovieInput). Fields and query names are camelCase (releaseYear, moviesByGenre). Enum values are SCREAMING_SNAKE_CASE (RELEASE_DATE_ASC). Mutations are named as domain verbs followed by the resource noun (createMovie, deleteMovie, addMovieToCatalog), not as REST-verb prefixes (postMovie, putMovie).
Why this is right / what interviewers want to hear:
Naming convention questions sound trivial, and they are not. Consistent naming in a public schema is part of the contract. Renaming a field is a breaking change, so the names you choose at the start are largely the names you keep. The cost of choosing badly compounds for as long as the schema lives.
The PascalCase / camelCase convention is universal, derives from the spec's own examples, and is written down on graphql.org's naming page. Deviating from it is a code smell.
The mutation naming convention is the interesting part. The standard GraphQL convention is to use a domain verb followed by the resource name: createMovie, not movieCreate. Shopify's public design tutorial explicitly advocates the reverse (movieCreate) for a specific reason: alphabetical grouping in documentation. When you have movieCreate, movieUpdate, movieDelete, orderCreate, orderCancel, and orderRefund, they sort into clean groups in the documentation because all movie mutations are adjacent and all order mutations are adjacent. When you use createMovie, updateMovie, deleteMovie, createOrder, cancelOrder, refundOrder, the mutation list sorts by verb and the resource-based grouping is lost.
This is a real and contested convention. Both are defensible. The interview answer that demonstrates sophistication acknowledges both conventions, explains the reasoning behind each (verb-first is natural English; resource-first enables documentation grouping), and states which you would use and why. "I use verb-first for internal APIs because teams read the mutations in code more than in docs, and verb-first reads naturally. For public partner APIs, I'd consider resource-first for the documentation benefit."
Enum naming: SCREAMING_SNAKE_CASE is universal. Enums in GraphQL often represent states or sort orders (RELEASE_DATE_ASC, TITLE_DESC, STATUS_ACTIVE), and the convention makes them visually distinct from field names and type names.
Input type naming: the universal convention is to suffix with Input (CreateMovieInput, UpdateMovieInput). This prevents confusion between the Movie type (the output type) and the CreateMovieInput type (the input type for creating a movie). Without the suffix, you would have a type named Movie and an input type also named Movie and the GraphQL spec forbids types and input types with the same name.
Further Reading
- Schema design and naming conventions on graphql.org are the closest thing the ecosystem has to an official style guide, and they cover most of this chapter.
- Pagination explains the connection model, with the Relay cursor connections spec as the normative source behind it.
- Versioning and change management is where the breaking-change taxonomy in Question 6 comes from.
- Shopify's GraphQL design tutorial is the original write-up of the
userErrorspattern, and it reads like a schema review you were invited to. - GitLab's GraphQL style guide shows one team's deprecation policy written down and enforced, which is the answer Question 5 is fishing for.