Skip to content
gohacks.dev
Databases

Ent GraphQL Integration With entgql and gqlgen Together

What entgql generates from an Ent schema: a Relay GraphQL schema, pagination, filters and mutation inputs, shown from a run, and what is left for gqlgen

11 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the GraphQL logo.

GraphQL and Ent share a worldview. Both are graphs of typed entities with relations, and both reward a client that asks for exactly the fields it needs. The Ent project's GraphQL extension, entgql, acts on that: from your schema it writes a GraphQL schema with the Relay conventions already applied, and the Go glue that implements pagination, filtering and mutation inputs. You supply resolvers with gqlgen, which wires it all into a server. This article runs the extension on an entity, shows what it generates, and lists what remains for you. It is deliberately limited to what ran: the generated schema and code, and that the code builds with gqlgen. I did not build resolvers or serve queries.

Tested with Go 1.25.6, Ent v0.14.5, entgo.io/contrib (entgql), gqlgen v0.17.78, PostgreSQL schema only, macOS on Apple Silicon (arm64). The gqlgen resolvers and a running GraphQL server were not built.

It builds on the Ent tutorial and sits with gRPC and OpenAPI, which generate other kinds of contract. The query machinery behind the generated pagination is in Ent queries, and the authorisation layer you will want underneath is Ent privacy.

The pieces and who does what

PieceWho writes it
The Ent schema with entgql annotationsyou
ent.graphql (types, connections, inputs)entgql
gql_*.go (pagination, filters, node lookup, inputs)entgql
gqlgen.yml and the resolversyou (gqlgen generates stubs)
Mutation type and mutation resolversyou
Authentication, authorisation, limitsyou

The division matters because it shows where the generated code stops. entgql makes the data model queryable. It does not make a GraphQL API safe to expose.

The schema

The Todo entity has the three ingredients of a real list: a required text, an enum status and an integer priority, plus a self referential edge so a todo can have a parent and children. The annotations tell entgql what to expose:

ent/schema/todo.goGo
package schema

import (
	"entgo.io/contrib/entgql"
	"entgo.io/ent"
	"entgo.io/ent/schema"
	"entgo.io/ent/schema/edge"
	"entgo.io/ent/schema/field"
)

type Todo struct{ ent.Schema }

func (Todo) Annotations() []schema.Annotation {
	return []schema.Annotation{
		entgql.RelayConnection(),
		entgql.QueryField(),
		entgql.Mutations(entgql.MutationCreate(), entgql.MutationUpdate()),
	}
}

func (Todo) Fields() []ent.Field {
	return []ent.Field{
		field.String("text").NotEmpty(),
		field.Enum("status").Values("IN_PROGRESS", "COMPLETED").Default("IN_PROGRESS").Annotations(entgql.Type("TodoStatus")),
		field.Int("priority").Default(0).Annotations(entgql.OrderField("PRIORITY")),
	}
}

func (Todo) Edges() []ent.Edge {
	return []ent.Edge{edge.To("children", Todo.Type).From("parent").Unique()}
}
  • entgql.RelayConnection() makes list fields follow the Relay connection specification: edges, cursors and pageInfo.
  • entgql.QueryField() adds a todos field to the root Query type.
  • entgql.Mutations(entgql.MutationCreate(), entgql.MutationUpdate()) generates CreateTodoInput and UpdateTodoInput, not mutations themselves.
  • entgql.Type("TodoStatus") names the GraphQL enum for the Ent enum.
  • entgql.OrderField("PRIORITY") makes the field sortable and adds it to the ordering enum.

The generator program

As with the other extensions, a small program runs entc.Generate with the extension:

ent/entc.goGo
//go:build ignore

package main

import (
	"log"

	"entgo.io/contrib/entgql"
	"entgo.io/ent/entc"
	"entgo.io/ent/entc/gen"
)

func main() {
	ex, err := entgql.NewExtension(
		entgql.WithSchemaGenerator(),
		entgql.WithSchemaPath("../ent.graphql"),
		entgql.WithWhereInputs(true),
	)
	if err != nil {
		log.Fatalf("creating the entgql extension: %v", err)
	}
	if err := entc.Generate("./schema", &gen.Config{}, entc.Extensions(ex)); err != nil {
		log.Fatalf("running ent codegen: %v", err)
	}
}
ent/generate.goGo
package ent

//go:generate go run -mod=mod entc.go

WithSchemaGenerator() asks entgql to write the GraphQL schema, WithSchemaPath says where, and WithWhereInputs(true) adds filter input types. The script runs it, lists what appeared and prints the relevant parts of the schema:

Shell
set -e
(cd ent && GOFLAGS=-mod=mod go generate .)
go mod tidy > /dev/null 2>&1
echo "== files the extension added next to the usual client"
ls ent | grep '^gql_' | sed 's/^/  /'
echo "== lines of GraphQL schema: $(wc -l < ent.graphql | tr -d ' ')"
echo "== the generated types"
grep -E '^(type|input|enum|interface|scalar|extend type) ' ent.graphql | sed 's/ @.*//; s/ {$//'
echo "== the Todo type"
awk '/^type Todo implements Node/,/^}/' ent.graphql | grep -vE '^\s*(\"\"\"|#)' | grep -vE '^\s*$'
echo "== queries and mutations"
awk '/^type Query/,/^}/' ent.graphql | grep -E '^  [a-z]' 
echo "mutations in the schema: $(grep -c '^type Mutation' ent.graphql || true)"
echo "== the generated Go compiles against gqlgen"
go build ./... && echo ok
Text
== files the extension added next to the usual client
  gql_collection.go
  gql_edge.go
  gql_mutation_input.go
  gql_node.go
  gql_pagination.go
  gql_transaction.go
  gql_where_input.go
== lines of GraphQL schema: 256
== the generated types
input CreateTodoInput
scalar Cursor
interface Node
enum OrderDirection
type PageInfo
type Query
type Todo implements Node
type TodoConnection
type TodoEdge
input TodoOrder
enum TodoOrderField
enum TodoStatus
input TodoWhereInput
input UpdateTodoInput
== the Todo type
type Todo implements Node {
  id: ID!
  text: String!
  status: TodoStatus!
  priority: Int!
  parent: Todo
  children: [Todo!]
}
== queries and mutations
  node(
  nodes(
  todos(
mutations in the schema: 0
== the generated Go compiles against gqlgen
ok

What it generated

Seven Go files appeared next to the usual client: gql_collection.go (field collection, so a query loads only the columns and edges the GraphQL selection asks for), gql_edge.go (edge resolvers), gql_mutation_input.go (the input types and their SetInput methods), gql_node.go (global object identification), gql_pagination.go (the connection types and the Paginate builder), gql_transaction.go (a transaction middleware for mutations) and gql_where_input.go (the filters).

A 256 line GraphQL schema. Its types, from the script:

  • Relay plumbing: scalar Cursor, interface Node, type PageInfo, enum OrderDirection.
  • Per entity: type Todo implements Node, type TodoConnection, type TodoEdge, input TodoOrder, enum TodoOrderField, enum TodoStatus, input TodoWhereInput.
  • Mutation inputs: input CreateTodoInput and input UpdateTodoInput.

The entity type reads as the Ent schema did: id: ID!, text: String!, status: TodoStatus!, priority: Int!, parent: Todo and children: [Todo!]. The edge direction became fields, and the unique edge a nullable single value.

Three query fields. The generated Query has node, nodes and todos. node(id: ID!) and nodes(ids: [ID!]!) fetch any entity by its global id, which is how Relay clients refetch. todos is the connection with after, first, before, last, orderBy and where arguments (the schema text puts each on its own line; the script printed the first line of each field).

No Mutation type. The line mutations in the schema: 0 is the point to notice. entgql generates the input types (CreateTodoInput has text, status, priority, parentID and childIDs) and the Go methods that apply them (SetInput), and leaves the Mutation type to you. You declare it in your own .graphql file, for example a createTodo(input: CreateTodoInput!): Todo! field, and write a resolver that calls the client with the input. That is a sensible split: which mutations your API offers, and with what rules, is a product decision.

The Go compiles. go build ./... printed ok against gqlgen v0.17.78. entgql's generated files import gqlgen packages, so the pin matters. The latest gqlgen releases can require a Go toolchain newer than the one you run, and a generator that cannot build blocks the whole module. Pin gqlgen to a version that builds with your Go version and upgrade on purpose.

What the generated pieces do for you

The reason to use this at all is the list of problems it solves before you write a resolver.

Pagination. Relay cursor pagination is fiddly to implement. The generated Paginate method on a query takes after, first, before and last plus ordering and filters, and returns a connection with edges, cursors and pageInfo, using keyset style cursors over the ordered columns. That is the pagination shape keyset paging recommends, produced without hand written SQL.

Selecting only what was asked. gql_collection.go inspects the GraphQL selection set and applies Select and With... to the Ent query, so a request for todos { edges { node { text } } } does not load the priority column and a request that includes children eager loads them in one extra query instead of a query per todo. This is the part that avoids the N plus one problem GraphQL servers otherwise fall into (Preload and Joins describes the trap). It works when your resolvers pass the GraphQL context to the generated collection hooks, which is a step you must not skip.

Filtering. TodoWhereInput has a field for each predicate Ent generates (textContains, priorityGT, hasChildrenWith), combinable with and, or and not. Clients get a typed filter language without you designing one. It is also a large attack surface, covered below.

Global ids and node. The Node interface lets clients refetch any object by id. The generated code maps ids to types, so you do not write the lookup.

Where the work remains

Resolvers and gqlgen configuration. gqlgen.yml binds GraphQL types to the Ent types (autobind to the ent package, the ID scalar, the Node interface), and gqlgen generate writes resolver stubs that you fill in with calls to the Ent client. The generated query resolvers are one line each.

Authorisation. A generated GraphQL API exposes every entity you annotate, with arbitrary filters, to every caller. The place for rules is under the transport, in Ent privacy rules: a viewer in the context filters every query the resolvers issue, including those the collection hooks build. Add the viewer in an HTTP middleware.

Query cost limits. GraphQL lets a client write an expensive query: deep children nesting, huge first values, broad where filters. Set a maximum page size, a depth or complexity limit in gqlgen (it has a complexity calculator) and a statement timeout in the database. The generated filters make every column filterable, so index the columns you expect clients to use, or exclude the filter from fields that should not be filtered.

Error handling. Map Ent errors (IsNotFound, IsConstraintError, IsValidationError) to GraphQL errors with codes in an error presenter, so clients can tell a validation failure from an outage.

Schema evolution. Renaming a Go field can rename a GraphQL field. Annotate names where they matter, and diff ent.graphql in CI. GraphQL deprecation (@deprecated) is the tool for retiring fields.

The Go surface you will call

The generated Go code gives resolvers a small set of functions to call. I describe their roles from the generated file names and the Ent documentation, without having run a resolver, so check the signatures in your own generated package.

  • Paginate on a query builder. client.Todo.Query().Paginate(ctx, after, first, before, last, opts...) is the whole implementation of the todos field. It returns the connection type the schema declares, with Edges, PageInfo and TotalCount. The options carry the ordering and the filter: ent.WithTodoOrder(orderBy) and ent.WithTodoFilter(where.Filter), converting the GraphQL inputs into Ent predicates.
  • SetInput on create and update builders. A mutation resolver for createTodo is client.Todo.Create().SetInput(input).Save(ctx). The input type's method applies only the fields the client sent, which is how optional fields stay optional.
  • Noder and the node lookups. client.Noder(ctx, id) resolves a global id to the right entity type, which backs the node and nodes fields.
  • Field collection. The generated CollectFields hook reads the GraphQL selection set from the context and turns it into Select and With... calls on the Ent query, which is where the avoided N plus one comes from. A resolver that builds its own query and skips the hook loses that benefit and silently reintroduces per row queries.

The upshot is that a full set of resolvers for an entity is a handful of one line functions. The hard parts are the ones the framework cannot decide for you, listed above.

Testing a generated GraphQL API

Test it the way a client uses it, with real queries against a real database:

  • Schema snapshot. Commit ent.graphql and fail CI when it changes without review, as with the other generated contracts. A removed field is a breaking change for clients you have not met.
  • Query tests. Send documents through the gqlgen handler with httptest and assert the JSON: a list query, a filtered query, a nested edge query, a pagination walk with cursors.
  • Statement counts. Count the SQL statements for a nested query and assert a constant, the check that confirms the field collection works (the same assertion for Ent reads 2 for a project with 30 tasks).
  • Authorisation. Run every query as a viewer without rights and assert that nothing leaks, including through the node and nodes fields, which fetch by id from any entity type.
  • Cost. Send a deliberately expensive query (deep nesting, a large first) and assert that the limits reject it.

Compared with the other generated contracts

The same Ent schema can drive three contracts. They suit different clients:

ContractStrengthWeakness
GraphQL (entgql)One endpoint, clients choose fields, relations traverse in one requestCost control, caching and authorisation need deliberate design
gRPC (entproto)Compact, typed, streaming, code generated clients in many languagesBrowser access needs a proxy, field numbers are forever
REST (entoas)Familiar, cacheable, easy for third partiesMany round trips for related data, a shape fixed per endpoint

A common arrangement is gRPC between services, REST or GraphQL at the edge for web and mobile clients, and Ent privacy rules under all of them so the access rules live in one place.

Versioning and deprecation

GraphQL lets you evolve a schema without version numbers, and the rule is to add and deprecate, never remove. A new Ent field appears in the GraphQL type on the next generation, which is safe. A removed or renamed field breaks any client that selects it. Mark fields with @deprecated(reason: "...") through an entgql annotation for a release or two, watch usage in your server logs, and remove them once nothing asks for them. Treat ent.graphql as the public record: it is reviewed like code, and a pull request that deletes a line from it is a pull request that needs a plan.

When it fits

It fits when the API is mostly the entity graph: an admin console, an internal dashboard, a product with a rich client that explores relations. The pagination, filtering and selection-aware loading that you would otherwise write for each entity come for free. It fits less well when the API is a set of operations rather than a graph, or when it is public and its shape must be designed and frozen. As with the other generated contracts, a hand designed GraphQL schema over the Ent client is a fair middle road: you keep Ent for the data access and write the types yourself.

Checklist

  • Annotate entities deliberately: QueryField, RelayConnection, and only the mutation inputs you want.
  • Declare the Mutation type yourself and call SetInput.
  • Pin gqlgen to a version that builds on your Go version.
  • Put authorisation in Ent privacy rules, not in resolvers.
  • Add page size, depth and complexity limits, and index the filterable columns.
  • Diff ent.graphql in CI.

The extension is documented in the entgql guide and the server framework in the gqlgen documentation.

Also inAPIs
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.