Federation
Serve this schema as an Apollo subgraph: an entity resolved by reference, gated like every other operation, behind a router and not on the internet.
A subgraph is a schema a router composes with others. The router asks it
two things a client never does: what does your schema look like (_service)
and give me these objects by their keys (_entities). Turn it on with one
config flag, write one new kind of operation, and the schema answers both.
#[operations]impl PostsResolver { #[entity] #[authorize(Read, PostEntity)] async fn find_post_by_id(&self, id: Uuid) -> Result<Option<Post>> { match self.svc.access(Action::Read, id).await? { Access::Found(post) => Ok(Some(Post::from(post))), Access::Denied | Access::Missing => Ok(None), } }}Denied and Missing answer the same thing on purpose: a field the router
addresses by key must not say which keys exist.
That is the whole surface. The @key the router matches on is inferred from
the resolver’s own arguments — id here — so an entity declares its key
once, where it is resolved, and #[expose] never carries a federation key of
its own.
An entity is an operation, and it owes what one owes
Section titled “An entity is an operation, and it owes what one owes”_entities is a field on the Query root that the router calls with
references — {__typename, <key fields>} — for objects the client never
named. Nothing in the document a client reads mentions your entity resolver, so
it is the one role where a forgotten gate is invisible.
It is therefore a #[query] in every respect that matters:
| Layer | On an #[entity] |
|---|---|
| Guard chain | runs — resolver-scope and method-scope, GraphqlGuard-attested |
| Access posture | mandatory — #[authorize(Action, Entity)] or #[public], no posture ⇒ compile error |
| Argument pipes | run — Valid<T> / Piped<P, T> on the key arguments |
| Response mask | runs — the entity is masked against the caller’s ability like any other row |
Six things it refuses that a #[query] does not, each a named compile error:
- a
Resultreturn is required — the guard chain is only emitted where a denial has somewhere to go, and a chain compiled out here is invisible in the schema and on the wire; bind = Serviceis refused — it answersNOT_FOUNDfor an absent row andFORBIDDENfor a withheld one, which by key is an existence oracle; load the row in the body and answerNonefor both;#[entity(key = "…")]is refused — the@keyis read off the method’s own arguments, so declaring it would be declaring it twice;- a
#[graphql(…)]of the method’s own is refused — async-graphql reads the first one on a method, and the decorator has to emit#[graphql(entity)]there, so yours would silently take its place and the method would quietly stop being an entity resolver; - the method must be
async; - and it must take at least one argument, since the arguments are the key.
Two more are decided at boot, because only the built schema holds the
fact. An #[entity] whose resolved type carries no @key — a list, a
scalar, a union, anything async-graphql does not key — fails the boot naming
the method and the type. So do two resolvers whose key shapes overlap
(below).
#[entity] is a role, not a modifier: it cannot be combined with #[mutation]
or #[subscription], because _entities lives on the Query root and no
other root has it. Writing both is a compile error naming both.
Turning it on
Section titled “Turning it on”GraphqlModule::for_root(GraphqlConfig { federation: true, ..GraphqlConfig::default()})or NESTRS_GRAPHQL__FEDERATION=true — the same dual path every module config
has. Default off.
The flag does not switch the federation surface on — the #[entity] does.
async-graphql serves _service and _entities the moment one resolver has
registered its keys, whatever the flag says. So the two are declared together or
not at all: an #[entity] with federation: false fails the boot, naming
the resolver. Without that check the flag would be a comment, and an app that
added one entity would publish its own SDL while its config said it was not a
subgraph.
What the flag itself decides:
- The committed SDL becomes the subgraph form.
@keyappears on every federated type, and_service/_entitiesare stripped from the export — which is what the Apollo spec asks of a subgraph schema. If your app commitsschema.graphql, expect it to move in both directions in that first diff. - The directives are declared even with no entity yet, so
_serviceanswers for a subgraph that has not written its first key. (_entitiesdoes not exist until one does — there would be nothing for it to return.)
One key shape, one entity resolver: two claims on the same shape fail the
boot, naming both. An entity is addressed by @key rather than by name, so
the duplicate leaves no clashing operation in the SDL — only a doubled @key,
and a router reaching whichever body linked first, access posture included. The
check applies inside a resolver too — two #[entity] methods in one impl
claiming one shape is the same duplicate, written the way it is easiest to
write.
A type may carry several @keys — as long as the shapes are disjoint.
Keying Post by id in one resolver and by slug in another is legal and both
stay reachable: no representation can satisfy both matchers, so which resolver
answers is decided by the reference, not by link order. Shapes that overlap —
id here and id tenant there, or the same fields in a different order —
fail the boot naming both claimants and both shapes.
Wherever they are declared, including on one resolver. A reference carrying
id and tenant satisfies the id matcher too, so one of the two bodies is
unreachable and the posture that answers is the other one’s — and nothing orders
them by anything you wrote. Across resolvers _entities takes whichever linked
first. Within one resolver async-graphql sorts its matchers by argument
count, not key arity, so a short key with a non-key argument beside it outranks
a longer key without one; at equal counts the sort is stable and declaration
order decides. This is narrower than Apollo, which allows a type several
overlapping @keys. The narrowing is deliberate: what the router picks decides
which #[authorize] runs, and neither this framework nor async-graphql orders
those two by anything declared.
A nested selection is one field: @key(fields: "id organization { id }") selects
id and organization, so it is disjoint from @key(fields: "slug") and
overlaps @key(fields: "id").
What guards the two fields a router calls
Section titled “What guards the two fields a router calls”_entities and _service are resolved by async-graphql’s own Query root,
above the merged root this framework composes — so the chain #[operations]
emits inside a resolver body is not where they are gated. The app-wide guard
pool runs in front of both, once per field, through a schema extension
GraphqlModule installs. A use_guards_global([AuthnGuard]) therefore refuses
_service and _entities exactly as it refuses a #[query], with the same
native GraphQL error frame.
Two consequences worth reading twice:
- The pool is the whole chain there, because a federation field belongs to no
resolver. The router calls it on the schema, so there is no
#[use_guards]scope to compose and no posture to read. An#[entity]’s own#[use_guards]and#[authorize]/#[public]still run in its body, per reference. - A pooled guard runs once per
_entitiesoperation, not once per representation. The gate runs it at the field; the#[entity]bodies reached through it compose everything except the pool, precisely so the multiplier on a pooled check is not a number the caller picks.
What is not here
Section titled “What is not here”Composition. Merging subgraphs into a supergraph is the router’s job, done
by Apollo’s own tooling (rover). NestRS ships the subgraph half — the schema,
the entity resolvers, and the guarantee that both are gated (above) — and takes
no dependency to do the router’s work.
Going further
Section titled “Going further”- Queries and mutations — the operation shape an
#[entity]follows in every respect but how it is reached. - Authorization — what the mandatory posture gates and masks.
- Configuration — the rest of the
NESTRS_GRAPHQL__*surface, including the introspection switch this one is not covered by.