Skip to content

Resolver Integrations

Resolvers in Viaduct form a layered model that separates entity retrieval from per-field computation. Node, field, and batch field resolvers each play a distinct role but integrate seamlessly during execution.

Standard entity pattern

Each entity type can typically implement:

  1. Node resolver for GlobalID-based retrieval via node queries.
@Resolver
class CharacterNodeResolver
    @Inject
    constructor(
        private val characterRepository: CharacterRepository
    ) : NodeResolvers.Character() {
        // Node resolvers can also be batched to optimize multiple requests
        // tag::node_batch_resolver_example[21] Example of a node resolver
        override suspend fun batchResolve(contexts: List<Context>): List<FieldValue<Character>> {
            // Extract all unique character IDs from the contexts
            val characterIds = contexts.map { it.id.internalID }

            // Perform a single batch query to get film counts for all characters
            // We only compute one time for each character, despite multiple requests
            val characters = characterIds.mapNotNull {
                characterRepository.findById(it)
            }

View full file on GitHub

  1. Batch field resolvers for expensive computed fields that benefit from batching.
@Resolver(objectValueFragment = "fragment _ on Character { id }")
class CharacterFilmCountResolver
    @Inject
    constructor(
        val characterFilmsRepository: CharacterFilmsRepository
    ) : CharacterResolvers.FilmCount() {
        override suspend fun batchResolve(contexts: List<Context>): List<FieldValue<Int>> {
            // Extract all unique character IDs from the contexts
            val characterIds = contexts.map { it.objectValue.getId().internalID }.toSet()

            // Perform a single batch query to get film counts for all characters
            // We only compute one time for each character, despite multiple requests
            val filmCounts = characterIds.associateWith { characterId ->
                characterFilmsRepository.findFilmsByCharacterId(characterId).size
            }

            // For each context gets the character ID and map to the precomputed film count
            // and return the results in the same order as contexts
            return contexts.map { ctx ->
                val characterId = ctx.objectValue.getId().internalID

View full file on GitHub

  1. Single field resolvers for lightweight computed or derived values.
@Resolver("name")
class CharacterDisplayNameResolver
    @Inject
    constructor() : CharacterResolvers.DisplayName() {
        override suspend fun resolve(ctx: Context): String? {
            // Directly returns the name of the character from the context. The "name" field is
            // automatically fetched due to the @Resolver annotation.
            return ctx.objectValue.getName()

View full file on GitHub

The entity resolution flow

  1. Query parsing: Viaduct analyzes the query and fragments to determine which types and fields are required.
  2. Node resolution: node resolvers load entities by Global ID for any node(id:) or reference field.
  3. Field resolution: Viaduct invokes field resolvers for each selected field, batching them when possible.
  4. Result assembly: all results are merged into a single GraphQL response.

This design isolates entity loading from field logic, ensures predictable performance, and enables resolver reuse across schemas and scopes.

Integration example

query {
  node(id: "Q2hhcmFjdGVyOjE=") {
    ... on Character {
      id
      name                # from Field Resolver
      homeworld { name }  # via batched field resolver
      filmCount           # aggregated by batch resolver
    }
  }
}

Execution flow for this query:

Step Resolver Responsibility
1 CharacterNodeResolver Retrieve Character entity by internal ID.
2 DisplayNameResolver Compute or format the name field.
3 HomeworldResolver Fetch related Planet; batched across all Characters.
4 FilmCountBatchResolver Compute film counts for all Characters in one call.
5 Viaduct runtime Assemble and serialize the final result tree.

Common integration pitfalls

1. Duplicated lookups

Avoid fetching the same entity in multiple resolvers. Node resolvers should load once, and related lookups should use field resolvers (batched when necessary).

2. Overfetching fragments

Limit objectValueFragment to only the fields your resolver actually uses. Overly broad fragments increase query cost and memory use.

3. Missing batching opportunities

If a field resolver executes the same repository call per parent, migrate it to a batch resolver.

4. Misaligned ID handling

Ensure all resolvers use ctx.globalIDFor(Type.Reflection, internalId) and consume IDs via ctx.id.internalID. Mixing raw IDs with Global IDs can cause type mismatches in queries.

5. Incorrect nullability handling

Return null for missing relationships when the schema field is nullable, rather than throwing exceptions.

Do and don’t

  • Do separate responsibilities: nodes fetch, fields compute, batch resolvers aggregate.
  • Do test integration flows end-to-end with actual queries.
  • Don’t mix loading logic inside field resolvers.
  • Don’t assume execution order between independent resolvers — rely on field dependencies, not sequencing.