Resolver Integrations
Resolvers in Viaduct form a layered model that separates entity retrieval from per-field computation. Node, field, and batch field resolvers each play a distinct role but integrate seamlessly during execution.
Standard entity pattern¶
Each entity type can typically implement:
- Node resolver for
GlobalID-based retrieval vianodequeries.
@Resolver
class CharacterNodeResolver
@Inject
constructor(
private val characterRepository: CharacterRepository
) : NodeResolvers.Character() {
// Node resolvers can also be batched to optimize multiple requests
// tag::node_batch_resolver_example[21] Example of a node resolver
override suspend fun batchResolve(contexts: List<Context>): List<FieldValue<Character>> {
// Extract all unique character IDs from the contexts
val characterIds = contexts.map { it.id.internalID }
// Perform a single batch query to get film counts for all characters
// We only compute one time for each character, despite multiple requests
val characters = characterIds.mapNotNull {
characterRepository.findById(it)
}
- Batch field resolvers for expensive computed fields that benefit from batching.
@Resolver(objectValueFragment = "fragment _ on Character { id }")
class CharacterFilmCountResolver
@Inject
constructor(
val characterFilmsRepository: CharacterFilmsRepository
) : CharacterResolvers.FilmCount() {
override suspend fun batchResolve(contexts: List<Context>): List<FieldValue<Int>> {
// Extract all unique character IDs from the contexts
val characterIds = contexts.map { it.objectValue.getId().internalID }.toSet()
// Perform a single batch query to get film counts for all characters
// We only compute one time for each character, despite multiple requests
val filmCounts = characterIds.associateWith { characterId ->
characterFilmsRepository.findFilmsByCharacterId(characterId).size
}
// For each context gets the character ID and map to the precomputed film count
// and return the results in the same order as contexts
return contexts.map { ctx ->
val characterId = ctx.objectValue.getId().internalID
- Single field resolvers for lightweight computed or derived values.
@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()
The entity resolution flow¶
- Query parsing: Viaduct analyzes the query and fragments to determine which types and fields are required.
- Node resolution: node resolvers load entities by Global ID for any
node(id:)or reference field. - Field resolution: Viaduct invokes field resolvers for each selected field, batching them when possible.
- Result assembly: all results are merged into a single GraphQL response.
This design isolates entity loading from field logic, ensures predictable performance, and enables resolver reuse across schemas and scopes.
Integration example¶
query {
node(id: "Q2hhcmFjdGVyOjE=") {
... on Character {
id
name # from Field Resolver
homeworld { name } # via batched field resolver
filmCount # aggregated by batch resolver
}
}
}
Execution flow for this query:
| Step | Resolver | Responsibility |
|---|---|---|
| 1 | CharacterNodeResolver |
Retrieve Character entity by internal ID. |
| 2 | DisplayNameResolver |
Compute or format the name field. |
| 3 | HomeworldResolver |
Fetch related Planet; batched across all Characters. |
| 4 | FilmCountBatchResolver |
Compute film counts for all Characters in one call. |
| 5 | Viaduct runtime | Assemble and serialize the final result tree. |
Common integration pitfalls¶
1. Duplicated lookups¶
Avoid fetching the same entity in multiple resolvers. Node resolvers should load once, and related lookups should use field resolvers (batched when necessary).
2. Overfetching fragments¶
Limit objectValueFragment to only the fields your resolver actually uses. Overly broad fragments increase query cost
and memory use.
3. Missing batching opportunities¶
If a field resolver executes the same repository call per parent, migrate it to a batch resolver.
4. Misaligned ID handling¶
Ensure all resolvers use ctx.globalIDFor(Type.Reflection, internalId) and consume IDs via ctx.id.internalID. Mixing
raw IDs with Global IDs can cause type mismatches in queries.
5. Incorrect nullability handling¶
Return null for missing relationships when the schema field is nullable, rather than throwing exceptions.
Do and don’t¶
- Do separate responsibilities: nodes fetch, fields compute, batch resolvers aggregate.
- Do test integration flows end-to-end with actual queries.
- Don’t mix loading logic inside field resolvers.
- Don’t assume execution order between independent resolvers — rely on field dependencies, not sequencing.