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.getObjectValue()and any arguments inctx.arguments(Kotlin) orcontext.getArguments()(Java). - The resolver returns a value for the field (or
null) through a suspending function in Kotlin orCompletableFuture<T>in Java, 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¶
Java annotations come from viaduct.java.api.annotations.Resolver. The Micronaut demo also annotates resolver implementations with @Prototype so its injector can construct them. Generated Java getters return nullable values for nullable fields; handle those values before using them.
A typical resolver extends the generated base class for the field and overrides resolve:
@Resolver("name")
@Prototype
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().getNameOrThrow()
}
}
package com.example.starwars.modules.filmography.characters.resolvers;
/** Demonstrates shorthand required-selection syntax. */
@Resolver(objectValueFragment = "name")
@Prototype
public final class CharacterDisplayNameResolver extends CharacterResolvers.DisplayName {
@Override
public CompletableFuture<String> resolve(Context context) {
return CompletableFuture.completedFuture(context.getObjectValue().getName());
}
}
Access to arguments¶
Arguments declared in the schema are available via ctx.arguments in Kotlin or context.getArguments() in Java, with the appropriate generated getters:
@Resolver("title episodeID director")
@Prototype
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.getEpisodeIDOrThrow()}: ${film.getTitleOrThrow()} (Directed by ${film.getDirectorOrThrow()})"
}
}
package com.example.starwars.modules.filmography.films.resolvers;
/** Computes a concise episode summary. */
@Resolver(objectValueFragment = "title episodeID director")
@Prototype
public final class FilmSummaryResolver extends FilmResolvers.Summary {
@Override
public CompletableFuture<String> resolve(Context context) {
viaduct.java.grts.Film film = context.getObjectValue();
return CompletableFuture.completedFuture(
"Episode "
+ film.getEpisodeID()
+ ": "
+ film.getTitle()
+ " (Directed by "
+ film.getDirector()
+ ")");
}
}
Examples¶
1) Simple computed value¶
@Resolver(
"""
fragment _ on Character {
birthYear
}
"""
)
@Prototype
class CharacterIsAdultResolver : CharacterResolvers.IsAdult() {
override suspend fun resolve(ctx: Context): Boolean? {
// Example rule: consider adults those older than 21 years
return ctx.getObjectValue().getBirthYearOrThrow()?.let {
age(it) > 21
} ?: false
}
private fun age(value: String): Double {
return value.dropLast(3).toDouble()
}
}
package com.example.starwars.modules.filmography.characters.resolvers;
/** Computes adulthood from a BBY/ABY birth-year value. */
@Resolver(objectValueFragment = "fragment _ on Character { birthYear }")
@Prototype
public final class CharacterIsAdultResolver extends CharacterResolvers.IsAdult {
@Override
public CompletableFuture<Boolean> resolve(Context context) {
String birthYear = context.getObjectValue().getBirthYear();
boolean isAdult =
birthYear != null
&& Double.parseDouble(birthYear.substring(0, birthYear.length() - 3)) > 21;
return CompletableFuture.completedFuture(isAdult);
}
}
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")
@Prototype
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().getIdOrThrow().internalID
val characterIds = filmCharactersRepository.findCharactersByFilmId(filmId)
val planetIds = characterIds.mapNotNull { characterRepository.findById(it)?.homeworldId }.toSet()
return planetIds.map {
ctx.ref<Planet>(it)
}
package com.example.starwars.modules.filmography.films.resolvers;
/** Resolves the unique homeworlds represented in a film's cast. */
@Resolver(objectValueFragment = "id")
@Prototype
public final class FilmPlanetsResolver extends FilmResolvers.Planets {
private final CharacterRepository characterRepository;
private final FilmCharactersRepository filmCharactersRepository;
@Inject
public FilmPlanetsResolver(
CharacterRepository characterRepository, FilmCharactersRepository filmCharactersRepository) {
this.characterRepository = characterRepository;
this.filmCharactersRepository = filmCharactersRepository;
}
@Override
public CompletableFuture<List<Planet>> resolve(Context context) {
String filmId = context.getObjectValue().getId().getInternalID();
LinkedHashSet<String> planetIds = new LinkedHashSet<>();
filmCharactersRepository
.findCharactersByFilmId(filmId)
.forEach(
characterId -> {
var character = characterRepository.findById(characterId);
if (character != null && character.homeworldId() != null) {
planetIds.add(character.homeworldId());
}
});
return CompletableFuture.completedFuture(
planetIds.stream()
.map(id -> context.ref(context.globalIDFor(Type.ofClass(Planet.class), id)))
.toList());
}
}
3) Argument-driven behavior¶
The format argument controls the presentation of the returned string.
@Resolver(
"""
fragment _ on Character {
...CharacterIdentityFields
...CharacterAppearanceFields
}
"""
)
@Prototype
class CharacterFormattedDescriptionResolver : CharacterResolvers.FormattedDescription() {
override suspend fun resolve(ctx: Context): String? {
val character = ctx.getObjectValue()
val name = character.getNameOrThrow() ?: "Unknown"
val format = ctx.arguments.format
return when (format) {
"detailed" -> {
val birthYear = character.getBirthYearOrThrow()
val eyeColor = character.getEyeColorOrThrow()
val hairColor = character.getHairColorOrThrow()
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.getBirthYearOrThrow()
birthYear?.let { "$name (born $it)" } ?: "$name (birth year unknown)"
}
"appearance-only" -> {
val eyeColor = character.getEyeColorOrThrow()
val hairColor = character.getHairColorOrThrow()
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
}
}
}
package com.example.starwars.modules.filmography.characters.resolvers;
/** Formats character details using two reusable named fragments. */
@Resolver(
objectValueFragment =
"""
fragment _ on Character {
...CharacterIdentityFields
...CharacterAppearanceFields
}
""")
@Prototype
public final class CharacterFormattedDescriptionResolver
extends CharacterResolvers.FormattedDescription {
@Override
public CompletableFuture<String> resolve(Context context) {
viaduct.java.grts.Character character = context.getObjectValue();
String name = character.getName() == null ? "Unknown" : character.getName();
String format = context.getArguments().getFormat();
return CompletableFuture.completedFuture(
switch (format) {
case "detailed" ->
appendAppearance(appendBirthYear(name, character.getBirthYear()), character);
case "year-only" ->
character.getBirthYear() == null
? name + " (birth year unknown)"
: appendBirthYear(name, character.getBirthYear());
case "appearance-only" -> appendAppearance(name, character);
default -> name;
});
}
private String appendBirthYear(String value, String birthYear) {
return birthYear == null ? value : value + " (born " + birthYear + ")";
}
private String appendAppearance(String value, viaduct.java.grts.Character character) {
if (character.getEyeColor() == null && character.getHairColor() == null) {
return value;
}
StringBuilder result = new StringBuilder(value).append(" - ");
if (character.getEyeColor() != null) {
result.append(character.getEyeColor()).append(" eyes");
}
if (character.getEyeColor() != null && character.getHairColor() != null) {
result.append(", ");
}
if (character.getHairColor() != null) {
result.append(character.getHairColor()).append(" hair");
}
return result.toString();
}
}
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.getObjectValue(). - 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.