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 parses the incoming Global ID,
hands your resolver the internal ID via ctx.id.internalID, and expects you to return the typed GraphQL object
(or null if it does not exist).
Responsibilities and boundaries
- Do: load a single entity by its internal ID.
- Do: build the GraphQL object and attach a Global ID with
ctx.globalIDFor(<Type>.Reflection, internalId). - Do: return
nullwhen the entity does not exist. - Don’t: perform cross-entity joins or heavy business logic here — use field resolvers for that.
- Don’t: throw for “not found”; prefer
nulland let GraphQL shape the result.
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 returns a typed builder (for example,
Character.Builder(ctx)).
Implementation
@Resolver
class FilmNodeResolver : 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)
}
Why return null instead of throwing?
GraphQL treats null as an expected outcome for missing nodes, avoiding unnecessary query failures and allowing the
client to branch on presence. Reserve exceptions for unexpected conditions (I/O errors, decoding failures, etc.).
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)
}
}
}
Testing checklist (in this repo)
- Happy path: returns a fully built
Characterwhen the internal ID exists. - Missing entity: returns
nullwithout throwing. - ID shape:
idfield is the typed Global ID you emitted fromctx.globalIDFor(...). - Composability: related fields (homeworld, species, film counts) resolve via field resolvers.
See the integration tests such as
ResolverIntegrationTest.ktandStarWarsNodeResolversTest.ktfor examples of end-to-end node behavior in this demo.
Do and don’t
- Do keep node resolvers tiny: lookup → build → return.
- 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.
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.