A schema gateway for services. A service registers with a .proto, an
OpenAPI spec or a GraphQL endpoint. Every client gets the same
services as GraphQL, gRPC and REST/OpenAPI, with typed exports for its
own codegen tools. Because the gateway reads the schemas, it also
knows each service's versions, who calls which version, and which
changes break a caller.
Services and their callers rarely want the same thing:
- Formats differ. The service team writes gRPC; the browser team wants GraphQL; a partner wants REST with an OpenAPI file. Each pair ends up with a hand-written translation layer.
- Nobody knows who calls what. So old endpoints are never removed, and nobody can say whether a change is safe.
- Breaking changes ship by accident. A renamed field reaches production before the client that used it is rebuilt.
gwag sits between them and works from the schemas:
- One registration, three surfaces. GraphQL, gRPC (Connect, gRPC-Web) and HTTP/JSON for every registered service, plus schema exports (SDL, FileDescriptorSet, OpenAPI) for graphql-codegen, protoc, openapi-typescript and the rest.
- Versions as data.
unstable,stableand numberedvNper service; an oldervNis marked@deprecatedas soon as a newer one registers, and that mark reaches generated clients. - Callers are counted. Usage per caller and version, a dependency graph, declared external consumers. A version that something still depends on cannot be retired.
- CI knows what breaks.
gwag schema diff --strictand frozen snapshots fail the build on a breaking change.
Two ways to run it:
gat: a library inside one Go binary. Your huma handlers serve REST as before; gat adds GraphQL and gRPC from the same handlers. No second process.gwag: a gateway process (or 3+ node cluster on embedded NATS) in front of many services, which register at runtime over a gRPC control plane. Admin UI, auth, metrics, pub/sub.
With Docker, from a checkout (the first run builds the image):
bin/gwag deploy up --platform docker-compose # add --replicas 3 for a cluster
bin/gwag deploy login --platform docker-compose # saves the admin token to .gw/
curl -s -X POST -H 'content-type: application/json' localhost:8080/api/graphql \
-d '{"query":"{ __schema { queryType { name } } }"}'bin/gwag services list and bin/gwag peer list now work. Without Docker,
bin/gwag up runs one node as a process. Kubernetes, minikube and ECS
files, replica rules and what is untested: docs/deploy.md.
A gateway with two services and three client surfaces:
cd examples/multi && ./run.sh # gateway + greeter + library (both proto)
curl -s -X POST localhost:8080/api/graphql \
-d '{"query":"{ greeter { hello(name:\"world\") { greeting } } }"}'
# → {"data":{"greeter":{"hello":{"greeting":"Hello, world!"}}}}Every service registered with gwag. Then the environment is known:
- What runs: every service, version and instance, in every environment.
- Who calls what: usage per caller and version, the dependency graph, consumers outside the gateway declared with a review date.
- What each service needs: its
Dependsand eventSubscribes, checked before it starts. - What a change breaks: known in CI, before it ships.
With that, teams change things quickly without breaking each other: CI refuses a breaking change, an old version stays as adapters until its callers move, the gateway refuses to retire a version that is still used, and gwag itself upgrades without downtime.
Nobody starts there. Most start with one service, with a tangle of existing services, or with another tool that no longer fits. Each path below ends in the same place.
The same four steps for a new service and for one that runs today:
gwag service init --spec api/billing.json --url /openapi.json --health /healthz # writes gwag.yaml
gwag service check --url http://localhost:8080 --gateway staging-gw:50090 # in CI
# deploy: the service registers from gwag.yaml, or the gateway pulls it:
gwag services add --profile-url http://legacy:8080/.well-known/gwag.json
gwag service wait --service billing:v1 --green --timeout 2m # gate promotionThe profile says what the service provides, depends on and listens
on. The gateway routes to an instance only when it is green: its
address and health URL answer, the spec it serves equals the spec it
registered, and its dependencies are present. A red instance gets no
traffic, and the reason is in the admin UI, the API and the metrics.
docs/integrate.md.
A new project: you write the service and its clients, often alone. Use gat: write each handler once as a huma operation, and every consumer in your stack takes the surface that suits it.
g, _ := gat.New()
gat.Register(api, g, huma.Operation{ /* ... */ }, listProjects) // drop-in for huma.Register
gat.Register(api, g, huma.Operation{ /* ... */ }, getProject)
gat.RegisterHuma(api, g, "/api") // /api/graphql + /api/schema/*
gat.RegisterGRPC(mux, g, "/api/grpc") // Connect / gRPC / gRPC-Web- The browser uses GraphQL, typed with graphql-codegen off
/api/schema/graphql. - Other services use gRPC, with clients from
/api/schema/proto. - Scripts and partners use plain REST and the OpenAPI document.
When you must break the API, keep the old version in the same
process: gat.RegisterHumaVersion serves v1 and v2 on one
schema, with v1 marked deprecated. When the binary outgrows itself,
register it with a gwag gateway; the surfaces do not change.
docs/gat.md— mental model, routes, streams, several versions in one processexamples/gat/— huma + gat server, React UI with graphql-codegen
Trap: huma skips unexported fields when it finds request parameters, and an embedded field takes its type's name, so embedding an unexported type silently drops every
query/path/header/cookieparameter in it. gat refuses to mount and names each lost parameter; plain huma does not warn. Details.
The common case in a company: services in different formats, vendor APIs and Kafka topics nobody fully mapped, no version rules, and no record of who calls what. Put a gateway in front first; change the services later.
-
Register everything as it is. Point the gateway at the existing
.protoand OpenAPI files, GraphQL endpoints and MCP servers, yours and other teams'. No code change in those services.gw := gateway.New() gw.AddProto("./protos/auth.proto", gateway.To("authsvc:50051")) gw.AddOpenAPI("./billing-openapi.json", gateway.As("billing"), gateway.To("https://billing.internal")) gw.AddGraphQL("https://catalog.internal/graphql", gateway.As("catalog"))
Or from the command line (
gwag --proto file=host:port,gwag serve --openapi vendor.yaml --to URL), or a few lines ofcontrolclientin services you own. -
Type what you call. Every client gets typed code from the gateway's exports. Hide fields, fill arguments from the caller's context, set outbound auth per upstream. For Kafka,
gwag genwrites typed publish and listen code over the corp topics, with types imported from the corp Schema Registry. -
See what exists and who uses it. The admin UI and API list every service, version and instance, the dependency graph, and usage per caller. Consumers outside the gateway are declared as external dependents with a review date.
-
Version the ones that change. Register
v2next tov1; keepv1alive as adapters overv2in the same binary for clients that cannot move; retire it when nothing depends on it.
docs/services.md—AddProto/AddOpenAPI/AddGraphQL/AddMCP,controlclient, optionsdocs/middleware.md— transforms, injection, outbound headersdocs/quota.md— one way out to a rate-limited vendor API: cluster-wide limit, 429 pause, quotas per caller, usage reportdocs/events-kafka.md— Kafka, Schema Registry, own / import / shadowdocs/lifecycle.md— register, version, deprecate, retire; external dependents; sunset ruledocs/versioning.md— breaking changes with hard-to-move clients: adapters, frozen snapshots, contract testsexamples/versioning/—ordersv1 + v2 in one gat service, Go and TS clients, walkthrough to retiring v1
You have a gateway or translation layer that does part of the job. gwag can sit next to it or in front of it, so services move one at a time.
| You run | With gwag |
|---|---|
| Apollo Federation, Hive Gateway, graphql-mesh, Cosmo | Register the existing graph's endpoint with AddGraphQL as one namespace, then move services to their own registrations one by one. gwag stitches by namespace and does not merge entities; keep Federation for types that several subgraphs extend. |
| Kong, Envoy, a cloud API gateway | Keep it at the edge for TLS and rate limits; put gwag behind it for schemas, versions and typed clients. |
| grpc-gateway, a hand-written BFF, REST-to-gRPC glue | Register the .proto; gwag serves REST and GraphQL from it. Delete the glue once its clients use the gateway. |
| Hasura | Different layer: it wraps databases. Register its GraphQL endpoint next to your services. |
| A Confluent Schema Registry | Connect to it for framing and imported types; later gwag can serve the registry API itself, with the corp ids kept in their own context. |
docs/comparison.md— gwag vs Federation, Kong, meshes, service discoverydocs/federation.md— when namespace stitching is enough and when it is notdocs/schema-registry.md— gwag as a Schema Registry; import with ids kept
The end state, kept in place. You run gwag for every team, and each environment is protected from changes that would break it.
-
Tiers per environment.
--allow-tier stable,vNon production gateways refusesunstable; a release build (BuildTag) cannot depend on another service'sunstable. -
CI gates.
gwag schema diff --strictagainst the live schema;gwag schema snapshot checkkeeps a frozen version additive-only. -
Start order. A service that declares
Depends(or eventSubscribes) waits until its dependencies are registered; a version stays until nothing depends on it. -
Who may register. App keys scoped to namespaces, with expiry for monthly rotation; admin and dev roles for operators.
-
Running the gateway. One node for development, three behind a load balancer for production; any node serves every service. What fails when a node, a service or the load balancer goes down, and how long recovery takes, is measured.
-
Upgrading gwag itself. Rolling restarts; a replica swap with cluster feature levels for version changes; or a cutover to a second cluster with replay verification, state copy and gradual switchover.
-
docs/running.md— topologies, routing with several nodes, measured failure modes, single points of failure -
docs/cluster.md— 3-node setup, node loss, rolling upgrade, replica swap, feature levels -
docs/cutover.md— second cluster: replay verify, state copy, switchover, event stream mirror -
docs/admin-auth.md— roles, boot token, AdminAuthorizer delegate -
docs/deploy.md—gwag deployfor Compose, Kubernetes, minikube, ECS
One Go binary or library import. Cluster is opt-in. Dispatch is reflection-based, so any registered service works without a build step.
Per-request overhead at 1 k rps × 15 s, loopback (gateway adds on top
of a direct dial in the matching wire format; measured May 2026,
not regenerated by bin/bench perf):
| Ingress | Source | Δp50 | Δp95 |
|---|---|---|---|
| gRPC | proto upstream | +283 µs | +336 µs |
| HTTP/JSON | OpenAPI upstream | +208 µs | +245 µs |
| GraphQL | GraphQL upstream | +344 µs | +505 µs |
Each active middleware rule on the hot path adds ~15–20 µs at p50.
Full numbers + reproduce recipe: docs/perf.md.
Head-to-head vs Hive Gateway, graphql-mesh, Apollo Router, Cosmo and
Grafbase on the same backends:
compare/comparison.md (harness + reproduce
recipe: compare/).
Embedded (gat), in-process — same huma handler, same 25-row
payload, each surface against the tool you'd otherwise hand-write it
with (no network; ratios travel, absolutes don't):
| Surface | gat | hand-written | Ratio |
|---|---|---|---|
| REST | 15.4µs (huma, unmodified) | 49.8µs (grpc-gateway) | 0.31× |
| gRPC | 16.9µs (binary codec) | 13.1µs (connect-go) | 1.29× |
| GraphQL | 102.0µs | 142.3µs (gqlgen) | 0.72× |
gat's REST path is huma — gat.Register wraps huma.Register
and adds nothing to it. GraphQL comes out ahead of gqlgen: the
executor walks a cached plan and appends response JSON straight
into a pooled buffer, where gqlgen assembles an intermediate
first. gRPC is the surface translation costs — gat allocates a
dynamicpb message per element and walks it by reflection, where
connect-go has generated structs. The row shows the binary
codec; the ratio moves with the caller's wire format and the
host. Per-caller numbers and method:
compare/gatbench/.
- Apollo Federation — entity-merging across services that share
entity identity. gwag stitches by namespace; if you actually need
entity-merging, use Federation. See
docs/federation.md. - Kong / Envoy / service meshes — route bytes; don't read schemas or emit clients.
- Hive Gateway / graphql-mesh — closest peers on multi-format
ingest. Apollo Router / Cosmo / Grafbase (single-subgraph mode)
— GraphQL-in, GraphQL-out only. Head-to-head numbers:
compare/comparison.md. - gqlgen — Go GraphQL
server framework. You write SDL, gqlgen generates resolver stubs,
you implement them. Different layer: gqlgen builds one Go service's
GraphQL surface; gwag composes existing services. Directive support
is narrow —
@deprecatedonly. Seedocs/directives.md.
Deeper breakdown: docs/comparison.md.
Services and clients
docs/services.md— registering a service:controlclient, versions, start gate, drain, app keys; in-processAddProto/AddOpenAPI/AddGraphQL/AddMCPdocs/registration-clients.md— TypeScript and Python registration clientsdocs/clients.md— proto → Go and OpenAPI → TypeScript clients from the schema exports; regenerate loopdocs/walkthrough.md— three services, three client languages, edit-redeploy-codegen cycledocs/gat.md— embedded mode; several versions in one processdocs/middleware.md—Transform/InjectType/InjectPath/InjectHeaderdocs/mcp.md— exposing the gateway to LLM agents; ingesting MCP serversdocs/uploads.md— file uploads
Versions and change control
docs/lifecycle.md— tiers, deprecate, retire, CI gate, frozen snapshotsdocs/versioning.md— breaking changes with hard-to-move clientsdocs/contract-tests.md— golden request/response tests for a versiondocs/directives.md—@deprecatedrendering; why no custom directives
Events
docs/pubsub.md—ps.pub/ps.sub, channel auth, channel→type bindingsdocs/events.md— typed event channels withgwag gen(Go, TypeScript, Python) over JetStreamdocs/events-kafka.md— Kafka backends, Schema Registry framing, own / import / shadowdocs/events-reference.md— wire format, headers, stream namesdocs/schema-registry.md— gwag as a Confluent-compatible Schema Registry
Operations
docs/running.md— topologies, routing, measured failure modes, single points of failuredocs/deploy.md—gwag deployand exported configsdocs/operations.md— health, drain, backpressure, metricsdocs/cluster.md— clustering, node loss, upgrades, feature levelsdocs/cutover.md— moving to a second cluster: replay verification, state copy, event stream mirror, switchover, load balancer switch; runnable inexamples/cutover/docs/admin-auth.md— admin roles, boot token, delegatedocs/caller-identity.md— per-caller ID and quotadocs/quota.md— rate limits, upstream 429 pauses, quotas, costs, usage reportdocs/tracing.md— OpenTelemetry wiring, span reference
Performance
docs/perf.md— throughput sweep on your hardwaredocs/comparison.md— gwag vs service discovery / mesh / Kong / Federationcompare/comparison.md— head-to-head numbers vs Hive Gateway, graphql-mesh, Apollo Router, Cosmo, Grafbasecompare/gatbench/— whatgatcosts vs gqlgen / connect-go / grpc-gateway
Stability and release
docs/stability.md— SemVer contractCHANGELOG.md— public-surface deltaRELEASE.md— cut a releasedocs/architecture.md— codebase layout, design notes, HTTP routing
SemVer contract: docs/stability.md.
Pending renames of wire-level identifiers:
- Prometheus metrics. Prefix is
go_api_gateway_*by default;WithMetricPrefix("gwag")opts in togwag_*now (v2.5). v3 makesgwag_*the default. Proto packages staygateway.*(exceptgwag.ps.v1). - GraphQL error codes. Since v2.11,
extensionsis{status, code: "not_found"}by default.WithLegacyErrorCodes()keeps the upper-caseCodename (NOT_FOUND) until v3 removes it.
Later:
- Append-mode executor wiring. The graphql-go fork
exposes
ExecutePlanAppend; the gateway-side swap is a single-function change, projected at ~3-4× end-to-end on the hot path. - Static codegen + plugin supervisor. Opt-in native-speed dispatch on top of reflection.
Open to, when a real use case pulls it in:
- WSDL / SOAP ingest for legacy services that cannot be rewritten.
- Service-account / OAuth-JWT outbound auth helpers. Composable today.
Not planned: Apollo Federation entity-merging; AsyncAPI export.
MIT. See LICENSE.
