Field Resolvers

Implementing field resolvers in Viaduct.

Field resolvers compute values for individual fields when a simple property read is not enough. They complement node resolvers by adding business logic, formatting, and light lookups at the field level, while keeping entity fetching in the node layer.

This page focuses on single-field resolvers. Batching strategies are covered in batch_resolvers.md.

Where field resolvers fit in the execution flow

  1. A client query selects fields on an object (for example, Character.name, Character.homeworld).
  2. Viaduct plans execution and invokes resolvers for fields that require logic beyond plain data access.
  3. Each resolver receives a typed Context with the parent object in ctx.objectValue and any arguments in ctx.arguments.
  4. The resolver returns a value for the field (or null), and execution continues for the rest of the selection set.

When to use field resolvers

  • Computed fields: the value is derived from other data (for example, formatting, aggregation, mapping).
  • Cross-entity relationships (lightweight): dereference an ID already present on the parent and fetch once.
  • Business rules and presentation: apply domain rules or output formatting.
  • Argument-driven behavior: vary the result based on resolver arguments.

Avoid heavy cross-entity fan-out here. If multiple objects need the same relationship, prefer a batch resolver so the work is grouped per request.

Anatomy of a field resolver

A typical resolver extends the generated base class for the field and overrides resolve:

@Resolver("name")
class CharacterDisplayNameResolver : 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()
   }
}

Access to arguments

Arguments declared in the schema are available via ctx.arguments with the appropriate getters:

@Resolver("title episodeID director")
class FilmSummaryResolver : FilmResolvers.Summary() {
   override suspend fun resolve(ctx: Context): String? {
       // Access the source Film from the context
       val film = ctx.objectValue
       return "Episode ${film.getEpisodeID()}: ${film.getTitle()} (Directed by ${film.getDirector()})"
   }
}

Examples

1) Simple computed value

@Resolver(
   """
   fragment _ on Character {
       birthYear
   }
   """
)
class CharacterIsAdultResolver : CharacterResolvers.IsAdult() {
   override suspend fun resolve(ctx: Context): Boolean? {
       // Example rule: consider adults those older than 21 years
       return ctx.objectValue.getBirthYear()?.let {
           age(it) > 21
       } ?: false
   }

Use for one-off relationships where only a few objects are in play. If many parent objects will request the same relationship in a single operation, move this to a batch resolver.

@Resolver(
   objectValueFragment = "fragment _ on Character { id }"
)
class CharacterHomeworldResolver : CharacterResolvers.Homeworld() {
   override suspend fun batchResolve(contexts: List<Context>): List<FieldValue<Planet?>> {
       // Extract character IDs from contexts
       val characterIds = contexts.map { ctx ->
           ctx.objectValue.getId().internalID
       }

       // Batch lookup: find characters and their homeworld IDs
       val charactersById = CharacterRepository.findCharactersAsMap(characterIds)

       // TODO: Validate homeworld Id

       // Return results in the same order as contexts
       return contexts.map { ctx ->
           // Obtain character ID from current context
           val characterId = ctx.objectValue.getId().internalID

           // Lookup the character and its homeworld data
           val character = charactersById[characterId]
           val planet = character?.homeworldId?.let {
               ctx.nodeFor(ctx.globalIDFor<Planet>(it))
           }

           // Build and return the Planet object or null
           if (planet != null) {
               FieldValue.ofValue(planet)
           } else {
               FieldValue.ofValue(null)
           }
       }
   }
}

3) Argument-driven formatting

The limit argument controls the length of the returned summary.

@Resolver
class AllCharactersQueryResolver : QueryResolvers.AllCharacters() {
   override suspend fun resolve(ctx: Context): List<Character?>? {
       // Fetch characters with pagination
       val limit = ctx.arguments.limit ?: DEFAULT_PAGE_SIZE
       val characters = CharacterRepository.findAll().take(limit)

       // Convert StarWarsData.Character objects to Character objects
       return characters.map { CharacterBuilder(ctx).build(it) }
   }
}

Error handling and nullability

  • Prefer returning null for missing/unknown values.
  • Throw exceptions only for unexpected conditions (I/O failure, decoding errors).
  • Match the field nullability in the schema: if the field is non-null, ensure you always produce a value.

Performance and design guidelines

  • Keep it light: perform inexpensive logic and at most a single lookup.
  • Defer relationships: if many parents need the same relationship, implement a batch field resolver instead.
  • Avoid hidden N+1: do not loop lookups inside resolve when the query can select many parents.
  • Respect fragments: if you need parent fields, request them via the base resolver’s fragment, or rely on getters that are already available on ctx.objectValue.