How it fits together
Layers, packages, startup vs request path, and where to plug in transports, middleware, plugins, and authorizers.
This page is the systems view: what owns the network edge, where GraphQL stops being HTTP, how packages depend on each other, and where you extend behavior without rewriting the executor. Pair it with Define your schema when you mostly care about resolver shape, or Execution pipeline when you want stage-by-stage detail.
Layers at runtime
Roughly everything in HTTP / WebSocket framing touches TCP, headers, or Match/Serve. Inside Transport-agnostic GraphQL the executor only sees core.Request - the intentional split so the same engine runs behind CLI tools, tests, WebSockets, SSE, or a bespoke gateway.
Clients & gateways (browsers · apps · BFF)
│
▼
┌─────────────────────────────────────────────────────────┐
│ HTTP / WebSocket framing │
│ Host HTTP middleware → server.Server → transports │
└─────────────────────────────────────────────────────────┘
│ Execute(ctx, core.Request)
▼
┌─────────────────────────────────────────────────────────┐
│ Transport-agnostic GraphQL │
│ exec.Executor <-> compiled schema / schema.Schema │
└─────────────────────────────────────────────────────────┘Important boundary: middleware works on http.Request (headers, cookies, trailers). core.Request deliberately carries only query, operationName, and variables - so JWT or Bearer parsing stays in middleware (or upstream proxies), attaching identity onto context.Context before the transport calls Execute. More on security wiring in Authentication & authorization.
Package layers (imports)
core is the thin ABI between transports and execution: Request / Response, the Executor interface, and Transport. The package deliberately imports nothing upward (exec, schema, server, … stay out), so wiring stays acyclic while every adapter shares the same types.
schema describes how Go types compile into GraphQL metadata (mostly at startup).
exec implements parsing, validation, execution, and subscription streaming.
plugins defines lifecycle hooks the executor invokes in order - logging, denial, enrichment.
server is the http.Handler: multiplexes playground, transports, timeouts, masking, persisted queries - then hands work to transports + executor.
Companion packages such as http, websocket, sse, and pubsub provide the official extension surfaces.
Import edges (→ reads “imports”):
plugins→coreexec→core,schema,pluginsserver→core,exec,schema,plugins
grx is a thin facade: NewServer and options map onto server.Config.
Two clocks: startup vs each request
| Phase | What happens |
|---|---|
| Process startup | Your schema.Config (resolver roots + optional enums/unions/etc.) is reflected once into a schema.Schema artifact; an exec.Executor binds that metadata with plugins and policy hooks; server.New wraps the executor and registers transports (http is auto-appended on the GraphQL path unless you customise ordering). |
| Each GraphQL operation | A transport parses the wire format → builds core.Request → invokes executor.Execute(ctx, req) or Subscribe → response bytes go back through the transport. No full schema rebuild; per-request CPU is dominated by lex/parse/validate/execute unless you explicitly cache fragments or APQ payloads. |
That split is why resolvers receive frozen schema metadata via the executor and fresh context.Context values (deadlines, cancellations, middleware-scoped identity).
Path routing in one glance
GraphQLPath(default/graphql) - HTTP JSON queries/mutations (POST) always land here thanks to the defaulthttptransport placed at the tail of that chain unless you reorganise transports.SubscriptionPath- when unset, WebSocket/SSE transports shareGraphQLPath(usual GraphiQL +graphql-transport-wssetup). When it differs, only concrete bundled*websocket.WebSocketTransport/*sse.Transportregistrations move - custom transports stay wherever you routed them unless you mimic that split yourself.
Ordering rule: transports are scanned registration order, first Match wins. See Routers & transports.
Where to intervene (extension points)
Pick the cheapest layer - don’t put HTTP concerns inside resolvers unless you truly need to.
| Need | Prefer | Nuance |
|---|---|---|
CORS, auth headers, JWT on ctx | Host-router middleware wrapping the final http.Handler | Runs before transports; propagate with r.WithContext. |
| New wire format or bespoke gateway | Implement core.Transport | Match must stay cheap & side‑effect‑free - see Custom transport. |
| Lifecycle logging, quotas, coarse deny | WithPlugins implementing plugins.Plugin - Hooks | RequestStart can replace ctx; hooks can abort before expensive work. |
| “No mutations anonymous” blanket rule | WithOperationAuthorizer - also Authentication guide | Evaluated during document validation (alongside trusted-doc / rate‑limit knobs on server config when enabled). |
| Per-field secrecy / tenancy | WithFieldAuthorizer | Paths are GraphQL paths; rejects before coercion + resolver. |
| Deadline on every operation | WithRequestTimeout | Deadline applies inside built-in transports calling Execute / subscription setup - wire your own timeouts if you replace transports. |
Operation flow (mental checklist)
Once a subscription or query reaches the executor, the condensed story matches the Execution pipeline: Lex → Parse → Validate → Execute (+ plugin taps between stages). Streaming responses simply repeat emission per subscription event according to transport rules documented under Subscriptions and realtime guides.
If you benchmark or profile, distinguish cold parse versus steady-state workloads - see the Benchmarks page and sibling benchmark/ module in the repository for comparisons with other Go GraphQL stacks.
Related reading
- Execution pipeline - stages, caches, hooks, executor limits
- Routers & transports - path splits, WS/SSE, registration order
- Hooks & plugins - lifecycle and plugin vs HTTP middleware
- Pub/Sub - bridging mutations and subscriptions
- API reference overview - package finder
Last updated on