Backing Data
The @backingData directive declares a field whose sole purpose is to fetch data once and share it with other resolvers on the same object. Viaduct guarantees the backing resolver runs at most once per parent object, regardless of how many sibling fields consume it.
Use @backingData when two or more field resolvers on the same type need the same upstream data and you want to avoid duplicate calls.
Schema declaration¶
Apply @backingData on a field typed as BackingData (a Viaduct built-in marker type, never exposed to clients). The class argument points to the application class that holds the fetched data:
"""
Character IDs fetched once from the film-character relationship store.
Shared by the characters and characterCountSummary resolvers so both
fields avoid making separate findCharactersByFilmId calls.
"""
castData: BackingData
@resolver
@backingData(class: "com.example.starwars.modules.filmography.films.models.FilmCastData")
The field must be declared directly on an object type. BackingData fields cannot be declared on interfaces, inherited from interfaces, or used as input fields. The BackingData type and @backingData directive must always appear together.
Schema visibility¶
Every field whose base type is BackingData is tenant-local automatically. Viaduct keeps it in the internal Full schema so resolvers and generated code can use it, but removes it from Base and Scoped executable schemas. Clients therefore cannot query backing-data fields.
Do not add @tenantLocal to a backing-data field. Its BackingData type already supplies the same visibility behavior.
The backing data class¶
A Kotlin data class or Java record holds the pre-fetched data. Keep it minimal, shared state only:
The backing data resolver¶
A resolver that fetches the data once per parent object. It reads the parent's ID from the object value and calls the repository:
@Resolver(objectValueFragment = "fragment _ on Film { id }")
@Prototype
class FilmCastDataResolver
@Inject
constructor(
private val filmCharactersRepository: FilmCharactersRepository
) : FilmResolvers.CastData() {
override suspend fun resolve(ctx: Context): FilmCastData {
val filmId = ctx.getObjectValue().getIdOrThrow().internalID
val characterIds = filmCharactersRepository.findCharactersByFilmId(filmId)
return FilmCastData(characterIds)
}
package com.example.starwars.modules.filmography.films.resolvers;
/** Resolves cast IDs once as backing data shared by film field resolvers. */
@Resolver(objectValueFragment = "fragment _ on Film { id }")
@Prototype
public final class FilmCastDataResolver extends FilmResolvers.CastData {
private final FilmCharactersRepository filmCharactersRepository;
@Inject
public FilmCastDataResolver(FilmCharactersRepository filmCharactersRepository) {
this.filmCharactersRepository = filmCharactersRepository;
}
@Override
public CompletableFuture<Object> resolve(Context context) {
String filmId = context.getObjectValue().getId().getInternalID();
return CompletableFuture.completedFuture(
new FilmCastData(filmCharactersRepository.findCharactersByFilmId(filmId)));
}
}
Consuming the backing data¶
Java code generation excludes BackingData fields from generated GRT getters and builders. The demo accesses the engine-backed value through getJavaEngineObjectData().getOrNull("castData") and casts it to FilmCastData. The objectValueFragment dependency is still required. This uses internal engine APIs and is shown only to explain the demo implementation; it is not a supported public Java GRT accessor. A generated Java getCastData() method does not exist. For application code, keep shared data loading in a repository or service until a public typed Java accessor is available.
Other resolvers declare castData in their objectValueFragment to receive the pre-fetched data. Viaduct automatically ensures the backing resolver completes before these consumers run:
@Resolver(objectValueFragment = "fragment _ on Film { castData }")
@Prototype
class FilmCharactersResolver
@Inject
constructor(
private val characterRepository: CharacterRepository
) : FilmResolvers.Characters() {
override suspend fun resolve(ctx: Context): List<Character?>? {
val castData = ctx.getObjectValue().get<FilmCastData>("castData", FilmCastData::class)
return castData.characterIds.map { id ->
val character = characterRepository.findById(id)
?: throw IllegalArgumentException("Character with ID $id not found")
CharacterBuilder(ctx).build(character)
}
}
package com.example.starwars.modules.filmography.films.resolvers;
/** Resolves a film's characters from shared cast backing data. */
@Resolver(objectValueFragment = "fragment _ on Film { castData }")
@Prototype
public final class FilmCharactersResolver extends FilmResolvers.Characters {
private final CharacterRepository characterRepository;
@Inject
public FilmCharactersResolver(CharacterRepository characterRepository) {
this.characterRepository = characterRepository;
}
@Override
public CompletableFuture<List<Character>> resolve(Context context) {
FilmCastData castData =
(FilmCastData) context.getObjectValue().getJavaEngineObjectData().getOrNull("castData");
List<Character> characters =
castData.characterIds().stream()
.map(
id -> {
com.example.starwars.modules.filmography.characters.models.Character character =
characterRepository.findById(id);
if (character == null) {
throw new IllegalArgumentException("Character with ID " + id + " not found");
}
return new CharacterBuilder(context).build(character);
})
.toList();
return CompletableFuture.completedFuture(characters);
}
}
Multiple resolvers can share the same backing data, each declares it in their fragment, but the fetch happens only once:
@Resolver(
"""
fragment _ on Film {
title
castData
}
"""
)
@Prototype
class FilmCharacterCountSummaryResolver : FilmResolvers.CharacterCountSummary() {
override suspend fun resolve(ctx: Context): String? {
val film = ctx.getObjectValue()
val castData = film.get<FilmCastData>("castData", FilmCastData::class)
return "${film.getTitleOrThrow()} features ${castData.characterIds.size} main characters"
}
}
package com.example.starwars.modules.filmography.films.resolvers;
/** Summarizes cast size using shared film backing data. */
@Resolver(objectValueFragment = "fragment _ on Film { title castData }")
@Prototype
public final class FilmCharacterCountSummaryResolver extends FilmResolvers.CharacterCountSummary {
@Override
public CompletableFuture<String> resolve(Context context) {
viaduct.java.grts.Film film = context.getObjectValue();
FilmCastData castData = castData(film);
return CompletableFuture.completedFuture(
film.getTitle() + " features " + castData.characterIds().size() + " main characters");
}
private FilmCastData castData(viaduct.java.grts.Film film) {
return (FilmCastData) film.getJavaEngineObjectData().getOrNull("castData");
}
}
How it works at runtime¶
- A client queries both
charactersandcharacterCountSummaryon a Film. - Viaduct sees both resolvers need
castDatain theirobjectValueFragment. FilmCastDataResolverruns once, fetching character IDs from the repository.- The result (
FilmCastData) is injected into both consuming resolvers viactx.getObjectValue()(Kotlin) orcontext.getObjectValue().getJavaEngineObjectData().getOrNull("castData")(Java). - Each consumer uses the shared data without any additional repository call.
Design guidelines¶
- Use
@backingDatawhen two or more sibling resolvers need the same upstream data. - Keep the data class minimal — only the shared state needed by consumers.
- The backing resolver should do I/O; consumers should do transformation only.
- The field type must be
BackingData— this is a Viaduct marker type that is never exposed in the client schema. - Do not declare backing-data fields on interfaces or fields inherited from interfaces.
Related¶
- Field resolvers —
@backingDatafields always also carry@resolver; see field resolvers for the general resolver pattern - Schema Reference: BackingData — covers the
BackingDatascalar and@backingDatadirective