GraphQL and Go: gqlgen after a year in production
Hi! Petr Korobeinikov here. I lead backend development in one of the key fintech teams, and I’m responsible for making sure that users of the My MTS app always see up-to-date information about their account. In short: we run Go on the backend, and we talk to the app over GraphQL – a choice dictated by the platform, since we effectively expose a subgraph that our part of the app queries.
In this note I won’t compare protocols,
start a holy war over REST vs gRPC vs GraphQL,
or give a step-by-step GraphQL tutorial.
I’ll share our experience of using gqlgen on a real project,
and I’ll also highlight what I would have done differently a year and a half ago (spoiler: not all that much).

I’ll start with how our Go project is structured and how gqlgen fits into it:
Then I’ll share the approaches we use on the project:
- A custom scalar type for dates instead of String
- Making resolvers testable
- Taming heavy resolvers inside objects
- Putting directives to work
- Extra fields on the model
- Integrating OTel
- Rejecting complex queries (which we did not adopt)
And the takeaways:
File layout and code generation
You can spin up and run an app quickly by following the official tutorial, but personally I don’t like the file layout it suggests.
In our project, all generated code (the output of mockgen, protoc, gqlgen, and other tools) lands in an internal/generated folder.
Schema definitions live in a contract folder,
split into server – for schemas the server provides – and client – for schemas from which we generate clients for external services.
Here’s what it looks like:
contract
├── client
│ ├── grpc
│ │ ├── client1
│ │ └── client2
│ └── openapi
│ ├── client1
│ └── client2
└── server
├── graphql
│ └── <our-app-graphql-schema-here>
...
internal
├── generated
│ ├── contract
│ │ ├── client
│ │ └── server
│ │ └── graphql/
│ │ └── <our-app-name>/
│ │ ├── model/
│ │ │ └── generated_model.go
│ │ ├── generated_server.go
│ │ ├── <our-app-name>.resolvers.go
│ │ └── resolver.go
│ └── mockgen
...
├── repository
...
├── service
...
Overall it fit our general layout scheme, but there was one catch:
the files resolver.go and *.resolvers.go are actually meant to be edited – you have to write code in them.
Which is a bit annoying, because we wanted idempotent generation and to never touch generated code.
I have to admit that gqlgen adds and removes code in these files very sensibly.
If a resolver disappears from the schema,
its handler code moves to the very end of the file and gets commented out – nothing is lost by accident.
Still, the first time around I’d recommend practicing a little and watching how the generator behaves when the schema changes.
As an example, here’s a fragment of the gqlgen.yml config where the paths are overridden to match our layout:
schema:
- contract/server/graphql/our-app-name/*.graphqls
exec:
filename: internal/generated/contract/server/graphql/our-app-name/generated_server.go
package: graphql
model:
filename: internal/generated/contract/server/graphql/our-app-name/model/generated_model.go
package: model
resolver:
layout: follow-schema
dir: internal/generated/contract/server/graphql/our-app-name
package: graphql
filename_template: "{name}.resolvers.go"
Defining the schema
With gqlgen we use the Schema First approach: we describe the schema and then generate code from it.
Here’s a primitive example of a schema:
type Query {
}
type Mutation {
}
We use the .graphqls extension in our code, which matches our schema (see the config above – the schema section).
I’ve noticed that many people use the .graphql extension without the trailing “s”.
Not that it’s wrong, but it’s still not quite right.
And there’s no Subscription in the schema, because we don’t use subscriptions.
Naming conventions
Naming is always the most contentious part of any set of conventions. Let me describe how we name resolvers and data types – and criticize myself along the way.
First, we don’t use snake_case.
The official docs always use camelCase for fields, too.
Unfortunately, I couldn’t find a decent linter that flags problems so I could hook it into pre-commit (drop a comment if you know one).
So I settled for the format-graphql utility to add missing spaces and sort the definitions.
But the choice between snake_case and camelCase isn’t the biggest problem.
I thought about how to name objects and the operations on them, and arrived at this:
type Query {
item: ItemResult
}
type Mutation {
# First the object being acted upon,
# then the verb.
# This narrows down the operation search in the autocomplete of the same Graphiql.
itemCreate(input: ItemCreateInput) ItemCreateResult
itemUpdate(input: ItemUpdateInput) ItemUpdateResult
itemDelete(input: ItemDeleteInput) ItemDeleteResult
}
type ItemResult {
id: ID!
name: String!
# ...
}
input ItemCreateInput {
name: String
# ...
}
input ItemUpdateInput {
name: String
# ...
}
input ItemDeleteInput {
id: ID!
}
Looking back, I’d simplify this schema a little:
- I’d drop
Item*Resultand returnID!(yes, we’d then have to request data from the API that the app already has). - I’d simplify the argument for
item*Deletequeries down toID!. - I might drop the
*Resultsuffix inQuery– that’s a matter of taste.
Here’s how our schema would look if I were designing it now rather than back then:
type Query {
item: Item
}
type Mutation {
itemCreate(input: ItemCreateInput) ID!
itemUpdate(input: ItemUpdateInput) ID!
itemDelete(id: ID!) ID!
}
type Item {
id: ID!
name: String!
# ...
}
input ItemCreateInput {
name: String
# ...
}
input ItemUpdateInput {
name: String
# ...
}
That’s probably how our schema would look if I were designing it now. But back then I was working within our constraints. At the time it made sense to return the whole object from a mutation straight to the app, instead of making a separate request for it. The constraint went away, but the schema stayed.
And the main idea I always keep in mind: the naming of types and methods (queries and mutations) must stay consistent across the whole schema.
Now let me share our approaches – or life hacks, if you like.
A custom scalar type for dates instead of String
If you declare your own type, the generated server takes care of all the conversion: you won’t have to parse the date by hand on every request or assemble it into the right format on every response.
Declare your own scalar type in your schema and use it:
scalar DateTime
type Item {
createdAt: DateTime!
updatedAt: DateTime
}
To support a particular format you’ll still need to write serialization and deserialization code – but only once for the whole project. To keep the article short, here’s a link to the official docs, where it’s all laid out in detail.
This works not only for dates with a strict format, but for any type specific to your domain – and for more specific scalar types such as UUID.
By the way, UUID is supported out of the box; just add these lines to the models section of your gqlgen.yml:
models:
UUID:
model:
- github.com/99designs/gqlgen/graphql.UUID
Making resolvers testable
Once I’d generated the code from the schema, my hands instinctively reached to add the handling logic.
Here are roughly the stubs you get after generation in your-app-name.resolvers.go:
// ...
func (r *mutationResolver) ItemCreate(ctx context.Context, input *model.ItemCreateInput) (*model.ItemCreateResult, error) {
// By default there's a line here with panic("Not implemented")
}
// ...
I moved the handling code out separately, because in the generated stubs we only enrich the data with request parameters intercepted in middleware higher up the stack:
// ...
func (r *mutationResolver) ItemCreate(ctx context.Context, input *model.ItemCreateInput) (*model.ItemCreateResult, error) {
personID := PersonIDFromRequestContext(ctx)
return r.itemCreateProcessor.Process(ctx, personID, input)
}
// ...
In this form, covering the Process() method with unit tests is a matter of routine.
You don’t have to craft a request with a context that has the user ID sitting in the right place.
We have the arguments we feed in, and the results we get out.
Taming heavy resolvers inside objects
Out of the box, gqlgen generates code that lets you return the whole object when it’s requested.
Even if you’ve listed the fields you want, you’ll still fill in all the fields of that object on the backend.
The unneeded ones get trimmed when the response is serialized.
Let me show an example. Given the schema:
Query {
item {
id: ID!
name: String!
expensiveField: YourDomainTypeOrMayBeJustTooLongString!
}
}
So for the item query you’ll have to fill in expensiveField as well:
{
item {
id
name
}
}
The official docs have a solution, but it seemed to me it isn’t obvious to newcomers who’ve only just started with GraphQL. Here’s the answer: split off a separate resolver that’s only called when the field is requested explicitly.
How to configure this:
models:
Item:
fields:
expensiveField:
resolver: true
Now, if the query doesn’t specify the expensiveField field, the code won’t run:
{
item {
id
name
expensiveField # <-- two resolvers will be called:
# one for item and one for expensiveField,
# the result is merged into the item object
}
}
You should split off into separate resolvers only the genuinely heavy fields – ones that, for example, require a trip to a neighboring service to return. If you have scalar data (say, a person’s full name) stored side by side in one table, it’s cheaper to fetch it all than to declare a resolver for every field.
Putting directives to work
Directives in GraphQL let you influence the runtime and/or code generation. The docs have an example of checking a user’s role in action. I’ll show our real example.
We use directives in the schema to add struct tags to the generated code. The validation mechanism looks at those tags. Here’s an example:
directive @goTag(key: String!, value: String) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION
input ItemCreateInput {
field1: String! @goTag(key: "validate", value: "validator_name1,validator_name2")
field2: String! @goTag(key: "validate", value: "validator_name3")
field3: String @goTag(key: "validate", value: "omitempty,validator_name4")
}
The @goTag directive is supported out of the box.
We just added it to the schema and applied it to a field.
Now you can validate the data that came from the user in a single line:
err := s.validator.StructCtx(ctx, input)
if err != nil {
return nil, someWrappingAndProcessing(err)
}
Extra fields on the model
Sometimes it happens that some user input needs additional fields – for example, the user’s id.
That field isn’t passed explicitly – it’s simply inconvenient and pointless to shove an authenticated user’s id into every payload.
But on the backend we know who this input belongs to.
Here’s a real example. Within their own account, a user can’t create more than N objects linked to it (notes, reminders, bills, cards, profile photos – you get the idea).
We know the account id, and we know what the user is trying to add.
And to avoid dragging an extra id parameter into every function, it’s convenient to put it into the same struct as the user input.
Here’s the config in gqlgen.yml:
ItemCreateInput:
extraFields:
PersonID:
description: "The user's identifier; not assigned automatically."
type: "github.com/google/uuid.UUID"
Now in the code we can set this field and pass the whole object for validation:
input.PersonID = personID
err := s.validator.StructCtx(ctx, input)
if err != nil { /* ... */ }
And, by the way, a validator is easier to describe when you work with the whole struct rather than with individual fields.
Integrating OTel
For tracing I didn’t reinvent anything; I took a ready-made library that suited me completely: github.com/zhevron/gqlgen-opentelemetry/v2 – it’s easy to integrate and automatically shows the query and its parameters.
Configuration is a single simple call:
server.Use(gqlgen_opentelemetry.Tracer{
IncludeFieldSpans: true,
IncludeVariables: true,
})
Of course, it only covers work with the GraphQL endpoint itself. If you need deeper tracing inside your resolver handlers, there’s nothing unusual – you just pick up the parent span from the context, as in any other code.
And, finally, I decided to share something we don’t use ourselves but that might be useful to you.
Rejecting complex queries (which we did not adopt)
When does this limit matter? If you expose your graph externally to outside clients – the way GitHub does, for example. You can forbid the execution of heavy queries that use several resolvers and pull a large volume of data in one go.
What might such a query look like? For example, like this:
{
item1: item {
id
name
subitems {
id
name
subitems {
# ...
}
}
}
item2: item {
id
name
subitems {
id
name
subitems {
# ...
}
}
}
# itemN ...
}
In this synthetic example we called the same resolver several times under different aliases and drilled deep into nested objects.
Since we don’t expose the API externally, we don’t limit complex queries either – we know them all in advance. But I decided to mention the Query Complexity limiting mechanism – it’s bound to be relevant to someone.
It’s not hard to implement. Counting the complexity of each node to cap the “heaviness” of a query is common practice in GraphQL. The mechanism will be similar in other languages and libraries. So we always know the cost – in abstract units – of each resolver’s work, we understand the complexity of the whole query, and we can reject it before it even runs:
server.Use(extension.FixedComplexityLimit(5))
What else I would do differently
If I’d had the chance to pick the language, framework, and library for GraphQL support at the start, I’d have gone with one of two options:
- Kotlin / Spring Boot / graphql starter
- C# / the standard ASP.NET application template / HotChocolate
I reimplemented our subgraph for myself in both. In my view, it came out noticeably more compact and expressive.
With Spring Boot we can use the Schema First approach we’re used to, wiring resolvers to code with annotations. That removes the manual code-generation step entirely.
With HotChocolate the Code First approach is convenient. And since we’re the ones providing the server, we can always obtain the current schema. And here seasoned .NET folks might ask: why HotChocolate and not GraphQL for .NET? I really did compare both frameworks, not just read what people write on Reddit. Integration and schema definition with HotChocolate felt smoother to me, and the documentation friendlier. If I’m mistaken, correct me in the comments.
Afterword
In this note I highlighted the things I personally ran into while providing a GraphQL API. I’d be glad to discuss your suggestions and thoughts in the comments.