Beyond REST: Task Management Through GraphQL

In earlier parts of this series, we built the same task management backend in several REST flavors: the standard library, router packages, web frameworks, OpenAPI, middleware and authentication. This part moves away from REST entirely and exposes the same kind of data through GraphQL, using Go and the gqlgen package.

To make the motivation for GraphQL concrete, we extend the original data model. Each task now has a sequence of attachments:

type Attachment struct {
  Name     string    `json:"Name"`
  Date     time.Time `json:"Date"`
  Contents string    `json:"Contents"`
}

type Task struct {
  ID          int           `json:"Id"`
  Text        string        `json:"Text"`
  Tags        []string      `json:"Tags"`
  Due         time.Time     `json:"Due"`
  Attachments []*Attachment `json:"Attachments"`
}

In database terms, this is a one-to-many relationship; every task owns zero or more attachments. The previous REST endpoints looked like this:

POST   /task/              :  create a task, returns ID
GET    /task/<taskid>      :  returns a single task by ID
GET    /task/              :  returns all tasks
DELETE /task/<taskid>      :  delete a task by ID
GET    /tag/<tagname>      :  returns list of tasks with this tag
GET    /due/<yy>/<mm>/<dd> :  returns list of tasks due by this date

Over-fetching and Under-fetching

The REST API works well for basic requests, but consider what happens when a client wants only the task text and attachment names for a given tag. The GET /tag/<tagname> endpoint returns the full task objects, including possibly large attachments. That is over-fetching: the response is much bigger than the client will actually use.

The standard REST workaround is to return only task IDs, then fetch each task individually with GET /task/<taskid> — and perhaps additional requests for attachment details. This create under-fetching: a single logical request lives a round of many endpoint calls, increasing latency.

Some APIs design purpose-built endpoints such as GET /task-name-and-attachment-name-in-tag/<tagname>. But this hard to scale. You must duplicate it for other filters (due date, priority) and other fields.

How GraphQL Solves the Problem

GraphQL gives the client a way to declare exactly which fields it wants, in one request. A query such as the following asks only for task text and attachment names:

query {
  getTasksByTag(tag: "shopping") {
    Text
    Attachments{
      Name
    }
  }
}

The response contains exactly that, nothing more. Over-fetching is gone and under-fetching never happens.

A GraphQL Server in Go

We use gqlgen, which generates Go code from a GraphQL schema and leaves resolver functions (the actual handlers) for the developer. The schema for our task backend is described below.

type Query {
    getAllTasks: [Task]
    getTask(id: ID!): Task

    getTasksByTag(tag: String!): [Task]
    getTasksByDue(due: Time!): [Task]
}

type Mutation {
    createTask(input: NewTask!): Task!

    deleteTask(id: ID!): Boolean
    deleteAllTasks: Boolean
}

scalar Time

type Attachment {
    Name: String!
    Date: Time!
    Contents: String!
}

type Task {
    Id: ID!
    Text: String!
    Tags: [String!]
    Due: Time!
    Attachments: [Attachment!]
}

input NewAttachment {
    Name: String!
    Date: Time!
    Contents: String!
}

input NewTask {
    Text: String!
    Tags: [String!]
    Due: Time!
    Attachments: [NewAttachment!]
}

There are three features worth noting in the schema:

  • The Query and Mutation types define the public API, and GraphQL is strongly typed — input validation has well-defined semantics, in contrast to typical loosely-typed JSON in REST.
  • Although APIs such as task(tag: ...): [Task] return full task lists, clients specify precisely the fields they need. Only those are transferred, which we saw in the earlier query.
  • GraphQL has no native date/time type, but gqlgen supplies a scalar Time extension mapped to Go's time.Time.

GraphQL types are strict about inputs. NewTask/NewAttachment are separate "input" types even though their fields mirror Task/Attachment. The reason: GraphQL distinguishes types that can appear in the output (which can form graphs through recursion) from those used as parameters, which must be trees. Reusing output types as input is forbidden by the spec, so duplicates are required.

Project Setup

The workflow with gqlgen:

  1. go run github.com/99designs/gqlgen init
  2. Write the schema, shown above.
  3. go run github.com/99designs/gqlgen generate
  4. Fill resolver implementations.
  5. Run the generated server.go.

gqlgen creates an empty Resolver struct for shared state. Our version holds the task store:

type Resolver struct {
  Store *taskstore.TaskStore
}

The generated resolver stubs are usually thin. One field-resolver example:

func (r *queryResolver) GetAllTasks(ctx context.Context) ([]*model.Task, error) {
  return r.Store.GetAllTasks(), nil
}

The resolver returns the whole list of tasks; field selection happens inside generated code, so at resolver time you don't know which fields the client requested. A consequence: your data layer still fetches entire objects from the database even when the client gets a small projection.

The generated server.go contains a main we can tweak:

func main() {
  port := os.Getenv("PORT")
  if port == "" {
    port = defaultPort
  }

  resolver := &graph.Resolver{
    Store: taskstore.New(),
  }
  srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: resolver}))

  http.Handle("/", playground.Handler("GraphQL playground", "/query"))
  http.Handle("/query", srv)

  log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
  log.Fatal(http.ListenAndServe(":"+port, nil))
}

handler.NewDefaultServer mounts the GraphQL engine at /query. Additional routes can be registered here, so REST and GraphQL can coexist in one process.

The Interactive Playground

A distinctive feature of GraphQL is the in-browser playground used for experimentation. Mounting playground.Handler at the root makes it available. When you open the server, you see a single-page editor:

Screenshot of the GraphQL playground interacting with our server

The playground shown here runs the same query from earlier: typed into the left pane and the exact server response appears on the right. It also provides syntax highlighting, autocompletion, multiple tabs and a "copy as curl" option for saving test scripts.

GraphQL vs. REST

GraphQL holds clear advantages on flexibility and wire efficiency — but flexibility has trade-offs. Clients can send arbitrarily complex queries, potentially exhausting server resources; the community is still developing guardrails against such DoS vectors.

Tooling and ecosystem are where REST remains strong. Any server can expose a REST API, and mature monitoring, logging and endpoint profiling are common. REST's simplicity also means basic interactions are just HTTP paths, often testable with curl or a browser. GraphQL requires structured POST bodies, so the entry friction is higher.

There is also an operational cost in a typical web backend. REST queries usually translate directly to SQL against a relational base. GraphQL adds another query layer: you reason about the shape of the GraphQL request, then map it to relational tables. Nothing about the query syntax helps you write that SQL, so you keep more details in your head. Projects such as Dgraph (native GraphQL database) and PostGraphile (automatic GraphQL-to-PostgreSQL layer) aim to close that gap.

Caching is another split. Much of REST relies on idempotent GET requests, which work naturally with HTTP caches. GraphQL mixes queries and mutations on the same HTTP endpoint and does not expose the idempotency distinction at the transport level, complicating conventional caching.

The final choice depends on your environment. Reach for GraphQL when you control the API surface and clients need field-level control of complex data shapes; stay with REST when simple path-based endpoints, fine-grain caching and broad tooling matter more — and use the playground and generated code to prototype when GraphQL has the edge.

[1]GraphQL schemas are written in a language that's codified by a spec.
[2]In SQL parlance, we're always asked to do a select * from ... rather than a select on specific fields. Once our resolver returns this data to the GraphQL engine, it will only send the selected fields to the client.