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 (using the default @resolver). Batching strategies are covered in Batch Resolvers.

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.getObjectValue().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 : FilmResolvers.Summary() {
    override suspend fun resolve(ctx: Context): String? {
        // Access the source Film from the context
        val film = ctx.getObjectValue()
        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 : CharacterResolvers.IsAdult() {
    override suspend fun resolve(ctx: Context): Boolean? {
        // Example rule: consider adults those older than 21 years
        return ctx.getObjectValue().getBirthYear()?.let {
            age(it) > 21
        } ?: false
    }

    private fun age(value: String): Double {
        return value.dropLast(3).toDouble()
    }
}

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("id")
class FilmPlanetsResolver
    @Inject
    constructor(
        private val characterRepository: CharacterRepository,
        private val filmCharactersRepository: FilmCharactersRepository
    ) : FilmResolvers.Planets() {
        override suspend fun resolve(ctx: Context): List<Planet?>? {
            val filmId = ctx.getObjectValue().getId().internalID

            val characterIds = filmCharactersRepository.findCharactersByFilmId(filmId)

            val planetIds = characterIds.mapNotNull { characterRepository.findById(it)?.homeworldId }.toSet()

            return planetIds.map {
                ctx.nodeRef<Planet>(it)
            }
        }

View full file on GitHub

3) Argument-driven behavior

The format argument controls the presentation of the returned string.

@Resolver(
    """
    fragment _ on Character {
        name
        birthYear
        eyeColor
        hairColor
    }
    """
)
class CharacterFormattedDescriptionResolver : CharacterResolvers.FormattedDescription() {
    override suspend fun resolve(ctx: Context): String? {
        val character = ctx.getObjectValue()
        val name = character.getName() ?: "Unknown"
        val format = ctx.arguments.format

        return when (format) {
            "detailed" -> {
                val birthYear = character.getBirthYear()
                val eyeColor = character.getEyeColor()
                val hairColor = character.getHairColor()

                buildString {
                    append(name)
                    birthYear?.let { append(" (born $it)") }
                    if (eyeColor != null || hairColor != null) {
                        append(" - ")
                        eyeColor?.let { append("$it eyes") }
                        if (eyeColor != null && hairColor != null) append(", ")
                        hairColor?.let { append("$it hair") }
                    }
                }
            }

            "year-only" -> {
                val birthYear = character.getBirthYear()
                birthYear?.let { "$name (born $it)" } ?: "$name (birth year unknown)"
            }

            "appearance-only" -> {
                val eyeColor = character.getEyeColor()
                val hairColor = character.getHairColor()

                buildString {
                    append(name)
                    if (eyeColor != null || hairColor != null) {
                        append(" - ")
                        eyeColor?.let { append("$it eyes") }
                        if (eyeColor != null && hairColor != null) append(", ")
                        hairColor?.let { append("$it hair") }
                    }
                }
            }

            else -> name // default format - just name
        }
    }
}

View full file on GitHub

What about heavy lookups?

Field resolvers are intended to be cheap. When a single field genuinely needs an expensive load, push the work out of the resolver:

  • Move it into the data layer so the cost is bounded by your repository or service.
  • Convert to a batch field resolver when more than a handful of parents will request the field in one operation — this is almost always the right answer for relationship loads.
  • Delegate to a service behind a small façade and keep the resolver thin. The resolver becomes a translator between Viaduct's Context and your service contract.

If you find yourself doing significant work inside resolve, that's a signal to revisit which layer should own the load.

Error handling and nullability

GraphQL itself dictates most of the rules here, so the resolver-side guidance is short:

  • Prefer returning null for missing or unknown values when the schema field is nullable. See the GraphQL error spec for how clients see partial results.
  • 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.

Do and don’t

  • Do keep it light: perform inexpensive logic and at most a single lookup.
  • Do defer relationships: if many parents need the same relationship, implement a batch field resolver instead.
  • Do request only the parent fields you need via objectValueFragment, and rely on getters already available on ctx.objectValue.
  • Don’t loop lookups inside resolve when the query can select many parents — that's a hidden N+1.
  • Don’t put heavy business logic or multi-step orchestration inside a field resolver; push it to a service or batch resolver.

See Best Practices for the consolidated reference.

For the full field-resolver API, generated base-class reference, and advanced patterns, see the Field Resolvers developer reference.