← Back to Blog

GraphQL Schema Design for Go Developers

Jun 29, 2026 · 5 min read gographqlapitutorial

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 init

This 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 point

Schema 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, not Int! or String!
  • Input types for mutations (CreatePostInput) — never reuse output types as inputs
  • Singular nouns for types (User, not Users)

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)

Got thoughts on this post?

I'd love to hear from you. Reach out on any of these:

Want to learn by doing?

ByteLearn.dev has free courses with interactive quizzes for developers.

Browse courses →
© 2026 ByteLearn.dev. Free courses for developers. · Privacy