You know REST. You know gRPC. Now an interviewer asks you to design a GraphQL API — cursor pagination, schema conventions, input validation. Here's everything you need in one post.
Why GraphQL Exists
REST has a problem: over-fetching and under-fetching. A mobile client needs 3 fields from /users/123 but gets 40. Then it needs the user's posts, requiring a second request. GraphQL lets clients ask for exactly what they need in one request.
query {
user(id: "123") {
name
email
posts(first: 5) {
edges {
node {
title
createdAt
}
}
}
}
}One request, exact fields, nested data. The server resolves each field independently.
gqlgen: GraphQL in Go
gqlgen is the standard Go GraphQL library. Schema-first: you write .graphql files, it generates Go types and resolver interfaces.
go install github.com/99designs/gqlgen@latest
gqlgen initThis generates:
├── graph/
│ ├── schema.graphql # your schema
│ ├── schema.resolvers.go # you implement these
│ ├── model/models_gen.go # generated types
│ └── generated.go # runtime (don't touch)
├── gqlgen.yml # config
└── server.go # HTTP entry pointSchema Design
Define types, queries, and mutations in schema.graphql:
type User {
id: ID!
name: String!
email: String!
posts(first: Int, after: String): PostConnection!
}
type Post {
id: ID!
title: String!
body: String!
createdAt: DateTime!
author: User!
}
type Query {
user(id: ID!): User
posts(first: Int!, after: String, filter: PostFilter): PostConnection!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}Conventions:
!means non-nullable — the field is always present- Use
ID!for identifiers, notInt!orString! - Input types for mutations (
CreatePostInput) — never reuse output types as inputs - Singular nouns for types (
User, notUsers)
Cursor-Based Pagination (The Connection Spec)
Offset pagination breaks when data changes between pages. Cursor pagination doesn't — each page says "give me items after this cursor."
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}How cursors work:
The cursor is an opaque string (base64-encoded) that encodes position. The client doesn't parse it — just passes it back.
// Encode cursor
func encodeCursor(id int, createdAt time.Time) string {
raw := fmt.Sprintf("%d:%d", id, createdAt.UnixMilli())
return base64.StdEncoding.EncodeToString([]byte(raw))
}
// Decode cursor
func decodeCursor(cursor string) (int, time.Time, error) {
data, err := base64.StdEncoding.DecodeString(cursor)
if err != nil {
return 0, time.Time{}, err
}
parts := strings.SplitN(string(data), ":", 2)
id, _ := strconv.Atoi(parts[0])
ms, _ := strconv.ParseInt(parts[1], 10, 64)
return id, time.UnixMilli(ms), nil
}The SQL query:
SELECT * FROM posts
WHERE (created_at, id) < ($1, $2) -- cursor position
ORDER BY created_at DESC, id DESC
LIMIT $3 -- first + 1 (to detect hasNextPage)Fetch first + 1 rows. If you get first + 1 results, hasNextPage = true and you drop the extra row.
Why base64? It's opaque to the client. If you expose raw id:timestamp, clients will construct cursors manually and break when you change the sorting logic. Base64 signals "don't parse this."
Resolver Implementation
gqlgen generates resolver interfaces. You implement them:
func (r *queryResolver) Posts(ctx context.Context, first int, after *string, filter *model.PostFilter) (*model.PostConnection, error) {
// Validate limits
if first <= 0 || first > 100 {
return nil, fmt.Errorf("first must be between 1 and 100")
}
var cursorID int
var cursorTime time.Time
if after != nil {
var err error
cursorID, cursorTime, err = decodeCursor(*after)
if err != nil {
return nil, fmt.Errorf("invalid cursor")
}
}
// Query DB (fetch first+1 to detect next page)
posts, err := r.DB.GetPosts(ctx, cursorID, cursorTime, first+1, filter)
if err != nil {
return nil, err
}
hasNext := len(posts) > first
if hasNext {
posts = posts[:first] // trim the extra
}
edges := make([]*model.PostEdge, len(posts))
for i, p := range posts {
edges[i] = &model.PostEdge{
Node: p,
Cursor: encodeCursor(p.ID, p.CreatedAt),
}
}
var startCursor, endCursor *string
if len(edges) > 0 {
startCursor = &edges[0].Cursor
endCursor = &edges[len(edges)-1].Cursor
}
return &model.PostConnection{
Edges: edges,
PageInfo: &model.PageInfo{
HasNextPage: hasNext,
StartCursor: startCursor,
EndCursor: endCursor,
},
}, nil
}Filter and Sort Guardrails
Never let clients sort or filter on arbitrary fields. Whitelist allowed options:
input PostFilter {
authorId: ID
status: PostStatus
createdAfter: DateTime
}
enum PostSortField {
CREATED_AT
TITLE
POPULARITY
}
enum SortDirection {
ASC
DESC
}Server-side enforcement:
// Only allow sorting on indexed columns
var allowedSorts = map[model.PostSortField]string{
model.PostSortFieldCreatedAt: "created_at",
model.PostSortFieldTitle: "title",
model.PostSortFieldPopularity: "like_count",
}
func buildOrderClause(field model.PostSortField, dir model.SortDirection) string {
col, ok := allowedSorts[field]
if !ok {
col = "created_at" // safe default
}
return fmt.Sprintf("%s %s", col, dir)
}Without guardrails, a client could request sort: BODY and trigger a full table scan on a non-indexed text column.
The N+1 Problem and Dataloaders
If you resolve posts { author { name } } naively, each post triggers a separate DB query for its author. 20 posts = 20 author queries.
Fix: dataloaders — batch + deduplicate within one request.
// Using github.com/graph-gophers/dataloader/v7
func (r *postResolver) Author(ctx context.Context, obj *model.Post) (*model.User, error) {
// This doesn't query immediately — it batches
return r.UserLoader.Load(ctx, obj.AuthorID)
}The dataloader collects all AuthorIDs requested in the same GraphQL query, then fires ONE SQL query:
SELECT * FROM users WHERE id IN (1, 5, 12, 7);20 queries → 1 query. This is the #1 performance pattern in GraphQL.
Authentication in Resolvers
Pass the authenticated user through context (same as REST middleware):
// Middleware extracts user from JWT → puts in context
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := validateToken(r.Header.Get("Authorization"))
ctx := context.WithValue(r.Context(), userCtxKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Resolver reads from context
func (r *mutationResolver) CreatePost(ctx context.Context, input model.CreatePostInput) (*model.Post, error) {
user := ctx.Value(userCtxKey).(*model.User)
if user == nil {
return nil, fmt.Errorf("unauthorized")
}
return r.DB.CreatePost(ctx, user.ID, input)
}GraphQL vs REST vs gRPC — When to Use What
| REST | GraphQL | gRPC | |
|---|---|---|---|
| Best for | CRUD APIs, simple resources | Complex nested data, multiple clients | Internal microservices |
| Client flexibility | Server decides response shape | Client decides response shape | Fixed protobuf schema |
| Caching | HTTP caching (GET + CDN) | Hard (POST-based, needs app-level cache) | No HTTP caching |
| Tooling | Mature (curl, Postman) | GraphQL Playground, introspection | Protobuf + generated clients |
| When to avoid | Many round-trips for nested data | Simple CRUD with few clients | Public APIs |
Key Takeaways
- gqlgen is schema-first — write
.graphql, generate Go code, implement resolvers - Cursor pagination > offset pagination — base64-encode an opaque cursor, fetch N+1 to detect next page, never expose internals
- Whitelist sort/filter fields — map enum values to indexed columns, reject arbitrary input
- Dataloaders solve N+1 — batch field resolution into single queries per type per request
- Auth lives in context — middleware extracts user, resolvers read from
ctx, same pattern as REST handlers - GraphQL trades caching for flexibility — no HTTP-level caching, need application-level solutions (Redis, persisted queries)