Why these building blocks matter
GraphQL APIs feel flexible because clients can ask for exactly what they need, but that flexibility is only safe and maintainable when the schema is built from a small set of well-defined building blocks. In practice, most day-to-day GraphQL work comes down to designing types, exposing fields through queries, changing data through mutations, and shaping write operations with input objects. This chapter focuses on how these pieces fit together, what rules they follow, and how to apply them in a way that stays predictable for clients and efficient for servers.
Types: the vocabulary of your API
Types define what can be requested and what shape the response will have. When a client selects fields, GraphQL validates that selection against the type system before any resolver runs. This means your type design is both a contract and a guardrail: it communicates meaning to consumers and prevents invalid requests from reaching business logic.
Object types: modeling what you return
Object types represent entities and aggregates you return from the API. Each field has a name, a type, and optionally arguments. A field can be a scalar, an enum, another object type, a list, or a non-null version of any of those. Object types should be designed around what clients need to read, not around database tables. It is normal for a single object type to combine data from multiple sources.
type User { id: ID! email: String! displayName: String! createdAt: DateTime! profile: UserProfile posts(limit: Int = 20, after: String): PostConnection!}type UserProfile { bio: String avatarUrl: URL}type Post { id: ID! title: String! body: String! author: User! tags: [Tag!]! publishedAt: DateTime}type Tag { id: ID! name: String!}Notice a few patterns: IDs are non-null because they are required to identify objects; optional fields like publishedAt indicate a draft; and posts is a field on User with arguments that control pagination. Even though posts returns a connection type, the client still experiences it as “a user has posts.”
Scalars and custom scalars: modeling primitives precisely
GraphQL includes built-in scalars like Int, Float, String, Boolean, and ID. Real APIs often need additional primitives such as timestamps, URLs, or JSON blobs. Custom scalars let you keep the schema expressive while validating and serializing values consistently.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
scalar DateTimescalar URLscalar JSONUse custom scalars when you want strong validation and consistent formatting. For example, a DateTime scalar can enforce ISO-8601 strings and reject invalid dates before resolvers run. Avoid overusing JSON as a shortcut; it weakens the contract and makes client code generation harder.
Enums: limiting values intentionally
Enums are ideal when a field can only be one of a known set of values. They improve discoverability and prevent invalid strings from slipping through.
enum PostStatus { DRAFT PUBLISHED ARCHIVED}Enums are also useful in inputs, such as specifying a sort direction or a filter mode.
Interfaces and unions: modeling polymorphism
Interfaces define a set of fields that multiple object types implement. Unions allow a field to return one of several object types without requiring shared fields. Both are useful when the client needs to handle different shapes in a single query.
interface Node { id: ID!}type User implements Node { id: ID! email: String! displayName: String!}type Post implements Node { id: ID! title: String! body: String!}union SearchResult = User | Post | TagInterfaces are great for shared identity and common fields. Unions are great for “search returns mixed results.” In both cases, clients use inline fragments to select fields based on the concrete type.
Nullability and lists: encoding guarantees
Nullability is one of the most important design decisions in GraphQL. String means “may be null,” while String! means “never null.” Lists add another layer: [Tag] means the list itself may be null and items may be null; [Tag!]! means you always return a list and it never contains null items. Choose the strongest guarantees you can reliably uphold.
- Use non-null for fields that are always present after successful authorization and business rules, such as
idorcreatedAt. - Use nullable fields to represent absence that is meaningful, such as
publishedAtfor drafts. - Prefer
[T!]!for collections when “no items” should be an empty list rather than null.
Queries: reading data through the schema
Queries define the entry points for reading data. The Query type is special: its fields are the top-level operations clients can execute to fetch data. Each query field should be designed to be stable, composable, and predictable in performance.
Designing query fields: single item, list, and connection patterns
A common baseline is to provide a way to fetch a single item by ID and a way to fetch lists with pagination. Even if your storage is relational, clients benefit from a consistent approach across resources.
type Query { user(id: ID!): User post(id: ID!): Post search(query: String!, limit: Int = 10): [SearchResult!]! posts(first: Int = 20, after: String, status: PostStatus): PostConnection!}type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo!}type PostEdge { cursor: String! node: Post!}type PageInfo { hasNextPage: Boolean! endCursor: String}The connection pattern is especially useful when you want cursor-based pagination and stable ordering. It also gives you a place to add pagination metadata without breaking clients.
Field arguments: filtering, pagination, and shaping
Arguments let clients control what they get back. Keep arguments focused and avoid creating “do everything” fields with dozens of optional parameters. When argument sets become complex, move them into input objects (even for queries) to keep the schema readable and evolvable.
input PostFilterInput { status: PostStatus tagIds: [ID!] authorId: ID publishedAfter: DateTime}type Query { posts(first: Int = 20, after: String, filter: PostFilterInput, sort: PostSortInput): PostConnection!}input PostSortInput { field: PostSortField! direction: SortDirection!}enum PostSortField { PUBLISHED_AT CREATED_AT}enum SortDirection { ASC DESC}This approach makes it easier to add new filter fields later without changing the query signature in a way that becomes unwieldy.
Step-by-step: mapping a query to resolvers
Even though GraphQL is query-based, the server still needs resolvers to fetch data. A practical way to implement queries is to start with the top-level field resolver and then add field resolvers only when you need custom behavior.
Step 1: Implement the top-level query resolver to fetch the root object or list. For example, Query.user fetches a user record by ID.
const resolvers = { Query: { user: async (_parent, args, ctx) => { return ctx.dataSources.users.getById(args.id); } }};Step 2: Let GraphQL’s default field resolver handle simple scalar fields when the returned object already has matching properties. If the user object has email and displayName properties, you do not need resolvers for them.
Step 3: Add field resolvers for computed fields or fields that require additional fetching. For example, User.posts might need pagination and filtering.
const resolvers = { Query: { user: (_p, { id }, ctx) => ctx.dataSources.users.getById(id) }, User: { posts: (user, { limit, after }, ctx) => { return ctx.dataSources.posts.getByAuthorId({ authorId: user.id, limit, after }); } }};Step 4: Ensure the resolver returns the shape promised by the schema. If User.posts returns a PostConnection, then it must return an object with edges and pageInfo. If your data source returns a different shape, adapt it in the resolver layer.
Mutations: changing data intentionally
Mutations define write operations: creating, updating, deleting, or triggering side effects. GraphQL executes mutation fields serially within a single operation, which helps avoid race conditions when multiple writes depend on each other. A mutation should be explicit about what it does and should return enough information for clients to update their UI without making extra round trips.
Mutation fields and payload design
A common pattern is to return a payload object rather than returning the mutated entity directly. Payloads give you room to include additional metadata such as validation errors, a success flag, or related objects. They also make it easier to evolve the mutation response over time.
type Mutation { createPost(input: CreatePostInput!): CreatePostPayload! updatePost(input: UpdatePostInput!): UpdatePostPayload! publishPost(input: PublishPostInput!): PublishPostPayload!}type CreatePostPayload { post: Post errors: [UserError!]!}type UpdatePostPayload { post: Post errors: [UserError!]!}type PublishPostPayload { post: Post errors: [UserError!]!}type UserError { message: String! field: String code: String}Returning errors as data is different from GraphQL’s top-level errors array. Top-level errors are best for unexpected failures (exceptions, outages). Payload errors are best for expected validation issues (missing title, invalid state transition) that clients should handle gracefully.
Step-by-step: implementing a create mutation with validation
Step 1: Define an input object that contains exactly what the client is allowed to provide. Avoid accepting server-managed fields like id or createdAt.
input CreatePostInput { title: String! body: String! tagIds: [ID!] = []} Step 2: Implement the resolver to authenticate, validate, and persist. Keep the resolver thin by delegating to services or data sources, but ensure the resolver is responsible for shaping the GraphQL response.
const resolvers = { Mutation: { createPost: async (_p, { input }, ctx) => { if (!ctx.viewer) { return { post: null, errors: [{ message: "Not authenticated", code: "AUTH" }] }; } const errors = []; if (input.title.trim().length < 3) { errors.push({ message: "Title is too short", field: "title", code: "VALIDATION" }); } if (errors.length) return { post: null, errors }; const post = await ctx.services.posts.create({ authorId: ctx.viewer.id, title: input.title, body: input.body, tagIds: input.tagIds }); return { post, errors: [] }; } }};Step 3: Return the newly created object (or enough identifiers) so the client can update its cache. Many clients will request a selection set that includes id, title, and any fields needed to render the new post in a list.
Step-by-step: implementing an update mutation safely
Updates often need partial inputs. In GraphQL, you typically model this by making fields optional in the input type. You also need to decide whether “omitted” means “no change” and whether “null” is allowed to clear a value. Be explicit: if clearing is allowed, accept nullable fields and interpret null as “clear.” If clearing is not allowed, keep the field non-null and require a value when present.
input UpdatePostInput { id: ID! title: String body: String tagIds: [ID!]} Step 1: Fetch the existing record and verify permissions before applying changes.
updatePost: async (_p, { input }, ctx) => { if (!ctx.viewer) return { post: null, errors: [{ message: "Not authenticated", code: "AUTH" }] }; const existing = await ctx.dataSources.posts.getById(input.id); if (!existing) return { post: null, errors: [{ message: "Post not found", code: "NOT_FOUND" }] }; if (existing.authorId !== ctx.viewer.id) { return { post: null, errors: [{ message: "Forbidden", code: "FORBIDDEN" }] }; } const patch = {}; if (typeof input.title === "string") patch.title = input.title; if (typeof input.body === "string") patch.body = input.body; if (Array.isArray(input.tagIds)) patch.tagIds = input.tagIds; const updated = await ctx.services.posts.update(input.id, patch); return { post: updated, errors: [] };}Step 2: Ensure your schema communicates what “partial update” means. With the input above, omitting tagIds means “leave tags unchanged,” while providing an empty list means “remove all tags.” That distinction is useful and should be documented in your field descriptions in a real schema.
Mutations and side effects: modeling actions
Not all mutations map cleanly to CRUD. Actions like publishPost, resetPassword, or addUserToTeam represent state transitions. Model them as verbs, keep inputs minimal, and validate transitions in the resolver or service layer.
input PublishPostInput { id: ID!} For state transitions, return the updated object and any relevant errors. If the action triggers asynchronous work (like sending emails), consider returning an additional field such as jobId in the payload so clients can track progress via a query or subscription.
Input objects: shaping writes and complex arguments
Input objects are used for mutation arguments and for complex query arguments. They are distinct from object types: input types can only contain scalars, enums, and other input types, not output object types. This separation prevents clients from sending arbitrary nested objects that the server cannot validate properly.
Designing inputs for clarity and evolution
Good inputs are explicit, minimal, and stable. They should represent what the client is allowed to control, not the entire internal model. A few practical guidelines help keep inputs clean as your API grows.
- Prefer a single
inputargument per mutation:updatePost(input: UpdatePostInput!). This makes it easier to add fields later without changing the argument list. - Use nested input objects to group related fields, such as separating
profilefields from top-level user fields. - Use enums for fields with limited values, such as
visibilityorrole, to prevent invalid strings. - Be careful with default values in inputs; defaults can be convenient, but they also hide behavior. Use them when the default is truly universal and unlikely to change.
Step-by-step: nested inputs for a profile update
Step 1: Define a nested input that mirrors the shape you want clients to send, while still restricting it to allowed fields.
input UpdateUserInput { id: ID! displayName: String profile: UpdateUserProfileInput}input UpdateUserProfileInput { bio: String avatarUrl: URL}Step 2: In the resolver, interpret omitted vs provided fields carefully. If profile is omitted, do not touch profile fields. If profile is provided with bio: null, decide whether that clears the bio or is invalid. Your schema should reflect that decision by making bio nullable or non-null accordingly.
UpdateUser: async (_p, { input }, ctx) => { const patch = {}; if (typeof input.displayName === "string") patch.displayName = input.displayName; if (input.profile) { patch.profile = {}; if (input.profile.bio !== undefined) patch.profile.bio = input.profile.bio; if (input.profile.avatarUrl !== undefined) patch.profile.avatarUrl = input.profile.avatarUrl; } const user = await ctx.services.users.update(input.id, patch); return { user, errors: [] };}Step 3: Keep the input stable even if your internal storage changes. If you later split profile data into another service, the input can remain the same while the resolver delegates to multiple backends.
Putting it together: a cohesive mini schema
Seeing the building blocks together helps clarify the boundaries between read models, write models, and shared primitives. The following mini schema combines object types, queries, mutations, and inputs in a consistent style.
scalar DateTimescalar URLenum SortDirection { ASC DESC }enum PostStatus { DRAFT PUBLISHED ARCHIVED }type Query { user(id: ID!): User posts(first: Int = 20, after: String, filter: PostFilterInput, sort: PostSortInput): PostConnection!}type Mutation { createPost(input: CreatePostInput!): CreatePostPayload! updatePost(input: UpdatePostInput!): UpdatePostPayload! publishPost(input: PublishPostInput!): PublishPostPayload!}type User { id: ID! email: String! displayName: String! profile: UserProfile posts(limit: Int = 20, after: String): PostConnection!}type UserProfile { bio: String avatarUrl: URL}type Post { id: ID! title: String! body: String! status: PostStatus! author: User! publishedAt: DateTime tags: [Tag!]!}type Tag { id: ID! name: String!}type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo!}type PostEdge { cursor: String! node: Post!}type PageInfo { hasNextPage: Boolean! endCursor: String}input PostFilterInput { status: PostStatus authorId: ID tagIds: [ID!] publishedAfter: DateTime}input PostSortInput { field: PostSortField! direction: SortDirection!}enum PostSortField { PUBLISHED_AT CREATED_AT }input CreatePostInput { title: String! body: String! tagIds: [ID!] = []}input UpdatePostInput { id: ID! title: String body: String tagIds: [ID!]}input PublishPostInput { id: ID!}type CreatePostPayload { post: Post errors: [UserError!]!}type UpdatePostPayload { post: Post errors: [UserError!]!}type PublishPostPayload { post: Post errors: [UserError!]!}type UserError { message: String! field: String code: String}This example highlights a practical separation: object types describe what you can read, while input types describe what you can write or pass as complex arguments. Queries provide stable entry points for fetching, and mutations provide explicit, validated ways to change state.
Common pitfalls and how to avoid them
Overloading queries with write-like behavior
Queries should not cause side effects. If a field triggers an email, writes an audit record, or changes state, it should be a mutation. Keeping this boundary clear helps with caching, observability, and client expectations.
Using output types as inputs
GraphQL intentionally separates input and output types. If you find yourself wanting to reuse an object type as an input, it usually means the input is too broad. Create a dedicated input type that includes only client-controlled fields.
Weak nullability choices
Making everything nullable seems safe, but it pushes complexity to clients and hides server guarantees. Prefer non-null where you can uphold it. When a field can be absent due to authorization, consider whether you should omit access by not exposing the field at all for that role, or return null with a clear contract and consistent behavior.
Mutations that return too little
If a mutation returns only a boolean, clients often need to refetch data, increasing load and latency. Returning the updated object (or at least its ID and key fields) enables clients to update local state efficiently.
Inputs that are too generic
Inputs like data: JSON or patch: JSON make validation and tooling difficult. Prefer explicit fields and nested input objects. If you need a flexible structure for a specific use case, consider a targeted input design that still constrains shape, such as a list of key-value pairs with an enum key.