Mert Özen Further With Every Line
Backend gRPC and GraphQL — #06

GraphQL Fundamentals: Schema, Queries, Mutations, Subscriptions and How Resolvers Work

Mert Özen Nov 14, 2026 13 min 5 views
GraphQL Fundamentals: Schema, Queries, Mutations, Subscriptions and How Resolvers Work

In GraphQL the client describes what a screen needs in one query and the server resolves each field with its own resolver; this ends over-fetching but brings N+1 and the loss of HTTP caching with it. With Hot Chocolate 16 we show the type system, variables, fragments and aliases, partial data and null propagation, @oneOf from the September 2025 spec and GraphQL over HTTP behaviour with real responses.

Version note: The code in this post was built and run with .NET 10 and Hot Chocolate 16.7; specification references follow the GraphQL September 2025 edition and the GraphQL over HTTP draft.

Guess First

The home page shows a list of five books: title and stock status. Stock information comes from Inventory, and Inventory fails while the fourth book's stock is being looked up. What does the client get from GraphQL? An HTTP 500? Four books and an error message? Or no books at all?

The answer depends on a single character: whether the stock field in the schema ends with !. We'll see both cases with their real responses.

What Problem Does GraphQL Solve?

Bookstore's book detail screen shows four things: the book's title and price, the author's name, stock status and the latest two reviews. With REST (Representational State Transfer), I called four endpoints for this:

GET /api/books/1  96 bytes
{"id":1,"title":"Distributed Systems Primer","isbn":"9780000000001","authorId":1,"price":249.90}
GET /api/authors/1  69 bytes
{"id":1,"name":"Ada Byron","bio":"Writes about distributed systems."}
GET /api/books/1/stock  27 bytes
{"available":12,"status":0}
GET /api/books/1/reviews  300 bytes
[{"id":3,"bookId":1,"rating":3,"text":"A bit long.","createdAt":"2026-11-09T12:00:00+00:00"},{"id":2,"bookId":1,"rating":4,"text":"Great chapter on retries.","createdAt":"2026-11-08T12:00:00+00:00"},{"id":1,"bookId":1,"rating":5,"text":"Clear and practical.","createdAt":"2026-11-07T12:00:00+00:00"}]
total 492

There are two separate problems. Over-fetching: the screen doesn't use isbn, bio, createdAt or the third review, yet they all arrive. Under-fetching: a single request isn't enough to fill the screen; worse, to ask for the author you first need the book's authorId, so there are at least two sequential round trips. On a mobile network every round trip means hundreds of milliseconds.

In GraphQL the screen states what it needs:

"""Book detail page: book, author, stock and the latest reviews."""
query BookDetail($id: Int!) {
  book(by: { id: $id }) {
    title
    price
    author {
      name
    }
    stock {
      available
      status
    }
    reviews(first: 2) {
      rating
      text
    }
  }
}
HTTP/1.1 200 OK
Content-Length: 240
Content-Type: application/graphql-response+json; charset=utf-8

{"data":{"book":{"title":"Distributed Systems Primer","price":249.90,"author":{"name":"Ada Byron"},"stock":{"available":12,"status":"IN_STOCK"},"reviews":[{"rating":3,"text":"A bit long."},{"rating":4,"text":"Great chapter on retries."}]}}}

One request, 240 bytes, and the response has exactly the shape of the query. This flexibility has costs too, and you need to know them up front. All requests go to a single endpoint (/graphql) and most are POSTs; HTTP caching that works per URL, the CDN and monitoring tools that say "this endpoint is slow" go blind. Because the client can write any query it likes, the server's cost becomes unpredictable. Tools that REST has settled over the years have to be rebuilt here. We cover how these costs are managed in the security and operations post of this series, and the comparison with REST in the measurement post at the end of the series.

Schema, Type System and Introspection

The contract of a GraphQL API is its schema. It's the counterpart of gRPC's .proto, with one difference: the client can query the schema from the server. I fetched the Bookstore Catalog service's schema as SDL from Hot Chocolate's /graphql?sdl address:

type Query {
  book(by: BookLookupInput!): Book
  books(first: Int! = 10): [Book!]!
  search(term: String!): [SearchResult!]!
}

type Mutation {
  addReview(bookId: Int!, rating: Int!, text: String!): Review!
    @cost(weight: "10")
}

type Subscription {
  onReviewAdded(bookId: Int!): Review!
}

type Book {
  author: Author
  stock: StockInfo @cost(weight: "10")
  reviews(first: Int! = 3, limit: Int @deprecated(reason: "Use first.")): [Review!]!
  id: Int!
  title: String!
  isbn: String!
  authorId: Int!
  price: Decimal!
}

type StockInfo {
  available: Int!
  status: StockStatus!
}

union SearchResult = Book | Author

input BookLookupInput @oneOf {
  id: Int
  isbn: String
}

enum StockStatus {
  IN_STOCK
  LOW
  OUT_OF_STOCK
}

scalar Decimal
  @specifiedBy(url: "https://scalars.graphql.org/chillicream/decimal.html")

This short schema contains almost the whole type system. type defines object types and links each field to another type. The tree ends in scalars: Int, String, Boolean, Float and ID are the built-in scalars. Decimal and DateTime are Hot Chocolate's custom scalars; @specifiedBy points to where their behaviour is defined. enum is a fixed set of values; C#'s StockStatus.InStock became IN_STOCK in GraphQL. union means "one of these"; search can return a book or an author. An interface is the contract of types that share fields (for example a Product interface implemented by Book and EBook); we didn't use one here, but it's queried the same way as a union. input types can only be used as arguments; output types can't appear in arguments and input types can't appear in responses.

The exclamation marks and square brackets are the most misread part. Book means "may be null", Book! "never null". [Book!]! makes two separate promises: the list itself is never null, and no book inside it is null. In GraphQL the default is nullable, the opposite of C#. The answer to the opening question hides in exactly these marks.

The schema is generated from C# code. In Hot Chocolate's implementation-first approach you write static classes marked with attributes, and the source generator handles the rest:

[OneOf]
public sealed record BookLookupInput(int? Id, string? Isbn);

[QueryType]
public static partial class Queries
{
    public static Book? GetBook(BookLookupInput by, CatalogData data) =>
        by.Id is { } id ? data.FindBook(id) : data.FindBookByIsbn(by.Isbn!);

    public static IReadOnlyList<Book> GetBooks(CatalogData data, int first = 10) => data.ListBooks(first);

    public static IReadOnlyList<ISearchResult> Search(string term, CatalogData data) => data.Search(term);
}

[ObjectType<Book>]
public static partial class BookNode
{
    public static Author? GetAuthor([Parent] Book book, CatalogData data) => data.FindAuthor(book.AuthorId);

    // nullable: if Inventory can't be reached only this field becomes null; the book isn't lost
    public static async Task<StockInfo?> GetStock([Parent] Book book, StockClient stock, CancellationToken cancellationToken) =>
        await stock.GetStockAsync(book.Id, cancellationToken);

    public static IReadOnlyList<Review> GetReviews(
        [Parent] Book book,
        CatalogData data,
        int first = 3,
        [GraphQLDeprecated("Use first.")] int? limit = null) =>
        data.ListReviews(book.Id, limit ?? first);
}
builder.Services
    .AddGraphQLServer()
    .AddCatalogTypes()
    .AddInMemorySubscriptions();

var app = builder.Build();

app.MapGraphQL();

AddCatalogTypes is the registration method the source generator derives from the assembly name. C# nullable annotations flow straight into the schema: Book? → Book, IReadOnlyList<Book> → [Book!]!. Hot Chocolate's three ways of writing a schema, Entity Framework Core (EF Core) integration and mutation conventions are the subject of the next post. Here we use just enough for the concepts.

The schema itself is queryable too. This is called introspection and is done through special fields such as __schema and __type. Autocompletion in IDEs and code generators rely on it:

{ __type(name: "StockStatus") { kind enumValues { name } } }

{"data":{"__type":{"kind":"ENUM","enumValues":[{"name":"IN_STOCK"},{"name":"LOW"},{"name":"OUT_OF_STOCK"}]}}}

When I sent the same query while the app ran in the Production environment, Hot Chocolate 16 refused it: Introspection is not allowed for the current request. (HC0046). Introspection is on in Development and off by default in Production. We discuss who should be allowed to introspect in production in the security post.

Writing Queries: Arguments, Variables, Fragments and Aliases

The opening BookDetail query had three basic pieces. by: { id: $id } is an argument. $id is a variable; its value isn't embedded in the query text but sent as separate JSON (JavaScript Object Notation) ({"id": 1}). That way the query text stays fixed and the server can parse it once and cache it. And since user input isn't added to the text by string concatenation, there's no injection risk. The name BookDetail is the operation name; in logs and traces it shows which screen sent which query.

A screen that compares two books side by side needs the same field twice. Aliases name the response field, and fragments gather a repeated selection in one place:

query Compare($a: Int!, $b: Int!) {
  left: book(by: { id: $a }) { ...BookCard }
  right: book(by: { id: $b }) { ...BookCard }
}

fragment BookCard on Book {
  title
  price
  stock { status }
}
{"data":{"left":{"title":"Distributed Systems Primer","price":249.90,"stock":{"status":"IN_STOCK"}},"right":{"title":"Practical Protobuf","price":189.00,"stock":{"status":"LOW"}}}}

On a field that returns a union, inline fragments say which fields to request from which type. __typename gives each object's actual type:

{
  search(term: "ra") {
    __typename
    ... on Book { title }
    ... on Author { name }
  }
}
{"data":{"search":[{"__typename":"Book","title":"Practical Protobuf"},{"__typename":"Book","title":"Streaming in Practice"},{"__typename":"Book","title":"GraphQL from Scratch"},{"__typename":"Author","name":"Grace Hopper"}]}}

On the Angular side, each component defines what it needs as a fragment and the page query combines those fragments. We set up that structure in the Angular client post.

Mutations and Subscriptions

Queries are for reading. Operations that change data are defined as mutations; the difference is in semantics more than syntax. According to the specification, multiple top-level mutation fields in a request run in sequence, while query fields may run in parallel. A mutation that adds a review, and a subscription that listens for new reviews of that book:

[MutationType]
public static partial class Mutations
{
    public static async Task<Review> AddReview(
        int bookId, int rating, string text, CatalogData data, HotChocolate.Subscriptions.ITopicEventSender sender, CancellationToken cancellationToken)
    {
        Review review = data.AddReview(bookId, rating, text);
        await sender.SendAsync($"reviews/{bookId}", review, cancellationToken);
        return review;
    }
}

[SubscriptionType]
public static partial class Subscriptions
{
    [Subscribe]
    [Topic("reviews/{bookId}")]
    public static Review OnReviewAdded(int bookId, [EventMessage] Review review) => review;
}

A subscription needs a long-lived connection. Hot Chocolate supports both WebSocket (the graphql-transport-ws protocol) and SSE. I tried SSE: in one terminal I opened the subscription with Accept: text/event-stream, in the other I sent the mutation:

mutation { addReview(bookId: 3, rating: 4, text: "Good examples.") { id rating } }

{"data":{"addReview":{"id":6,"rating":4}}}

event: next
data: {"data":{"onReviewAdded":{"rating":4,"text":"Good examples."}}}

We discuss production settings for subscriptions (the Redis provider, multiple instances, when SignalR is the better fit) in the Angular client post. The options map from the first post of the Real-time Applications series applies here too.

Execution: The Resolver Tree and Where N+1 Is Born

How does the server execute a query? First it parses it, validates it against the schema, then walks the selection set from top to bottom. Every field has a resolver; the resolver receives the parent object and returns that field's value. The data layer's log while the BookDetail query ran:

06-resolver-tree-en.png

info: Bookstore.Catalog.Data.CatalogData[0]
      db: book 1
info: Bookstore.Catalog.Data.CatalogData[0]
      db: author 1
info: Bookstore.Catalog.Data.CatalogData[0]
      db: reviews of book 1
info: Bookstore.Catalog.Data.StockClient[0]
      grpc: Inventory.GetStock 1

Query.book ran first. The Book object it returned became the parent of the child fields. title and price need no separate I/O; the value is already in the parent. author, reviews and stock, however, went to their own data sources: one to the database, one to Inventory over gRPC. A GraphQL server is a layer that combines these sources, and each field can be resolved independently from its own source.

This model is also the source of the N+1 problem. The log of { books(first: 5) { title author { name } } }, which asks for the authors of five books:

db: books first 5
db: author 1
db: author 1
db: author 2
db: author 3
db: author 2

One query fetched the books, then the author resolver ran separately for every book: 1 + 5 queries. We asked for Ada Byron twice and Alan Turing twice. In the N+1 post of the EF Core series we saw the same problem with navigation properties. In GraphQL it's sneakier, because the client decides which fields are requested; a list of 500 books means 501 queries. The fix is DataLoaders and batch resolvers; in the GraphQL performance post we bring this 1 + 5 down to 2.

Errors: Partial Data and Null Propagation

Back to the opening question. Inventory throws an exception for the fourth book. In the schema's first version the stock field was non-null: Task<StockInfo> in C#, stock: StockInfo! in the schema. I asked for five books with their stock:

{
  books(first: 5) {
    title
    stock { available }
  }
}
200 application/graphql-response+json; charset=utf-8
{"errors":[{"message":"Unexpected Execution Error","path":["books",3,"stock"]}],"data":null}

No books at all; data is null. A single stock error took the whole list down. The reason is null propagation: since stock can't be null, the error moved one level up, to the book. But the book is non-null too, inside [Book!]!; the error moved to the list. And since the list is non-null as well, data itself ended up null. To keep its "won't be null" promise, GraphQL prunes the response up to the nearest nullable parent.

When I changed the resolver to Task<StockInfo?>, the schema became stock: StockInfo, and the same query returned:

200 application/graphql-response+json; charset=utf-8
{"errors":[{"message":"Unexpected Execution Error","path":["books",3,"stock"]}],"data":{"books":[{"title":"Distributed Systems Primer","stock":{"available":12}},{"title":"Practical Protobuf","stock":{"available":1}},{"title":"Streaming in Practice","stock":{"available":12}},{"title":"Compilers for Everyone","stock":null},{"title":"GraphQL from Scratch","stock":{"available":12}}]}}

Four books with their stock, the fourth one's stock null, and the exact location of the error in the errors array: ["books", 3, "stock"]. This is called partial data, and it's a fundamental difference from REST's all-or-nothing responses. Three lessons follow. First, design fields that go to another system or can fail as nullable; give non-null only to fields you can truly guarantee. Second, the HTTP status is still 200. The error is inside the response, and the client must look at errors. Third, the error message is "Unexpected Execution Error". Hot Chocolate doesn't show the exception itself to the client, which is the right default for not leaking internals. We cover how to return domain errors meaningfully, together with mutation errors, in the next post.

The September 2025 Specification and GraphQL over HTTP

The September 2025 edition of the GraphQL specification is the first official release since October 2021. The three additions that affect day-to-day use most are already in this post's schema.

OneOf input objects. BookLookupInput @oneOf means "find the book by id or by ISBN, not both". This used to be solved either with two separate fields (bookById, bookByIsbn) or with manual checks inside the resolver. Now it happens during validation:

{ book(by: { id: 1, isbn: "9780000000001" }) { title } }

400 application/graphql-response+json; charset=utf-8
{"errors":[{"message":"The OneOf Input Object `BookLookupInput` requires that exactly one field must be supplied and that field must not be `null`.","locations":[{"line":1,"column":12}],"path":["book"],"extensions":{"type":"BookLookupInput","specifiedBy":"https://spec.graphql.org/September2025/#sec-All-Variable-Usages-Are-Allowed"}}]}

@deprecated on arguments and input fields. The specification now defines @deprecated for ARGUMENT_DEFINITION and INPUT_FIELD_DEFINITION alongside FIELD_DEFINITION and ENUM_VALUE. The old limit argument of reviews looks like this in introspection:

{"name":"reviews","args":[{"name":"first","isDeprecated":false,"deprecationReason":null},{"name":"limit","isDeprecated":true,"deprecationReason":"Use first."}]}

Descriptions on executable documents. The """Book detail page...""" line above the opening BookDetail query is an operation description; it tells both people and tools what the query was written for. Hot Chocolate 16 accepted it. Schema coordinates (standard addresses such as Book.reviews(limit:)) and full Unicode support also arrived with this edition. @defer and @stream, however, are still outside the specification.

How GraphQL travels over HTTP is defined in a separate document, and that document is still a draft (Current Working Draft). What I tried against Hot Chocolate 16:

GET /graphql?query={ book(by: { id: 2 }) { title } }
HTTP/1.1 200 OK
Content-Type: application/graphql-response+json; charset=utf-8
{"data":{"book":{"title":"Practical Protobuf"}}}

GET /graphql?query=mutation { addReview(bookId: 2, rating: 5, text: "x") { id } }
HTTP/1.1 405 Method Not Allowed
Allow: POST
{"errors":[{"message":"The specified operation kind is not allowed."}]}

POST { book(by: { id: 2 } { title } }
HTTP/1.1 400 Bad Request
{"errors":[{"message":"Expected a `Name`-token, but found a `LeftBrace`-token.","locations":[{"line":1,"column":22}],"extensions":{"code":"HC0011"}}]}

POST { book(by: { id: 2 }) { title publisher } }
HTTP/1.1 400 Bad Request
{"errors":[{"message":"The field `publisher` does not exist on the type `Book`.","locations":[{"line":1,"column":31}],"extensions":{"type":"Book","field":"publisher","responseName":"publisher","specifiedBy":"https://spec.graphql.org/September2025/#sec-Field-Selections"}}]}

Queries can also be sent with GET; that's the door to winning back HTTP caching. Mutations can't be sent with GET; as the draft recommends, you get 405 and Allow: POST. The response media type is application/graphql-response+json, and when this type is requested, parse or validation errors return 4xx. When I sent the same invalid request with Accept: application/json, however, the response was 200 OK with Content-Type: application/json. For compatibility with older clients, GraphQL errors come back inside HTTP 200. The current draft recommends 422 for validation errors and 294 for partial data; Hot Chocolate 16 returned 400 and 200. That's what being a draft means: write clients that look at data and errors in the response, not at the status code.

In Production

Design nullability as an error boundary. Fields that depend on another service, a cache or a computation should be nullable. Reserve non-null for core fields without which the screen makes no sense. Changing non-null to nullable later breaks clients, because they didn't write null checks.

Don't allow clients that don't use variables. Clients that embed values in the query text send a different document on every request; the server's parse cache becomes useless, logs become unreadable, and security measures such as persisted operations can't be applied.

Build monitoring around operation names. The p95 of a single /graphql endpoint tells you nothing. Measure per operation name (BookDetail) and per resolver duration; consider rejecting anonymous operations.

Handle partial data explicitly on the client. Is a response with both errors and data a success or a failure? Decide per screen: if the stock badge can't be shown, the book page should still open; if the payment summary is incomplete, it shouldn't.

Common Pitfalls

Making everything non-null. Adding ! out of C# habit turns a single field's error into a chain of nulls that wipes out the whole response. Above, five books dropped to zero because of one error.

Treating HTTP 200 as success. GraphQL errors often arrive inside a 200; a client that doesn't check errors silently shows incomplete data.

Not noticing N+1 in resolvers. A list that runs fast with three records in development turns into hundreds of queries in production. Logging data access in resolvers shows this from day one.

Translating REST endpoints one-to-one into GraphQL fields. A flat list like getBookById and getAuthorById doesn't use GraphQL's relationship traversal. Design the schema as a graph that the screens walk.

Leaving introspection and detailed error messages on in production. Hot Chocolate 16 ships with both off by default; think twice before turning them on "for developer convenience".

A Small Experiment

★☆☆ Add author { name bio } to the BookDetail query and compare the response size with REST's 492 bytes. At what point does GraphQL's advantage disappear?

★★☆ Define Review.text as non-nullable through a C# resolver and throw an exception for one particular review. Compare how far the null spreads, first with reviews: [Review!]!, then with reviews: [Review]!.

★★★ Define an interface called Product (title, price) and have Book and a new GiftCard type implement it. Change search to return the interface instead of a union, and write down how the two approaches affect client queries.

The concepts are in place. In the next post we build Bookstore's real GraphQL API with Hot Chocolate 16: choosing a schema-writing approach, DbContext management with EF Core, the SQL produced by projection and filtering, and mutations with typed errors.