Skip to content

Field Resolvers

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
    @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

Access to arguments

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

@Resolver("title episodeID director")
class FilmSummaryResolver
    @Inject
    constructor() : 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()})"
        }

View full file on GitHub

Examples

1) Simple computed value

@Resolver(
    """
    fragment _ on Character {
        birthYear
    }
    """
)
class CharacterIsAdultResolver
    @Inject
    constructor() : 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

View full file on GitHub

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
    @Inject
    constructor(
        private val characterRepository: CharacterRepository
    ) : 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)

View full file on GitHub

3) Argument-driven formatting

The limit argument controls the length of the returned summary.

@Resolver
class AllCharactersQueryResolver
    @Inject
    constructor(
        private val characterRepository: CharacterRepository
    ) : 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) }
        }
    }

View full file on GitHub

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.