Migrate from graph-gophers/graphql-go
Step-by-step migration from the graph-gophers/graphql-go schema-first executor to grx.
github.com/graph-gophers/graphql-go
is schema-first - you ship an SDL string and a tree of resolver structs
that mirror it. grx is code-first; the schema is derived from your Go
types, so the SDL string and the per-object resolver wrappers both
disappear.
This guide walks through the swap using the schema shared by the bundled benchmarks as the running example.
The mental model
| graph-gophers/graphql-go | grx |
|---|---|
SDL string parsed by graphql.ParseSchema(sdl, root) | Plain Go structs with gql:"..." tags |
Per-object resolver struct (type userResolver struct{ u *User }) | The data type is the resolver - no wrapper |
| Methods returning Go scalars/objects mapped to GraphQL fields | Same shape, but on the root struct only - nested fields read directly from struct fields |
func (r *userResolver) ID() graphql.ID | ID string \gql:"id,nonNull"`` field on the struct |
Anonymous arg struct: func(args struct{ ID graphql.ID }) | Named arg struct: type IDArgs struct { ID string \gql:"id,nonNull"` }` |
graphql.ID | string |
int32 for Int! fields | int or int32 |
relay.Handler{Schema: schema} | grx.NewServer(grx.WithSchema(...), ...opts) - already an http.Handler |
graphql.MaxParallelism(N) | Not needed - grx executes synchronously by design |
Type mapping
| graph-gophers idiom | grx equivalent |
|---|---|
graphql.ID | string |
string for String! | string (with gql:",nonNull") |
*string for String | *string |
int32 for Int! | int or int32 |
[]*Foo | []*Foo |
*Foo (nullable resolver result) | *Foo |
Resolver method returning Foo | Struct field of type Foo on the parent |
Step 1 - Drop the SDL string
In graph-gophers, the schema is the SDL string:
const sdl = `
schema { query: Query }
type Query {
user(id: ID!): User
post(id: ID!): Post
users(count: Int!): [User!]!
}
type User {
id: ID!
name: String!
email: String
}
type Post {
id: ID!
title: String!
body: String!
author: User!
}
`In grx the SDL doesn't exist as a string. The struct definitions in the next steps are the schema; grx derives the equivalent SDL from them at startup. Delete the SDL.
Builtin directives in SDL
If your SDL carries @deprecated, @specifiedBy, or similar built-in
directives, verify the current directive and description support before
migrating. Unsupported metadata will not be preserved automatically.
Step 2 - Object resolver structs collapse into data types
Before (graph-gophers - note three structs per object):
type userResolver struct{ u *User }
func (r *userResolver) ID() graphql.ID { return graphql.ID(r.u.ID) }
func (r *userResolver) Name() string { return r.u.Name }
func (r *userResolver) Email() *string { return r.u.Email }
type postResolver struct{ p *Post }
func (r *postResolver) ID() graphql.ID { return graphql.ID(r.p.ID) }
func (r *postResolver) Title() string { return r.p.Title }
func (r *postResolver) Body() string { return r.p.Body }
func (r *postResolver) Author() *userResolver {
return &userResolver{u: r.p.Author}
}After (grx - the data struct is the resolver):
type User struct {
ID string `gql:"id,nonNull"`
Name string `gql:"name,nonNull"`
Email *string `gql:"email"`
}
type Post struct {
ID string `gql:"id,nonNull"`
Title string `gql:"title,nonNull"`
Body string `gql:"body,nonNull"`
Author *User `gql:"author,nonNull"`
}What disappears:
- The
userResolver/postResolverwrappers. - The
graphql.IDconversions (just usestring). - The
Author()method that re-wraps the inner pointer - grx walks*Post.Authordirectly because it's already a*User.
What you keep:
- Nullability via pointers (
*stringforString, value type forString!). - Lists as Go slices (
[]*User).
If a field needs computed logic (not just direct field access), turn the type into a struct with methods on the root, not on the data type itself - see the Resolvers page.
Step 3 - Root resolver methods stay; argument types get names
Before:
type root struct{}
func (*root) User(args struct{ ID graphql.ID }) *userResolver {
return &userResolver{u: loadUser(string(args.ID))}
}
func (*root) Users(args struct{ Count int32 }) []*userResolver {
src := loadUsers(int(args.Count))
out := make([]*userResolver, len(src))
for i, u := range src {
out[i] = &userResolver{u: u}
}
return out
}After:
type IDArgs struct {
ID string `gql:"id,nonNull"`
}
type UsersArgs struct {
Count int `gql:"count,nonNull"`
}
type Query struct{}
func (Query) User(ctx context.Context, args IDArgs) (*User, error) {
return loadUser(args.ID), nil
}
func (Query) Users(ctx context.Context, args UsersArgs) ([]*User, error) {
return loadUsers(args.Count), nil
}Notable changes:
- Argument structs are named types so they can carry
gqltags. The graph-gophers anonymous-struct trick doesn't fit grx's reflection model; promote each one to a top-level type. - Resolvers return
(value, error)instead of just a value. Errors become entries inerrors[]with the field path populated. - A
context.Contextis supplied as the first parameter (optional - drop it if the resolver doesn't need it). - The wrapper resolver structs (
*userResolver,[]*userResolver) are gone; return the data types directly.
Step 4 - Splitting one root into per-entity structs
graph-gophers users typically have one giant root struct with every
resolver method on it. grx supports the same shape, but
reflect.Type.NumMethod() includes promoted methods from embedded
fields, so you can split the root by entity:
// graph/user.go
type UserQuery struct{}
func (UserQuery) User(ctx context.Context, args IDArgs) (*User, error) { /* ... */ }
func (UserQuery) Users(ctx context.Context, args UsersArgs) ([]*User, error) { /* ... */ }
// graph/post.go
type PostQuery struct{}
func (PostQuery) Post(ctx context.Context, args IDArgs) (*Post, error) { /* ... */ }
// graph/schema.go
type Query struct {
UserQuery
PostQuery
}Each entity now lives in one file. See Queries and mutations for the full pattern.
Step 5 - Replace graphql.ParseSchema and relay.Handler
Before:
schema, err := graphql.ParseSchema(sdl, &root{}, graphql.MaxParallelism(1))
if err != nil { log.Fatal(err) }
http.Handle("/graphql", &relay.Handler{Schema: schema})
http.ListenAndServe(":4000", nil)After:
import (
"github.com/grx-gql/grx"
"github.com/grx-gql/grx/schema"
)
srv, err := grx.NewServer(
grx.WithSchema(schema.Config{Query: Query{}}),
grx.WithPlaygroundPath("/"),
)
if err != nil { log.Fatal(err) }
http.ListenAndServe(":4000", srv)Notes:
grx.NewServerreturns anhttp.Handlerthat already includes the GraphiQL playground at/. You can drop anyplaygroundpackage you were wiring up alongsiderelay.Handler.MaxParallelismdoesn't translate. grx executes synchronously by design (see Execution) which is why it benchmarks faster than graph-gophers running with the equivalentMaxParallelism(1)setting.
Step 6 - Subscriptions
graph-gophers supports subscriptions through resolver methods returning
<-chan values. The grx signature is essentially the same - only the
schema/server wiring changes.
Before (graph-gophers):
func (*root) UserCreated(ctx context.Context) (<-chan *userResolver, error) {
out := make(chan *userResolver)
go func() {
defer close(out)
for u := range bus {
select {
case <-ctx.Done(): return
case out <- &userResolver{u: u}:
}
}
}()
return out, nil
}After (grx):
type UserSubscription struct{}
func (UserSubscription) UserCreated(ctx context.Context) (<-chan *User, error) {
out := make(chan *User)
go func() {
defer close(out)
for u := range bus {
select {
case <-ctx.Done(): return
case out <- u:
}
}
}()
return out, nil
}
type Subscription struct{ UserSubscription }Then opt into the transports:
import (
"github.com/grx-gql/grx"
"github.com/grx-gql/grx/sse"
"github.com/grx-gql/grx/websocket"
"github.com/grx-gql/grx/schema"
)
grx.NewServer(
grx.WithSchema(schema.Config{
Query: Query{},
Mutation: Mutation{},
Subscription: Subscription{},
}),
grx.WithTransports(websocket.New(), sse.New()),
)The wire protocol is graphql-transport-ws (the modern protocol used
by graphql-ws v5+ and Apollo Client v3.5+). graph-gophers users on
the legacy subscriptions-transport-ws protocol will need to upgrade
their clients - see Subscriptions.
Step 7 - Auth, tracing, request-id
In graph-gophers you typically wrap relay.Handler in HTTP middleware
to attach values to r.Context(). grx prefers
Plugins for the same job because they get to
participate in the GraphQL lifecycle, not just the HTTP boundary:
type RequestID struct{ plugins.Base }
func (RequestID) RequestStart(ctx context.Context, _ core.Request) (context.Context, error) {
return context.WithValue(ctx, requestIDKey{}, newID()), nil
}RequestStart is the only hook that may return a derived context. See
Write a Custom Plugin for the full pattern.
If you genuinely need HTTP-level middleware (CORS, gzip, IP allowlists),
keep wrapping srv like any other http.Handler:
http.ListenAndServe(":4000", corsMiddleware(srv))Things that aren't yet supported
If your graph-gophers schema uses any of these, check the current grx implementation before committing:
- Enums (
type Direction enum). - Interfaces and unions including
implementslists. - Custom scalars registered via
graphql.ScalarConfig. - Directives, including
@deprecatedand@specifiedBy. - Field aliases, fragments,
@skip/@include- parser and executor gaps. - Default argument values declared in the SDL.
Verification checklist
Before deleting graph-gophers/graphql-go from your go.mod:
- Build the grx schema in a sibling package; keep the old SDL around as a reference.
- Run a representative query set through both executors and diff
the JSON. Pay attention to nullability - graph-gophers tends to
return non-null fields as zero values; grx returns
nullfor pointer-typednil. - Move HTTP middleware that only cares about HTTP (CORS, gzip)
by wrapping
srv. Move middleware that touches the GraphQL lifecycle into a plugin. - Confirm subscriptions still work end-to-end - clients on the old
subscriptions-transport-wsprotocol must upgrade. - Re-benchmark your real workload. graph-gophers is the closest competitor in the bundled benchmarks (~3.7× slower on the nested query); your numbers will depend on the size and shape of your responses.
When everything passes:
go mod edit -droprequire github.com/graph-gophers/graphql-go
go mod tidyLast updated on