Skip to content

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.

crates/features/src/posts/graphql/resolver.rs
#[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 argumentsid 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:

LayerOn an #[entity]
Guard chainruns — resolver-scope and method-scope, GraphqlGuard-attested
Access posturemandatory#[authorize(Action, Entity)] or #[public], no posture ⇒ compile error
Argument pipesrun — Valid<T> / Piped<P, T> on the key arguments
Response maskruns — 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 Result return 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 = Service is refused — it answers NOT_FOUND for an absent row and FORBIDDEN for a withheld one, which by key is an existence oracle; load the row in the body and answer None for both;
  • #[entity(key = "…")] is refused — the @key is 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.

crates/features/src/graphql/module.rs
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. @key appears on every federated type, and _service / _entities are stripped from the export — which is what the Apollo spec asks of a subgraph schema. If your app commits schema.graphql, expect it to move in both directions in that first diff.
  • The directives are declared even with no entity yet, so _service answers for a subgraph that has not written its first key. (_entities does 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 overlapid 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").

_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 _entities operation, 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.

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.

  • 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.