How to Handle GraphQL Queries, Mutations, and Subscriptions in Go

Web
Go requires third-party libraries like gqlgen to implement GraphQL queries, mutations, and subscriptions.

The dashboard that ate the API

You're building a dashboard. The frontend needs a user's name and email. The backend returns the whole user object, including the password hash, the last login timestamp, and a list of audit logs. The frontend ignores the junk, but the network pays for it. Next week, a mobile client needs the name and the avatar URL, but not the email. You add a new endpoint. Then another. Soon you have getUserBasic, getUserWithAvatar, getUserForMobile. The API surface is exploding. Every new feature requires a new endpoint or a bloated response.

GraphQL solves this by flipping the script. Instead of the server deciding what data to send, the client asks for exactly what it needs. The server executes that request and returns a response that matches the shape of the query. You get one endpoint, flexible data fetching, and a schema that documents your API.

GraphQL in plain words

GraphQL is a query language for your API. You define a schema that describes your data types and the operations allowed on them. The client sends a query describing the shape of the response. The server validates the query against the schema, executes the necessary logic, and returns the data.

Think of a restaurant menu. REST is like ordering set meals: the "Burger Combo" comes with fries and a drink whether you want them or not. GraphQL is like a la carte ordering. You point at the burger, the pickles, and the water. You get exactly that. The kitchen still has to prepare the food, but you control the plate.

GraphQL operations fall into three categories:

  • Query: Read operations. The client asks for data.
  • Mutation: Write operations. The client changes data and optionally gets back the result.
  • Subscription: Real-time streams. The client subscribes to updates and receives them as they happen.

Minimal schema and resolvers

gqlgen is the most popular GraphQL library for Go. It generates Go code from your schema, so you write the schema first and the code follows. This keeps your API contract and your implementation in sync.

Here's the schema that defines what the client can ask for.

# schema.graphql
# Query type defines read operations.
type Query {
  hello: String
}

# Mutation type defines write operations.
type Mutation {
  setHello(name: String!): String
}

# Subscription type defines real-time streams.
type Subscription {
  helloChanged: String
}

The code that fulfills these requests lives in resolvers. gqlgen generates interfaces for Query, Mutation, and Subscription. You implement those interfaces.

// resolver.go
package main

import (
	"context"
	"fmt"
)

// QueryResolver handles read requests.
type QueryResolver struct{}

// Hello returns a greeting string.
func (r *QueryResolver) Hello(ctx context.Context) (string, error) {
	// Return the static greeting.
	return "Hello, World!", nil
}

// MutationResolver handles write requests.
type MutationResolver struct{}

// SetHello updates the greeting and returns the new value.
func (r *MutationResolver) SetHello(ctx context.Context, name string) (string, error) {
	// In a real app, you'd save this to a database.
	// Here we just echo it back.
	return "Hello, " + name, nil
}

Convention aside: receiver names in Go are usually one or two letters matching the type. (r *QueryResolver) is idiomatic. Avoid (this *QueryResolver) or (self *QueryResolver). The community expects short, consistent names.

How the code runs

When you run gqlgen generate, the tool reads schema.graphql and generates Go files. It creates generated.go with the executable schema, model.go with Go structs for your types, and resolver.go with empty interface implementations. You fill in the resolvers.

The generated code binds your resolvers to the HTTP handler. When a client sends a request, the handler parses the query, validates it against the schema, and calls the appropriate resolver methods.

If you forget to implement a resolver method, the compiler rejects the build with cannot use &QueryResolver{} as type QueryResolver in return argument: missing method Hello. The error tells you exactly which method is missing. Fix the resolver and the build succeeds.

GraphQL maps types to Go types. A nullable String becomes *string. A non-null String! becomes string. A list [String] becomes []string. Be careful with pointers. Don't pass *string around unnecessarily. Dereference early or use the pointer only where the nullability matters. Strings are cheap to pass by value; pointers add indirection without benefit unless you need to represent null.

Wiring the HTTP server

Here's how you wire the GraphQL server into an HTTP handler.

// main.go
package main

import (
	"net/http"
	"log"

	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/playground"
)

// NewServer wires the GraphQL handler to the HTTP server.
func NewServer() http.Handler {
	// Create the default server using the generated schema.
	srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{}}))

	// Return the handler.
	return srv
}

func main() {
	// Serve the GraphQL playground for development.
	http.Handle("/", playground.Handler("GraphQL playground", "/query"))
	// Serve the actual GraphQL endpoint.
	http.Handle("/query", NewServer())

	// Listen on port 8080.
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Convention aside: context.Context always goes as the first parameter in resolver methods. The generated code enforces this. Use the context to pass deadlines, cancellation signals, and request-scoped values. Check ctx.Err() to detect cancellation.

Convention aside: gofmt is the standard formatter. Run it on save. It formats code consistently. The community expects this. Arguments about indentation are banned. Trust gofmt. Argue logic, not formatting.

Subscriptions and streams

Subscriptions require a stream. The resolver returns a channel that yields values over time. The server keeps the connection open and pushes updates to the client.

Here's how a subscription resolver yields values.

// subscription_resolver.go
package main

import (
	"context"
	"fmt"
	"time"
)

// SubscriptionResolver handles real-time updates.
type SubscriptionResolver struct{}

// HelloChanged yields a stream of greeting updates.
func (r *SubscriptionResolver) HelloChanged(ctx context.Context) (<-chan string, error) {
	// Create a channel to send updates.
	ch := make(chan string)

	// Start a goroutine to simulate updates.
	go func() {
		defer close(ch)
		for i := 0; i < 5; i++ {
			// Check context cancellation before sending.
			select {
			case <-ctx.Done():
				return
			case ch <- fmt.Sprintf("Update %d", i):
			}
			// Wait a second between updates.
			time.Sleep(time.Second)
		}
	}()

	// Return the channel to the client.
	return ch, nil
}

Convention aside: goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. Check ctx.Done() in the select statement. Close the channel when the goroutine exits. The worst goroutine bug is the one that never logs.

Pitfalls and errors

GraphQL introduces new failure modes. N+1 queries are common. If a resolver fetches a user, and another resolver fetches the user's posts, and you query a list of users with their posts, you might hit the database once for users and N times for posts. Use a DataLoader pattern to batch requests. gqlgen supports DataLoaders via middleware.

Deep nesting is another risk. A client can query deeply nested fields, causing stack overflow or performance issues. Limit query depth in your schema or use a complexity analyzer. gqlgen has a complexity plugin that rejects queries exceeding a threshold.

Error handling differs from REST. GraphQL errors are part of the response body. The HTTP status is usually 200 even on error. Check the errors array in the JSON response. Use fmt.Errorf to wrap errors in resolvers. The error propagates to the client.

If a query takes too long, the context times out. The resolver receives context deadline exceeded via ctx.Err(). Return the error. The server cancels the request and returns an error to the client. Always respect context deadlines.

Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Don't hide errors. Return them.

When to use GraphQL

Use GraphQL when your frontend needs flexible data shapes and you want to reduce the number of round-trips. Use REST when your API is simple, cacheable, and you want standard HTTP semantics like caching headers. Use gRPC when you are building internal microservices that need high performance and strong typing. Use a simple JSON endpoint when you are prototyping and don't need a schema.

The schema is the contract. Trust the generator. Subscriptions leak if you forget context.

Where to go next