Node Resolvers
Node resolvers provide typed lookups by Global ID. Any entity that must be fetched individually through the GraphQL node(id:) entry point should have a corresponding node resolver. Viaduct decodes the incoming Global ID, hands your resolver the internal ID via ctx.id.internalID, and expects you to return the typed GraphQL object. Keep node resolvers tiny: lookup → build → return.
Request lifecycle (node)¶
- Client calls
node(id: ID!). - Viaduct decodes the Global ID (base64 of
"<Type>:<InternalID>"). - The matching
NodeResolvers.<Type>implementation receives a typedContextwithctx.id.internalID. - Your resolver loads the record and builds the response using
Character.of(ctx) { ... }or a dedicated builder class (for example,CharacterBuilder(ctx).build(entity)).
Implementation¶
@Resolver
class FilmNodeResolver
@Inject
constructor(
private val filmsRepository: FilmsRepository,
) : NodeResolvers.Film() {
override suspend fun resolve(ctx: Context): viaduct.api.grts.Film {
val filmId = ctx.id.internalID
val film = filmsRepository.findFilmById(filmId)
?: throw IllegalArgumentException("Film with ID $filmId not found")
return FilmBuilder(ctx).build(film)
}
}
Handling "not found"¶
GraphQL's error specification gives you two reasonable choices when an entity isn't found: return null (and let node show the absence to the client) or surface a typed error. The Star Wars demo throws an IllegalArgumentException because, in this demo, an unknown ID is treated as a programming error. Most production node resolvers prefer null so that callers can branch on presence without a query failure. Whichever you pick, reserve exceptions for unexpected conditions (I/O errors, decoding failures, programming bugs).
Common patterns that pair with node resolvers¶
Lightweight builders¶
Populate only intrinsic, low-cost fields in the node resolver. Related entities (homeworld, species, films) should be resolved via field resolvers — which Viaduct can batch efficiently per request.
Stable IDs¶
Always generate IDs with ctx.globalIDFor(<Type>.Reflection, internalId) to keep them opaque and stable across module or storage backends.
Query example (client)¶
query GetNode($id: ID!) {
node(id: $id) {
... on Character {
id
name
homeworld { name } # resolved later via a field resolver (can batch)
species { name } # resolved later via a field resolver (can batch)
}
}
}
Integration testing¶
Node resolvers are most usefully exercised by integration tests that issue real node(id:) queries against a configured Viaduct instance. Verify:
- ID shape: the
idreturned in the response is the typed Global ID you emitted fromctx.globalIDFor(...). - Composability: related fields (homeworld, species, film counts) resolve via field resolvers, not by extra work inside the node resolver.
- Not-found behavior: the resolver behaves consistently with whatever convention you chose (
nullor a typed error).
See
ResolverIntegrationTest.ktandStarWarsNodeResolversTest.ktin the demo for examples of end-to-end node behavior.
Do and don’t¶
- Do keep node resolvers tiny: lookup → build → return.
- Do load a single entity by its internal ID and attach a Global ID with
ctx.globalIDFor(<Type>.Reflection, internalId). - Do lean on field resolvers for relationships and heavy logic.
- Don’t perform per-request joins here; you'll lose batching opportunities.
- Don’t leak internal IDs — always emit typed Global IDs in the
idfield.
See Best Practices for the consolidated reference. For the complete node-resolver API and generated base-class reference, see the Node Resolvers developer reference.