Skip to content

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)

  1. Client calls node(id: ID!).
  2. Viaduct decodes the Global ID into its type and internal ID components.
  3. Viaduct uses the type component to find the corresponding node resolver and call it passing the decoded Global ID as Context.id.
  4. 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)
        }
    }

View full file on GitHub

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 id returned in the response is the typed Global ID you emitted from ctx.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 (null or a typed error).

See ResolverIntegrationTest.kt and StarWarsNodeResolversTest.kt in 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 id field.

See Best Practices for the consolidated reference. For the complete node-resolver API and generated base-class reference, see the Node Resolvers developer reference.