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 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:

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 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").

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