GraphQL Federation on Go: When a Monolith Schema Should Split
Federation is a team-scaling tool, not a performance upgrade. The signals that justify splitting a Go GraphQL schema, what it costs, and the migration order.
Someone on your team has started saying "we should federate this." Usually the trigger is a merge conflict in schema.graphqls, or a release that waited three days for an unrelated feature to land.
Both are real problems. Federation solves one of them. The question is which.
TL;DR Federation is an organizational tool, not a performance tool. Split the graph when two or more teams need to ship parts of the schema without coordinating a release, and not before. The cost is a gateway hop, entity resolution round trips, composition in CI and a harder debugging story. On Go, plan for gqlgen's entity resolvers to be an N+1 machine unless you batch them deliberately.
The Only Reason That Holds Up
Federation lets several teams own several subgraphs and publish them independently, while consumers see one schema. That is the whole value proposition. Everything else people hope for from it is either available without federation or made worse by it.
If a single team owns the entire schema, a federated graph gives that team more moving parts and nothing else. Module boundaries inside one gqlgen service, enforced by a linter and a code owners file, buy the same clarity for none of the operational cost.
If the graph is slow, federation will not fix it. A slow resolver is slow in a subgraph too, and now it has a network hop in front of it. Fix the data access first.
Signals That You Are Ready
| Signal | What it looks like | What it is not |
|---|---|---|
| Release coupling | A schema change is blocked waiting for an unrelated team's deploy | One team wanting a faster CI |
| Ownership contention | Two teams edit the same type in the same week and neither can say who decides | A big schema file |
| Divergent runtime needs | One subdomain needs different scaling, a different datastore, or sits inside a compliance boundary the rest does not | One endpoint that is slow |
| Divergent lifecycle | A subdomain is being replaced or acquired and needs to move independently | A refactor you have been postponing |
| Boundary already exists | The teams already have separate services, on-call rotations and databases | A shared repo with folders |
Two or more of these, sustained over a quarter, is a real case. One of them, once, is a code review problem.
What Federation Costs
Say it out loud before you commit, because these costs are permanent and the benefit is only realized if the teams actually split.
- A gateway hop on every request. One more process, one more deploy, one more thing that can be the reason the site is down at 2am.
- Sequential entity resolution. When subgraph B needs keys that only subgraph A can produce, the router cannot parallelize those fetches. A query crossing three subgraphs in a dependency chain costs three sequential round trips before the first byte reaches the client.
- Composition in CI. Every subgraph pull request has to compose against the current supergraph or the whole graph breaks. That is a new build step, a new failure mode and a new coordination point, which is the thing you were trying to remove.
- Harder debugging. A single trace now spans the router and several services. Without distributed tracing wired through from day one, "why was this query slow" becomes an afternoon.
- Two schema languages in your head. Federation directives are a dialect. Every engineer touching the graph has to know what
@key,@shareable,@external,@requiresand@overridemean, and what happens when they are wrong.
What Should Stay Monolithic
Not everything wants an owner.
Keep authentication, session context and the viewer type in one place. A federated graph where three subgraphs each have an opinion about who the caller is will eventually disagree, and the disagreement will be a security finding.
Keep small reference data (currencies, countries, plan tiers) in whichever subgraph already owns it, and let others declare it @external rather than duplicating it. A value type copied into four subgraphs is four places to fix a typo.
Keep the low-traffic administrative corner of the schema wherever it is. Splitting a subdomain that nobody contends over adds a service and a rotation for no gain.
The Migration Order
The order matters more than the tooling. Each step below is independently shippable and independently revertible.
- Freeze the naming rules. Write down pluralization, nullability defaults, error conventions and pagination style, and put a schema linter in CI. Federation composition failures are usually naming disputes wearing a costume.
- Put the router in front of the existing service, unchanged. One subgraph, one router. Nothing about the schema changes. This flushes out the operational work (routing, timeouts, tracing, health checks, client migration) while a rollback is still one config line.
- Pick the first subgraph by contention, not by size. The subdomain with the most cross-team edits and the fewest inbound references leaves first. Resist starting with the interesting one.
- Define the entity keys before you move code. A
@keymust be stable, non-null, and resolvable in every subgraph that references the entity. Keys derived from mutable data (email, slug) will hurt you at exactly the wrong moment. - Move queries first, mutations last. Read paths are easy to run in parallel and compare. Write paths carry transactions, and a transaction split across two subgraphs is a distributed transaction whether or not you call it one.
- Use
@overrideto shift field ownership incrementally, then delete the old resolver once traffic has moved and the dashboards agree. - Delete the shims. Migrations that skip this step leave a permanently federated monolith: all the cost, none of the independence.
flowchart TD
Start["One gqlgen service"]
Q1{"Two or more teams blocked on each other's releases?"}
Q2{"Do they already own separate services and data?"}
Mono["Modularize inside one service"]
Prep["Split the services first"]
Fed["Router in front, then extract by contention"]
Start --> Q1
Q1 -->|"No"| Mono
Q1 -->|"Yes"| Q2
Q2 -->|"No"| Prep
Q2 -->|"Yes"| FedGo Specifics That Bite
gqlgen speaks Federation v2, but you have to ask. The federation block in gqlgen.yml needs version: 2; without it you get v1 semantics and a composition error the first time someone writes @shareable. Check which v2 directives your gqlgen version actually emits before you design a boundary around one of them.
Entity resolution is an N+1 generator by default. gqlgen generates one FindTypeByID call per representation in the _entities request. A router asking for fifty products calls your resolver fifty times, and if each one opens a database query you have built a fan-out amplifier with a public endpoint in front of it. The fix is the @entityResolver(multi: true) directive, which generates a FindManyTypesByIDs resolver that receives the whole batch. Do this on every entity that appears in a list.
Dataloaders are not included. gqlgen ships no batching layer; vikstrous/dataloadgen and graph-gophers/dataloader are the usual choices. Two rules: construct loaders per request in middleware and put them in the context, never as a package-level singleton, and make the cache key include the tenant. A process-wide loader cache in a multi-tenant graph is a cross-tenant data leak, and it will pass every test you have because your tests use one tenant.
Nullability propagates across the gateway. A non-null field on an entity that a downstream subgraph cannot resolve nulls out the parent object, and in a non-null list it can null the whole list. Be generous with nullability on federated boundaries. The alternative is one degraded service taking out an unrelated screen.
The subgraphs are not public, but assume they are. Subgraphs must enforce authorization themselves, on top of whatever the router does. The _entities field is a general purpose object loader: anything reachable by key is reachable by anyone who can reach the port. Network policy plus per-request auth in the subgraph, not one or the other.
Pick the router deliberately. Apollo Router is the reference implementation and is written in Rust; Cosmo Router is a Go implementation of the same Federation v2 contract. A Go team that wants to read and patch its own gateway should weigh that. What matters more is that whichever you choose supports persisted queries, depth and complexity limits, and OpenTelemetry traces that stitch to your subgraph spans.
How I Approach It
I start by asking who is blocked, not what is slow. If the answer is "one team, on itself," the engagement is a schema and module review, and it ends with a linter and a code owners file. That is a cheaper, better outcome than a supergraph, and I would rather say so in week one than build the thing you asked for.
If the answer is genuinely several teams, I map entity ownership before anything else: which type belongs to whom, what its key is, and which fields cross a boundary. That map is the design. The gateway configuration and the gqlgen wiring follow from it in a few days. Getting the map wrong costs a year.
Then we sequence it so every step is reversible, and the first thing we ship is the router in front of the graph you already have.