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¶
- A client query selects fields on an object (for example,
Character.name,Character.homeworld). - Viaduct plans execution and invokes resolvers for fields that require logic beyond plain data access.
- Each resolver receives a typed
Contextwith the parent object inctx.objectValueand any arguments inctx.arguments. - 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()
}
}
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()})"
}
}
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()
}
}
2) Single related lookup (non-batched)¶
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)
}
}
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
}
}
}
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
Contextand 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
nullfor 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 onctx.objectValue. - Don’t loop lookups inside
resolvewhen 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.