Skip to main content
Resolvers are the heart of your Viaduct application. They implement the business logic that fetches and transforms data for your GraphQL fields.

Types of Resolvers

Viaduct provides two types of resolvers:

Node Resolvers

Fetch objects by their global ID. Every type implementing Node has a node resolver.

Field Resolvers

Compute field values. Used for fields marked with @resolver directive.

Node Resolvers

Node resolvers fetch entities by their GlobalID.

Schema Definition

type User implements Node {
  id: ID!
  firstName: String
  lastName: String
  email: String
  displayName: String @resolver
}

Generated Base Class

Viaduct generates an abstract base class for each Node type:
object NodeResolvers {
  abstract class User {
    open suspend fun resolve(ctx: Context): viaduct.api.grts.User =
      throw NotImplementedError()

    open suspend fun batchResolve(contexts: List<Context>): List<FieldValue<viaduct.api.grts.User>> =
      throw NotImplementedError()

    class Context: NodeExecutionContext<viaduct.api.grts.User>
  }
}

Simple Implementation

import com.example.myapp.resolverbases.NodeResolvers
import jakarta.inject.Inject
import viaduct.api.Resolver
import viaduct.api.grts.User

@Resolver
class UserNodeResolver @Inject constructor(
    private val userService: UserServiceClient
) : NodeResolvers.User() {
    override suspend fun resolve(ctx: Context): User {
        // Fetch data for a single user ID
        val userData = userService.getUser(ctx.id.internalID)
        
        return User.Builder(ctx)
            .firstName(userData.firstName)
            .lastName(userData.lastName)
            .email(userData.email)
            .build()
    }
}
Node resolvers should not populate fields that have their own @resolver directive. In this example, we don’t set displayName because it has its own field resolver.

Batch Node Resolver

Batch resolvers solve the N+1 problem by fetching multiple entities at once:
@Resolver
class UserNodeResolver @Inject constructor(
    private val userService: UserServiceClient
) : NodeResolvers.User() {
    override suspend fun batchResolve(contexts: List<Context>): List<FieldValue<User>> {
        // Extract all IDs
        val userIds = contexts.map { it.id.internalID }
        
        // Single batch call to service
        val users = userService.batchGetUsers(userIds)
        
        // Map results back to contexts
        return contexts.map { ctx ->
            val userId = ctx.id.internalID
            val userData = users[userId]
            
            if (userData != null) {
                FieldValue.ofValue(
                    User.Builder(ctx)
                        .firstName(userData.firstName)
                        .lastName(userData.lastName)
                        .email(userData.email)
                        .build()
                )
            } else {
                FieldValue.ofError(
                    IllegalArgumentException("User not found: $userId")
                )
            }
        }
    }
}
Always prefer batch resolvers when your backend supports batch fetching. This dramatically improves performance for queries that return lists.

Node Execution Context

The Context object provides:
id
GlobalID<R>
The GlobalID of the node to resolve
selections()
SelectionSet<R>
The fields requested in the query (for selective resolvers)
You can also:
  • Execute subqueries with ctx.query()
  • Create node references with ctx.nodeFor()
  • Create GlobalIDs with ctx.globalIDFor()

Field Resolvers

Field resolvers compute values for fields marked with @resolver.

Schema Definition

type User implements Node {
  id: ID!
  firstName: String
  lastName: String
  displayName: String @resolver
  profileImage: Image @resolver
  posts(first: Int, after: String): PostConnection! @resolver
}

Generated Base Class

object UserResolvers {
  abstract class DisplayName {
    open suspend fun resolve(ctx: Context): String? =
      throw NotImplementedError()

    open suspend fun batchResolve(contexts: List<Context>): List<FieldValue<String?>> =
      throw NotImplementedError()

    class Context: FieldExecutionContext<User, Query, NoArguments, NotComposite>
  }
}

Simple Field Resolver

import com.example.myapp.resolverbases.UserResolvers
import viaduct.api.Resolver

@Resolver("firstName lastName")
class UserDisplayNameResolver : UserResolvers.DisplayName() {
    override suspend fun resolve(ctx: Context): String? {
        val firstName = ctx.objectValue.getFirstName()
        val lastName = ctx.objectValue.getLastName()
        
        return when {
            firstName == null && lastName == null -> null
            firstName == null -> lastName
            lastName == null -> firstName
            else -> "$firstName $lastName"
        }
    }
}
The @Resolver("firstName lastName") annotation declares the required selection set - the fields this resolver needs to access from the parent object.

Field Resolver with Arguments

type User {
  posts(
    first: Int = 10
    after: String
    status: PostStatus
  ): PostConnection! @resolver
}
@Resolver
class UserPostsResolver @Inject constructor(
    private val postService: PostServiceClient
) : UserResolvers.Posts() {
    override suspend fun resolve(ctx: Context): PostConnection {
        val userId = ctx.objectValue.getId().internalID
        val (offset, limit) = ctx.arguments.toOffsetLimit()
        
        val posts = postService.getUserPosts(
            userId = userId,
            offset = offset,
            limit = limit + 1,
            status = ctx.arguments.status
        )
        
        return PostConnection.Builder(ctx)
            .fromSlice(
                items = posts,
                hasNextPage = posts.size > limit
            ) { post ->
                ctx.nodeFor(post.id)
            }
            .build()
    }
}

Field Execution Context

The Context object provides:
objectValue
O
The parent object containing this field. Only fields from the required selection set are accessible.
queryValue
Q
The root Query object. Only fields from the query selection set are accessible.
arguments
A
Type-safe access to field arguments
selections()
SelectionSet<R>
The fields requested for this field’s return type

Required Selection Sets

Resolvers declare their data dependencies using GraphQL fragments:

Shorthand Syntax

Just list the fields you need:
@Resolver("firstName lastName")
class UserDisplayNameResolver : UserResolvers.DisplayName() {
    override suspend fun resolve(ctx: Context): String? {
        val firstName = ctx.objectValue.getFirstName()
        val lastName = ctx.objectValue.getLastName()
        return "$firstName $lastName"
    }
}

Full Fragment Syntax

Use standard GraphQL fragment syntax for complex selections:
@Resolver(
    """
    fragment _ on User {
        firstName
        lastName
        profileImage {
            url
            caption
        }
    }
    """
)
class UserSummaryResolver : UserResolvers.Summary() {
    override suspend fun resolve(ctx: Context): String {
        val name = ctx.objectValue.getFirstName()
        val imageUrl = ctx.objectValue.getProfileImage().getUrl()
        return "$name - $imageUrl"
    }
}

Multiple Fragments

Reuse fragment definitions:
@Resolver(
    """
    fragment Main on User {
        firstName
        lastName
        ...UserImage
    }
    fragment UserImage on User {
        profileImage {
            url
            thumbnailUrl
        }
    }
    """
)
If you access a field not in your required selection set, you’ll get an UnsetFieldException at runtime.

Query Value Fragment

Access root Query fields:
@Resolver(
    queryValueFragment = """
    fragment _ on Query {
        currentUser {
            id
            role
        }
    }
    """
)
class PermissionCheckResolver : SomeResolvers.Field() {
    override suspend fun resolve(ctx: Context): Boolean {
        val currentUser = ctx.queryValue.getCurrentUser()
        val role = currentUser.getRole()
        return role == UserRole.ADMIN
    }
}

Responsibility Sets

Responsibility sets define which fields each resolver is responsible for.

Node Resolver Responsibility

A node resolver is responsible for:
  • All fields without the @resolver directive
  • Including nested fields (unless they have their own resolver)
  • But not the id field (it’s provided as input)
type User implements Node {
  id: ID!                        # Not included (input)
  firstName: String              # Responsibility of node resolver
  lastName: String               # Responsibility of node resolver
  email: String                  # Responsibility of node resolver
  displayName: String @resolver  # NOT in node resolver's responsibility
}

Field Resolver Responsibility

A field resolver is responsible for:
  • Scalar/Enum fields: Just that single field
  • Object fields: The field and all nested fields without their own resolver
type User {
  profile: UserProfile @resolver  # Field resolver's responsibility
}

type UserProfile {
  bio: String              # Field resolver's responsibility
  website: String          # Field resolver's responsibility
  avatar: Image @resolver  # NOT in field resolver's responsibility
}

Building GRTs

Use the builder pattern to construct response objects:
// Simple object
val user = User.Builder(ctx)
    .firstName("Alice")
    .lastName("Smith")
    .email("[email protected]")
    .build()

// With node reference
val post = Post.Builder(ctx)
    .title("My Post")
    .author(ctx.nodeFor(authorId))  // Creates reference to User node
    .build()

// With nested object
val user = User.Builder(ctx)
    .name("Alice")
    .profile(
        UserProfile.Builder(ctx)
            .bio("Software engineer")
            .website("https://example.com")
            .build()
    )
    .build()
Use ctx.nodeFor() to create references to Node objects. This tells Viaduct to call the node resolver when those fields are queried.

Error Handling

In Batch Resolvers

Return errors for individual items:
override suspend fun batchResolve(contexts: List<Context>): List<FieldValue<User>> {
    return contexts.map { ctx ->
        try {
            val user = userService.getUser(ctx.id.internalID)
            FieldValue.ofValue(buildUser(ctx, user))
        } catch (e: UserNotFoundException) {
            FieldValue.ofError(e)
        }
    }
}

In Regular Resolvers

Throw exceptions directly:
override suspend fun resolve(ctx: Context): User {
    val userId = ctx.arguments.id.internalID
    val user = userService.getUser(userId)
        ?: throw IllegalArgumentException("User not found: $userId")
    
    return User.Builder(ctx)
        .name(user.name)
        .build()
}

Dependency Injection

Viaduct works with any DI framework:

With Micronaut

import jakarta.inject.Inject
import jakarta.inject.Singleton

@Singleton
@Resolver
class UserNodeResolver @Inject constructor(
    private val userService: UserService,
    private val authContext: AuthContext
) : NodeResolvers.User() {
    // ...
}

With Guice

import javax.inject.Inject

@Resolver
class UserNodeResolver @Inject constructor(
    private val userService: UserService
) : NodeResolvers.User() {
    // ...
}

Real-World Examples

@Resolver
class CharacterNodeResolver @Inject constructor(
    private val characterRepository: CharacterRepository
) : NodeResolvers.Character() {
    override suspend fun batchResolve(
        contexts: List<Context>
    ): List<FieldValue<Character>> {
        val characterIds = contexts.map { it.id.internalID }
        val characters = characterRepository.findByIds(characterIds)
        
        return contexts.map { ctx ->
            val characterId = ctx.id.internalID
            val character = characters.find { it.id == characterId }
            
            if (character != null) {
                FieldValue.ofValue(
                    Character.Builder(ctx)
                        .name(character.name)
                        .birthYear(character.birthYear)
                        .height(character.height)
                        .mass(character.mass)
                        .build()
                )
            } else {
                FieldValue.ofError(
                    IllegalArgumentException("Character not found: $characterId")
                )
            }
        }
    }
}

Best Practices

1

Declare your dependencies

Always specify required selection sets in @Resolver annotation:
@Resolver("firstName lastName")
class UserDisplayNameResolver : UserResolvers.DisplayName()
2

Use batch resolvers

Implement batchResolve for node resolvers and field resolvers that fetch external data:
override suspend fun batchResolve(contexts: List<Context>): List<FieldValue<User>>
3

Return only your responsibility

Don’t set fields that have their own resolvers:
// Good - only sets fields in responsibility set
User.Builder(ctx)
    .firstName(data.firstName)
    .lastName(data.lastName)
    .build()

// Bad - displayName has its own resolver
User.Builder(ctx)
    .firstName(data.firstName)
    .displayName("...")  // Don't do this!
    .build()
4

Use node references for relationships

Use ctx.nodeFor() instead of inline building:
// Good - creates node reference
Post.Builder(ctx)
    .author(ctx.nodeFor(authorId))
    .build()

// Bad - duplicates node resolver logic
Post.Builder(ctx)
    .author(
        User.Builder(ctx)
            .firstName(...)
            .build()
    )
    .build()

Next Steps

Mutations

Learn how to implement mutations

Pagination

Implement cursor-based pagination

Testing

Write tests for your resolvers

Build docs developers (and LLMs) love