Examples
| Directory |
Read it for |
quickstart |
The smallest thing that runs: one SDL file, hand-written bindings, no codegen, no layers. |
blog |
A layered service: generated bindings, a domain isolated from the wire format, a DataLoader, subscriptions. |
storefront |
Authorization: scopes, an argument site, per-row instance checks, and the server wiring a deployment needs. |
federation |
Two Apollo Federation subgraphs and the fetch a router performs across them. |
relaynode |
The Relay contract: global ids, Query.node re-fetch, and cursor connections. |
echo |
Serving blog through Echo v5 — routing and shutdown wiring only. |
fiber |
Serving blog through Fiber v3 — routing and shutdown wiring only. |
veloxfx |
A real service: velox writes the ORM and the SDL for seven entities in three domains, gqlc binds them, uber/fx assembles each domain like a gRPC service behind Echo. GUIDE.md is the developer guide. Its own module. |
storefront is the one to read for anything about authorization, limits or
shutdown; blog is the one to read for layering and codegen. They do not
overlap on purpose.
echo and fiber build the same schema blog/cmd/server does. One schema
over three frameworks is the point: nothing in blog knows which transport is
serving it.
Two ways to bind Go types
quickstart binds its own type straight to the schema:
graphql.Object[Note]("Note", graphql.Field("id", ...))
blog does not. Its internal/domain types never reach the GraphQL layer;
internal/transport/graphql maps them to the generated models at the boundary,
so editing the SDL cannot change the type the repository stores.
The library does not require this. Object[domain.User] works, and for a
small service it is the better trade. blog pays for the separation to show
what it costs as well as what it buys — and it does cost. Post.author is a
resolver field, so the generated model.Post has no AuthorID, and
PostAuthor has to fetch the post back out of the store to recover an author id
the domain object was already holding, before the DataLoader can batch anything.
Binding the domain type directly would hand it over for free.
Layers in blog
| Package |
Holds |
internal/domain |
Entities. Imports neither graphql-go nor the generated models. |
internal/repository |
Storage. Stores what it is given. |
internal/app |
The rules: a post needs a real author; title cannot be cleared. |
internal/transport/graphql |
Implements the generated Resolver, maps domain to model, owns the DataLoader. |
graph/ |
Generated by gqlc. Do not edit. |
Most of app forwards to the repository, which is the honest shape of a service
layer over an in-memory store. It earns its place by being the only path to the
two rules — a repository will happily store a post whose author does not exist.
blog exports one function, NewSchema. Everything else is under internal/,
which is what lets examples/echo use the schema and not its internals.
Editing the schema
blog/schema.graphql is the source. blog/graph/schema/schema.graphql is a
copy gqlc writes for the //go:embed; editing it is undone by the next
go generate.
cd examples/blog && go generate
Authorization in storefront
blog shows how to build a schema. storefront shows what has to be true
before one is deployed: every position declares who may reach it, and
RequireAuthCoverage fails the build for a field that declares nothing.
| Mechanism |
Where |
What it is for |
@requiresScopes |
Customer.email, Order.margin, Mutation.refundOrder |
A scope on one position. |
MarkerDirective |
@authenticated on Order, Customer, the roots |
A no-argument directive whose presence is the requirement. Type-level, so it is ANDed into every field below. |
@authorizeInput(kind: FILTER) |
Query.orders(where:) |
Filtering by minMargin reads margin, so it needs the same scope as selecting it. |
@authorizeInput(kind: WRITE) |
Mutation.placeOrder(input:) |
Naming someone else's customerId is a staff action. |
@authorizeObject |
Order, Customer |
Which rows a principal may see, decided per value during execution and batched per list. |
RequireAuthCoverage |
NewSchema |
A field with neither a requirement nor @public fails the build. |
Four principals read the same graph and see different parts of it. Run the
server and try them:
go run ./examples/storefront/cmd/server
# The header stands in for a verified token. Anything unrecognised is
# anonymous, because a credential the server cannot read is not one.
GQL='curl -s localhost:8080/graphql -H content-type:application/json'
# anonymous: Query.health and nothing else
$GQL -d '{"query":"{ health }"}'
$GQL -d '{"query":"{ orders { reference } }"}'
# staff: everything
$GQL -H authorization:staff -d '{"query":"{ orders { reference margin customer { name email } } }"}'
# support: every order, masked addresses, margin refused
$GQL -H authorization:support -d '{"query":"{ orders { reference customer { email } } }"}'
$GQL -H authorization:support -d '{"query":"{ orders { margin } }"}'
# a customer: their own two orders, and no sign that a third exists
$GQL -H authorization:customer:c1 -d '{"query":"{ orders { reference } }"}'
The four withholding outcomes are each used where they are right, which is a
product decision and not a mechanical one:
- Allow — the row is yours.
- Redact —
Customer.email is masked for a support agent, because an
agent has to tell two customers apart. The field still resolves; Redact
rewrites a result rather than preventing one, so it is wrong for anything
whose computation is itself the secret.
- Deny —
Order.margin is refused outright. A zeroed margin is a number,
and a wrong number is worse than a refusal. The field is non-null, so
refusing it bubbles the order away; that is the visible cost of the choice.
- Drop — a customer's listing omits rows that are not theirs, so it does
not report how many exist. Valid only at a list element position, which
AuthSite.ListElement is what tells the policy.
One limit the example states rather than hides: on a subscription the row
check withholds the payload, not the event. A subscriber who may not see an
order still receives an event carrying the refusal, so timing leaks even
though contents do not. Withholding the event itself is the source's job.
Federation in federation
fed/'s own tests prove one subgraph answers _service and _entities
correctly. They cannot show the thing federation actually is: an entity owned
by one service and extended by another, joined at request time by a router
neither service knows about.
products owns Product. reviews owns Review and contributes reviews
and averageRating to a Product it knows nothing about except the key --
its ProductRef is one field wide, which is the point and not an oversight.
federation_test.go plays the router for the query neither service can
answer, { topProducts { name reviews { rating } } }:
- Ask the owner, adding
__typename and the key to what the client wanted.
- Turn each row into a representation: typename and key, nothing else.
- Ask the other subgraph by
_entities.
- Merge by position.
Every step is asserted, because every step is where a subgraph is usually
wrong: the __typename it forgets, the key it returns in the wrong scalar
type, the row it drops instead of returning null. That last one is the worst
of the three -- _entities answers positionally, so a dropped row shifts
every row after it onto the wrong product, which is a wrong answer rather than
a failed one. A test pins it.
What this does and does not prove. It proves both subgraphs hold up their
half of the contract for a real cross-subgraph query, without either tool
installed. It does not prove composition: that is rover supergraph compose's
job, and cmd/subgraphs/supergraph.yaml is there to run it.
go run ./examples/federation/cmd/subgraphs
# what the router reads first
curl -s localhost:8080/products/graphql -H content-type:application/json -d '{"query":"{ _service { sdl } }"}'
# the second hop, by hand
curl -s localhost:8080/reviews/graphql -H content-type:application/json -d '{"query":"query($r:[_Any!]!){ _entities(representations:$r){ ... on Product { averageRating } } }","variables":{"r":[{"__typename":"Product","sku":"kb-01"}]}}'
Both subgraphs run in one process on two paths. That is not how they would be
deployed -- it is the opposite of the point -- but a router reads each
endpoint's _service and cannot tell.
Relay in relaynode
The relay package had no example before this one. Its own tests exercise
every function, but a test shows one call at a time; what a reader needs is
the three calls a connection takes sitting beside the SDL they bind to.
relay.Pagination() // once per schema
relay.Bind[*Repository]("RepositoryConnection", "RepositoryEdge") // once per connection
relay.FromSlice(rows, args) // in the resolver
relay.IDField is the one that is easy to get wrong. It writes
base64("Type:id") for the id field -- byte for byte what graphql-relay-js
and graphql-java produce. Using a plain Field("id", ...) that returns the
local id compiles, validates, and is a perfectly good ID!, and then
node(id:) fails for every object. A test pins the encoding for that reason.
node(id:) has three ways to find nothing and they are deliberately not the
same answer: a well-formed id naming a missing row, and one naming a type this
server does not serve, are both a null Node -- the id decoded, it just named
nothing. Text that is not a global id at all is an error, because the client
sent something it could not have received from this server.
examples/blog also has a Node interface, hand-written and without this
package. Both are worth reading: blog shows that Query.node needs no engine
support at all, since an unbound interface resolves from the dynamic Go type.
relaynode shows what you get for not writing it yourself.
go run ./examples/relaynode/cmd/server
GQL='curl -s localhost:8080/graphql -H content-type:application/json'
# a connection hands back global ids and a cursor
$GQL -d '{"query":"{ users(first:1){ edges{ cursor node{ id name } } pageInfo{ hasNextPage endCursor } } }"}'
# the id from that response, handed straight back
$GQL -d '{"query":"query($id:ID!){ node(id:$id){ __typename ... on User { name } } }","variables":{"id":"VXNlcjox"}}'
velox, fx and Echo in veloxfx
blog writes its SDL by hand and generates models from it. veloxfx goes the
other way, which is how an ORM-backed service is usually built: the velox
schema in schema/ is the only source, and both the ORM and the SDL are
output. Seven entities in three domains, served through Echo and assembled by
uber/fx.
veloxfx/GUIDE.md is the developer guide, laid out
like the gRPC basics tutorial: define, generate, implement, register, run,
then the everyday loop of adding an entity, with every output recorded from a
real run. In short:
schema/*.go + sdl/*.graphql --go generate--> velox/ (ORM), graph/<group>/ (interfaces, bindings),
internal/<domain>/<group>.resolvers.go (stubs)
- Nothing is converted by hand.
id binds to velox's ID int
(models: ID: int), and velox's order fields and cursors bind as they are
(graphql.EnumMarshaler, graphql.ScalarMarshaler), so connection edges are
velox's own methods and a resolver takes velox's types as arguments.
- It is laid out like a gRPC service. Each group has a
Resolver
interface and a Bindings function; a domain package implements its
groups and registers each with one gqlfx.Register line in its fx
Module; nothing lists every entity.
- The API offers operations, not the ORM's raw writes. Order and stock
change only through
placeOrder, cancelOrder, adjustStock and the
like, each a conditional update or a transaction, and twenty concurrent
orders for five units place exactly five.
- Errors carry
extensions.code, mapped in one presenter; a driver's
message never reaches a client.
- Adding an entity is 35 lines by hand, none of them a signature or a
conversion.
It is a separate module because velox, fx and SQLite are dependencies of the
example, not of the library. scripts/gate.sh finds it like every other
go.mod.
cd examples/veloxfx
go generate # after editing schema/ or sdl/
go run ./cmd/server
Running
go run ./examples/quickstart # :8080 /graphql, /graphql/stream
go run ./examples/blog/cmd/server # :8080 + /graphql/ws
go run ./examples/storefront/cmd/server # :8080, all three, with limits and a drain
go run ./examples/echo # same schema, Echo v5
go run ./examples/fiber # same schema, Fiber v3
go run ./examples/federation/cmd/subgraphs # two subgraphs on two paths
go run ./examples/relaynode/cmd/server # global ids and connections
(cd examples/veloxfx && go run ./cmd/server) # velox + fx + Echo, own module