Move frontend Relay client documentation into relay.md and create new graphql.md dedicated to Go backend patterns. Covers gqlgen schema-first approach, @goModel/@goEnum/@goField directives, connection type patterns, and cursor pagination implementation. - relay.md: Frontend Relay client (environments, compiler, queries, fragments, mutations) - graphql.md: Go backend gqlgen (directives, connection types, pagination schema, keyset pagination) - AGENTS.md: Update documentation references Signed-off-by: Bryan Frimin <bryan@getprobo.com>
5.5 KiB
GraphQL (Go Backend — gqlgen)
Schema-first GraphQL using gqlgen. The schema is hand-written; Go types and resolvers are generated.
Connection types and @goModel
Always define a custom Go type for connection types using the @goModel directive. The model path points to the types package for the relevant API. The totalCount field must use @goField(forceResolver: true). Edge types do not need @goModel.
type VendorConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.VendorConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [VendorEdge!]!
pageInfo: PageInfo!
}
type VendorEdge {
cursor: CursorKey!
node: Vendor!
}
Without @goModel, gqlgen generates a default struct that lacks the custom fields (ParentID, Resolver, Filter) needed by the pagination resolvers.
Enums and @goModel / @goEnum
Map GraphQL enums to existing Go types using @goModel on the enum and @goEnum on each value:
enum VendorOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.VendorOrderField") {
CREATED_AT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.VendorOrderFieldCreatedAt")
NAME
@goEnum(value: "go.probo.inc/probo/pkg/coredata.VendorOrderFieldName")
}
Schema directives
| Directive | Target | Purpose |
|---|---|---|
@goModel(model: "...") |
OBJECT, ENUM, INPUT_OBJECT, SCALAR, INTERFACE, UNION |
Map GraphQL type to existing Go type |
@goEnum(value: "...") |
ENUM_VALUE |
Map enum value to Go constant |
@goField(forceResolver: true) |
FIELD_DEFINITION |
Force a resolver function instead of struct field |
@goField(name: "...") |
FIELD_DEFINITION, INPUT_FIELD_DEFINITION |
Override Go field name |
@goField(omittable: true) |
INPUT_FIELD_DEFINITION |
Use graphql.Omittable[T] for distinguishing null vs absent |
Cursor pagination schema types
Every paginated field uses shared base types plus entity-specific types:
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: CursorKey
endCursor: CursorKey
}
enum OrderDirection
@goModel(model: "go.probo.inc/probo/pkg/page.OrderDirection") {
ASC @goEnum(value: "go.probo.inc/probo/pkg/page.OrderDirectionAsc")
DESC @goEnum(value: "go.probo.inc/probo/pkg/page.OrderDirectionDesc")
}
Each entity defines: enum XxxOrderField, input XxxOrder, type XxxConnection (with @goModel), type XxxEdge.
Connection fields on parent types use standard Relay arguments:
type Organization {
vendors(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: VendorOrder
filter: VendorFilter
): VendorConnection!
}
Go connection type pattern
Each connection type lives in types/*_connection.go and follows this structure:
type (
VendorOrderBy OrderBy[coredata.VendorOrderField]
VendorConnection struct {
TotalCount int
Edges []*VendorEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
)
func NewVendorConnection(
p *page.Page[*coredata.Vendor, coredata.VendorOrderField],
parentType any,
parentID gid.GID,
) *VendorConnection {
edges := make([]*VendorEdge, len(p.Data))
for i, v := range p.Data {
edges[i] = NewVendorEdge(v, p.Cursor.OrderBy.Field)
}
return &VendorConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
ParentID: parentID,
}
}
func NewVendorEdge(
v *coredata.Vendor,
orderBy coredata.VendorOrderField,
) *VendorEdge {
return &VendorEdge{
Cursor: v.CursorKey(orderBy),
Node: NewVendor(v),
}
}
Cursor format
Cursors are opaque CursorKey scalars. Internally they encode as base64url(JSON):
["<entity_global_id>", <sort_field_value>]
This enables keyset pagination — the database seeks directly to the right position instead of using OFFSET.
Keyset pagination
The database query uses the cursor to build a WHERE clause:
DESC: rows where(field <= cursor_value) AND NOT (field = cursor_value AND id > cursor_id)ASC: rows where(field >= cursor_value) AND NOT (field = cursor_value AND id < cursor_id)
The query fetches size + 1 (or size + 2 with a cursor) rows to detect whether more pages exist. NewPage trims extra rows and sets hasNextPage / hasPreviousPage.
For backward pagination (last / before), SQL sort direction is reversed, then the result slice is reversed back.
Default page size is 25 when neither first nor last is provided.
Adding a new paginated field — checklist
- Schema — add
enum XxxOrderField(with@goModel/@goEnum),input XxxOrder,type XxxConnection(with@goModelandtotalCountusing@goField(forceResolver: true)),type XxxEdge, and the connection field with Relay arguments on the parent type - Coredata — add
*_order_field.go(withColumn(),IsValid(), marshaling),CursorKey(field)method on the entity, and theLoadAllBy*query using cursor SQL fragments +page.NewPage() - API types — add
*_connection.gowithOrderByalias, connection struct,NewXxxConnection,NewXxxEdge - Resolver — implement the resolver (authorize, build order, build cursor, call service, build connection)
- Codegen — run
go generatefor the relevant API package