Batch Field Resolvers

Implementing batch field resolvers in Viaduct.

Batch field resolvers process multiple field requests in one pass, dramatically improving performance when the same field is selected across many parent objects. Viaduct guarantees the input order of contexts and expects you to return results in the same order.

Where batching fits in the execution flow

  1. The planner groups identical field selections across all matching parent objects in the operation.
  2. Viaduct calls your batchResolve(contexts: List<Context>).
  3. You perform one data fetch per unique key set (for example, character IDs).
  4. You map results back to each context and return a List<FieldValue<T>> aligned with the input order.

Minimal example (counts per character)

@Resolver(objectValueFragment = "fragment _ on Character { id }")
class CharacterFilmCountResolver : 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

        FieldValue.ofValue(filmCounts[characterId] ?: 0)
    }
}

Choosing the fragment

The objectValueFragment declares the parent fields your resolver needs. Keep it minimal — requesting only id is typical for lookup scenarios. If you require additional, cheap fields (for example, name for formatting), add them here so they are available on ctx.objectValue without extra work.

@Resolver(objectValueFragment = "fragment _ on Character { id }")
class CharacterFilmCountResolver : CharacterResolvers.FilmCount() {

Implementing batch resolvers in node resolvers

Node resolvers can also be batched. The pattern is similar, but you receive a list of GlobalIDs instead of Contexts. You can use GlobalID.toInternalID() to extract your internal ID

   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)
       }

       // For each context gets the character ID and map to the viaduct object
       return contexts.map { ctx ->
           val characterId = ctx.id.internalID
           characters.firstOrNull { it.id == characterId }?.let {
               FieldValue.ofValue(
                   CharacterBuilder(ctx).build(it)
               )
           } ?: FieldValue.ofError(IllegalArgumentException("Character not found: $characterId"))
       }
   }
}

For a node resolver you can only implement batchResolve or resolve — not both.

Error handling and nullability

  • Return a sensible default or FieldValue.ofNull() for missing items (match schema nullability).
  • Avoid throwing for “not found” cases — reserve exceptions for unexpected failures.
  • Ensure the size of the returned list matches contexts.size exactly.

When to batch (and when not to)

Batch when:

  • The same field is selected for many parent objects in a single operation.
  • The data access layer supports bulk retrieval by keys (IDs).
  • You would otherwise repeat the same lookup per parent (N + 1 pattern).

Prefer single resolvers when:

  • Only a handful of parents are involved.
  • The logic is strictly local and cheap for each parent.

Example query that benefits from batching

Schema definition:

allCharacters(limit: Int): [Character]
  @resolver
  @backingData(class: "com.example.starwars.modules.filmography.characters.queries.AllCharactersQueryResolver")

Executed query:

query {
  allCharacters(limit: 100) {
    filmCount  # resolved by FilmCountBatchResolver in one grouped call
  }
}

Do and don’t

  • Do request only the parent fields you need in objectValueFragment.
  • Do deduplicate keys before hitting the data layer.
  • Do return results in the same order as the input contexts.
  • Don’t perform per-context DB calls inside batchResolve.
  • Don’t allocate large intermediate structures unnecessarily — map directly back to contexts.