GraphQL vs REST in Go: A Clash of Titans, or How to Avoid a Wrong Choice
\n\nIf you've been writing Go for at least six months, you've likely encountered the eternal debate: REST or GraphQL? One is the good old reliable "workhorse," the other is a trendy "sports car" with a ton of features. But in the world of Go, where performance and simplicity are a religion, the choice isn't always obvious.
\n\nIn this article, we won't just list the pros and cons. We'll write real Go code, measure execution time, and analyze when GraphQL is a shot in the foot, and when it's a cheat code to efficient APIs. Let's go!
\n\nREST in Go: Stability and Predictability
\n\nREST is like an old, reliable hammer. It doesn't shine, but it drives nails perfectly. In Go, REST APIs are written using net/http or lightweight frameworks like gin and chi. Let's look at a simple example:
package main\n\nimport (\n "encoding/json"\n "net/http"\n)\n\ntype User struct {\n ID int `json:"id"`\n Name string `json:"name"`\n}\n\nfunc getUserHandler(w http.ResponseWriter, r *http.Request) {\n user := User{ID: 1, Name: "Alexey"}\n w.Header().Set("Content-Type", "application/json")\n json.NewEncoder(w).Encode(user)\n}\n\nfunc main() {\n http.HandleFunc("/api/user", getUserHandler)\n http.ListenAndServe(":8080", nil)\n}\n\nNice? Yes. Simple? Very. But what if the frontend only needs the user's name, and we're sending the entire object? In REST, this is called over-fetching — an excess of data. And in Go, where we're used to controlling every byte, this can be annoying.
\n\nThe N+1 Problem in REST
\n\nLet's say we have a list of orders, and for each one we need the user's name. In REST, we either make N requests (slow) or create a separate endpoint /api/orders-with-users (a ton of endpoints). Go handles this with join queries in the database, but at the API level, it still feels clunky.
GraphQL in Go: Power and Flexibility
\n\nGraphQL is like a Swiss Army knife: one endpoint, and the client decides what data it needs. In Go, there's the gqlgen framework (from 99designs) — it generates code from a schema, which is very much in the spirit of Go (less boilerplate, more action).
// schema.graphql\ntype Query {\n user(id: Int!): User\n}\n\ntype User {\n id: Int!\n name: String!\n orders: [Order!]!\n}\n\ntype Order {\n id: Int!\n total: Float!\n}\n\nNow the Go implementation:
\n\npackage graph\n\nimport (\n "context"\n "fmt"\n)\n\ntype Resolver struct{}\n\nfunc (r *Resolver) User(ctx context.Context, args struct{ ID int }) (*User, error) {\n // In reality — a database query\n return &User{\n ID: args.ID,\n Name: "Maria",\n Orders: []*Order{\n {ID: 1, Total: 250.50},\n },\n }, nil\n}\n\nThe client requests only name — it gets only name. No over-fetching. But there's a catch: if the client requests orders for 10 users, and each has 50 orders — the database could be brought to its knees. This is called the N+1 problem in GraphQL, and in Go, it's solved with DataLoaders.
DataLoader — The N+1 Savior
\n\nimport "github.com/graph-gophers/dataloader"\n\ntype UserLoader struct {\n *dataloader.Loader\n}\n\nfunc NewUserLoader() *UserLoader {\n return &UserLoader{\n Loader: dataloader.NewBatchedLoader(func(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {\n // Batch database queries\n userIDs := make([]int, len(keys))\n for i, key := range keys {\n userIDs[i] = key.Int()\n }\n users := batchFetchUsers(userIDs) // One SQL query with WHERE IN\n results := make([]*dataloader.Result, len(ke