APIs: Concepts, Design, Requests, Responses, and Integration
GraphQL: Concepts, Queries, Schemas, and API Design
Learn GraphQL fundamentals, SDL schemas, queries, mutations, subscriptions, resolvers, pagination, performance, security, testing, and production API evolution.
What GraphQL is
GraphQL is a typed query language and server runtime for APIs. A client sends an operation describing the fields it needs, and the GraphQL server validates and executes that operation against a schema. The response is usually JSON.
GraphQL is not a database and does not require a particular programming language, database, or transport implementation. A server can use resolvers to obtain data from a database, another HTTP API, a file, or several sources at once.
Problems GraphQL addresses
- Over-fetching: a response contains more fields than the client needs.
- Under-fetching: one response lacks related data, so the client must make more requests.
- Multiple endpoint requests: a screen may need separate requests for a book, its author, and related reviews.
With GraphQL, the client can request a selected shape of nested data in one operation. This does not make every operation faster: the server still has to resolve the requested fields, and poorly designed queries can be expensive.
GraphQL and REST
REST commonly organizes resources around URLs such as /books/101 and uses HTTP methods to describe actions. GraphQL commonly exposes one endpoint, such as /graphql, and places the operation and field selection in the request body. REST can provide excellent caching, simple resource boundaries, and predictable responses; GraphQL can provide flexible selection and a cohesive view across sources. Neither approach is a universal replacement for the other.
HTTP requests
GraphQL servers commonly accept POST requests with a JSON body containing query, optional variables, and optional operationName. Some servers support GET for read-only queries, which can help browser and HTTP caching. Mutations should use POST; a subscription usually requires an additional persistent transport such as WebSocket.
POST /graphql HTTP/1.1
Content-Type: application/json
Authorization: Bearer <token>
{
"operationName": "GetBook",
"query": "query GetBook($id: ID!) { book(id: $id) { id title } }",
"variables": { "id": "book-101" }
}GraphQL architecture
The schema is the API contract. It declares types, fields, arguments, input values, and root operations. The type system lets a server validate a client operation before execution. An operation is a query, mutation, or subscription sent by a client. A resolver supplies a field's value during execution.
A typical execution sequence is:
- Parse the operation document.
- Validate its fields, arguments, variables, and selection sets against the schema.
- Choose the named operation if the document contains several operations.
- Coerce variables and arguments into their declared types.
- Execute root and nested resolvers.
- Serialize scalar values and assemble the requested response shape.
- Return
data,errors, and optionallyextensions.
{
"data": {
"book": { "id": "book-101", "title": "A Sample Book" }
},
"errors": [],
"extensions": { "traceId": "req-42" }
}Schema Definition Language
SDL means Schema Definition Language. It is a textual syntax for defining a schema. An object type has named fields. Fields can return scalar values, objects, lists, or abstract types.
type Query {
book(id: ID!): Book
books(first: Int = 20, after: String): BookConnection!
}
type Book {
id: ID!
title: String!
author: Author!
}
type Author {
id: ID!
name: String!
}
type BookConnection {
edges: [BookEdge!]!
pageInfo: PageInfo!
}
type BookEdge {
cursor: String!
node: Book!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}Scalars, lists, and nullability
Built-in scalar types are Int, Float, String, Boolean, and ID. A scalar is a leaf value: a client cannot select subfields beneath it. ID represents an identifier and is serialized as a string even when its source is numeric. A custom scalar, such as DateTime or Money, requires parsing and serialization rules in the server.
Nullability is part of the contract. Do not mark a field non-null merely because it is usually present; use ! when the server can reliably guarantee it.
Arguments and input objects
An argument is an input supplied to a field, such as an ID, filter, sort order, or page size. Input object types structure related arguments and are especially useful for mutations. Input types are separate from output object types.
input BookFilter {
authorId: ID
search: String
}
type Query {
books(first: Int = 20, filter: BookFilter): BookConnection!
}Enums, interfaces, unions, and descriptions
An enum restricts a value to named choices, such as BookSortField. An interface defines fields shared by multiple object types. A union represents one of several object types without requiring shared fields. Descriptions written as quoted SDL strings become schema documentation.
"""A publication available in the library."""
type Book {
id: ID!
title: String!
}
enum SortDirection { ASC DESC }
interface Node { id: ID! }
type Author implements Node {
id: ID!
name: String!
}
union SearchResult = Book | AuthorThe schema can explicitly identify root operation types:
schema {
query: Query
mutation: Mutation
subscription: Subscription
}If conventional names are used, many implementations infer Query, Mutation, and Subscription as the roots.
Common schema constructs
Queries
A query is a read operation. Scalar fields are selected directly; object fields require a nested selection set. The operation name is optional for a single anonymous operation but useful for logs, metrics, and documents containing multiple operations.
query GetBook {
book(id: "book-101") {
id
title
author {
name
}
}
}An alias changes the response key without changing the schema field. Variables are typed values supplied separately from the query document. They avoid string concatenation and make operation execution safer and easier to cache.
query CompareBooks($firstId: ID!, $secondId: ID!) {
first: book(id: $firstId) {
id
title
author { name }
}
second: book(id: $secondId) {
id
title
}
}{
"firstId": "book-101",
"secondId": "book-202"
}Variable definitions can have default values, for example $limit: Int = 20. Arguments can implement filtering, sorting, and pagination, but the server must validate bounds such as a maximum page size.
Fragments and conditional selections
A fragment is a reusable selection set for a particular type.
fragment BookSummary on Book {
id
title
author { name }
}
query Library($first: Int = 20) {
books(first: $first) {
edges { node { ...BookSummary } }
}
book(id: "book-101") { ...BookSummary }
}An inline fragment selects fields conditionally when a result has an interface or union type. __typename tells the client which concrete type was returned.
query Search($term: String!) {
search(term: $term) {
__typename
... on Book { id title }
... on Author { id name }
}
}Directives annotate selections or schema definitions. The built-in @include(if: Boolean!) includes a field conditionally, while @skip(if: Boolean!) omits it when the condition is true.
query BookDetails($id: ID!, $withAuthor: Boolean!) {
book(id: $id) {
title
author @include(if: $withAuthor) { name }
}
}Mutations
A mutation represents an operation intended to change server-side state: creating, updating, or deleting a resource. A mutation should accept a structured input and return a payload designed for the client.
input AddBookInput {
title: String!
authorId: ID!
}
type UserError {
field: String
message: String!
}
type AddBookPayload {
book: Book
errors: [UserError!]!
}
type Mutation {
addBook(input: AddBookInput!): AddBookPayload!
}
mutation AddBook($input: AddBookInput!) {
addBook(input: $input) {
book { id title }
errors { field message }
}
}Payloads can return the created or updated resource, a success indicator, warnings, validation errors, and metadata such as a client mutation ID. Expected domain failures should be represented consistently, while unexpected failures belong in the GraphQL errors array. Top-level mutation fields are executed serially, which helps preserve the expected order of state changes; nested field resolution can still be parallelized where safe.
Subscriptions
A subscription is a long-lived operation that receives payloads when relevant events occur. It is suitable for collaborative editing, notifications, live monitoring, or other updates where immediate delivery matters.
subscription BookAdded {
bookAdded {
book { id title }
occurredAt
}
}The server must maintain an event source and a persistent connection. WebSocket or a similar transport is commonly used; ordinary request-response HTTP is not sufficient for a continuous stream. Authenticate during connection setup and re-check authorization for each event or subscription scope. Polling is often simpler and preferable when updates are infrequent, a small delay is acceptable, or infrastructure does not support persistent connections.
Resolvers and data fetching
A resolver is a server-side function or mechanism that supplies one field's value. A common resolver signature receives:
- Parent: the value returned by the parent field.
- Arguments: the field's validated arguments.
- Context: request-scoped services, authentication data, loaders, and tracing information.
- Info: execution metadata, including field and schema details.
Root resolvers implement fields on Query, Mutation, or Subscription. Nested resolvers implement fields on object types. If a parent object already has a property with the requested name, the default field resolver can often return that property without custom code.
const resolvers = {
Query: {
book: (_parent, { id }, { repositories }) =>
repositories.books.findById(id)
},
Book: {
author: (book, _args, { authorLoader }) =>
authorLoader.load(book.authorId)
}
};Keep business rules in reusable services or domain modules rather than duplicating them in individual resolvers. Resolvers should coordinate authentication, authorization, input handling, and data access without becoming a second, inconsistent business layer. Context can contain the authenticated user and request-scoped repositories, but it should not be treated as proof that every nested resource is authorized.
Performance and data loading
The N+1 problem
Suppose a query returns 100 books and each Book.author resolver performs a separate author lookup. The server may execute one query for books plus 100 author queries: the N+1 problem. It can also appear as repeated calls to a downstream REST service.
A request-scoped DataLoader batches keys requested during one execution and caches each key for that request. A batch function might receive author IDs [a1, a2, a3], fetch them together, and return results in the same key order. Request scoping prevents one user's data from leaking through a process-wide cache.
const authorLoader = new DataLoader(async (ids) => {
const authors = await authorRepository.findManyByIds(ids);
const byId = new Map(authors.map(author => [author.id, author]));
return ids.map(id => byId.get(id) || null);
});Use pagination to prevent unbounded result sets. A connection commonly exposes edges, node, cursors, and pageInfo.
query NextBooks($first: Int!, $after: String) {
books(first: $first, after: $after) {
edges { cursor node { id title } }
pageInfo { hasNextPage endCursor }
}
}When hasNextPage is true, send endCursor as after for the next request. Enforce maximum page sizes and reject unreasonable pagination patterns.
Controlling query cost
- Set maximum query depth.
- Estimate field or list cost and reject operations above a budget.
- Limit aliases and heavily nested selections when appropriate.
- Use persisted queries: pre-register an operation and send its hash rather than arbitrary text.
- Monitor resolver latency, database time, downstream calls, response size, and error rate.
Client normalized caches can reuse objects by identity, while server and network caches need operation-, variable-, and authorization-aware keys. Public read operations may be cacheable; user-specific responses must not be shared accidentally.
Error handling
A GraphQL response can contain data, errors, and extensions. Validation errors may prevent execution and produce no useful data. Execution errors can produce partial data when unrelated fields succeed.
{
"data": {
"book": {
"id": "book-101",
"title": "A Sample Book",
"privateNotes": null
}
},
"errors": [
{
"message": "You are not authorized to view this field",
"path": ["book", "privateNotes"],
"extensions": { "code": "FORBIDDEN" }
}
]
}Nullability controls error propagation. If a nullable field fails, that field becomes null and an error is added. If a non-null field fails, null propagates to its nearest nullable parent; this can remove a larger part of the response. Choose nullability based on actual guarantees and client needs.
Expected domain errors should have stable codes and safe messages, such as VALIDATION_FAILED, NOT_FOUND, or FORBIDDEN. Unexpected errors should be logged internally with a correlation ID; do not expose stack traces, SQL, tokens, or infrastructure details to clients.
Security and governance
- Authenticate from HTTP headers or a verified connection context.
- Authorize both resources and sensitive fields. A client omitting a field is not an authorization mechanism.
- Validate input values, formats, ownership, and business constraints.
- Consider restricting introspection in production environments while retaining a secure documentation workflow for trusted developers.
- Apply rate limits and query cost limits, not only request-count limits.
- Cap pagination and reject excessive aliases, depth, or complexity.
- Use persisted or allowlisted operations for public clients when arbitrary queries are not required.
- Protect secrets and personal data; avoid exposing sensitive fields merely because a resolver can obtain them.
Authorization must also apply to mutations, nested objects, subscriptions, and every downstream data access path. Central policy functions reduce inconsistent checks, but each resolver still needs to invoke the appropriate policy.
Schema design practices
- Model domain concepts and client use cases rather than mirroring database tables mechanically.
- Use meaningful, stable names such as
publishedAtandaddBook. - Choose opaque or globally unique IDs when clients need to identify objects across types.
- Separate output object types from input types because read and write shapes have different validation and exposure needs.
- Use connection-based cursor pagination for large or changing collections.
- Group filtering, sorting, and search parameters in explicit input types.
- Use consistent mutation names and payload shapes.
- Prefer additive evolution. Mark old fields with
@deprecated(reason: "Use ...")before removal. - Measure deprecated-field usage, announce a migration period, and remove only after dependent clients have migrated.
Schema versioning is usually handled through backward-compatible additions and deprecations rather than separate versions. A change such as removing a field, changing its type, or making a nullable field non-null can break clients and requires compatibility testing.
Tooling and workflow
Query explorers such as GraphiQL, GraphQL Playground, or comparable tools can load schema documentation and help developers compose and test operations. Introspection powers documentation generation and editor autocomplete, subject to access policy.
Client libraries can generate typed operation code and maintain normalized caches. Code generation can produce server types, client types, or resolver signatures from the schema and operation documents. Schema validation and linting catch invalid references, naming problems, unsafe nullability, and accidental breaking changes before deployment.
- Unit-test resolver behavior with mocked repositories and context.
- Integration-test complete operations against a test schema and data source.
- Test authorization with nested fields, aliases, fragments, and different user roles.
- Record logs, traces, operation names, field timings, downstream calls, and error codes.
- Use schema-diff checks to detect breaking changes in continuous integration.
GraphQL API lifecycle
- Design domain types, inputs, payloads, authorization rules, and pagination boundaries.
- Publish the schema and documentation through a controlled registry or developer workflow.
- Implement and test resolvers, data loaders, validation, and observability.
- Release additive changes and monitor operation and field usage.
- Deprecate fields with a reason and migration guidance.
- Measure remaining usage before removal.
- Test compatibility and communicate any unavoidable breaking change.
Practical design: a small library API
A useful first schema might expose a book by ID, a paginated list, nested author details, and an add-book mutation. The client can request only the data needed by a screen:
query LibraryScreen($id: ID!) {
book(id: $id) {
title
author { name }
}
}The server may obtain the book from a database and the author from a separate service, but the client receives one predictable JSON shape. This is the central client-driven selection model: the schema defines what is legal, while the operation chooses which legal fields to return.
Exam-relevant notes
- GraphQL has both a query language and an execution runtime; it is not a database.
- The schema is the contract, and resolvers supply field values.
- Object fields need selection sets; scalar fields do not.
- Variables are typed separately from the query and are preferable to interpolating values into query text.
- Mutations change state; subscription operations stream event-driven updates.
- GraphQL errors can coexist with partial data, so clients must inspect both
dataanderrors. - Non-null errors can propagate null upward to a nullable parent.
- DataLoader is commonly request-scoped and addresses batching and per-request caching, especially for N+1 access.
- GraphQL does not automatically solve authorization, caching, pagination, or performance.
- Backward-compatible schema evolution usually means adding fields and deprecating before removal.