Viaduct generates GraphQL Representational Types (GRTs) - Kotlin classes that represent GraphQL types in a type-safe, ergonomic way. Every GraphQL type in your schema gets a corresponding GRT with builders and accessor methods.
GRT stands for “GraphQL Representational Type.” These are the generated Kotlin classes you use to read query results and construct response objects in your resolvers.
type User implements Node { id: ID! firstName: String lastName: String email: String! displayName: String}
Viaduct generates:
Generated: viaduct.api.grts.User
package viaduct.api.grtsclass User private constructor(...): NodeObject { // Suspending getters for accessing field values suspend fun getId(alias: String? = null): GlobalID<User> suspend fun getFirstName(alias: String? = null): String? suspend fun getLastName(alias: String? = null): String? suspend fun getEmail(alias: String? = null): String suspend fun getDisplayName(alias: String? = null): String? // Builder for constructing instances class Builder(ctx: ExecutionContext): DynamicValueOutputBuilder<User> { fun id(id: GlobalID<User>): Builder fun firstName(firstName: String?): Builder fun lastName(lastName: String?): Builder fun email(email: String): Builder fun displayName(displayName: String?): Builder override fun build(): User }}
Object type GRTs implement NodeObject and provide suspending getters:
Using a GRT
@Resolver("fragment _ on User { firstName lastName }")class DisplayNameResolver : UserResolvers.DisplayName() { override suspend fun resolve(ctx: Context): String? { // Access fields via suspending getters val first = ctx.objectValue.getFirstName() val last = ctx.objectValue.getLastName() return "$first $last" }}
UnsetFieldException: If you access a field not declared in your @Resolver fragment, an UnsetFieldException will be thrown at runtime, even if the field is nullable.
class CreateUserInput private constructor(...) { // Properties, not suspend functions val firstName: String val lastName: String val email: String val age: Int? class Builder { fun firstName(firstName: String): Builder fun lastName(lastName: String): Builder fun email(email: String): Builder fun age(age: Int?): Builder // Throws error if required fields not set fun build(): CreateUserInput }}
Input object builders require all non-nullable fields without defaults to be set. Calling build() without setting required fields throws a runtime error.
@Resolverclass CreateUserResolver : MutationResolvers.CreateUser() { override suspend fun resolve(ctx: Context): User { // Access input via ctx.arguments val input = ctx.arguments.input // Properties available immediately (not suspend) val firstName = input.firstName val lastName = input.lastName val email = input.email val age = input.age // Nullable // Create user... }}
While Viaduct maintains a central schema, each tenant module only generates code for types it actually uses:
A compilation schema is a per-tenant-module, private view of the central schema consisting of only the schema elements used by that module. This makes Viaduct builds fast and scalable.
Compilation schemas are always valid, self-contained GraphQL schemas. They’re not just a subset of types - they include all dependencies needed for those types.
For each type, a container class groups all field resolvers:
Generated: CharacterResolvers.kt
abstract class CharacterResolvers { // Base class for displayName field resolver abstract class DisplayName : FieldResolver< Character, // Object type Query, // Query type DisplayNameArgs, // Arguments type String // Return type > { abstract suspend fun resolve(ctx: Context): String? } // Base class for filmCount field resolver abstract class FilmCount : FieldResolver< Character, Query, FilmCountArgs, Int > { abstract suspend fun resolve(ctx: Context): Int? } // Base class for homeworld field resolver abstract class Homeworld : FieldResolver< Character, Query, HomeworldArgs, Planet > { abstract suspend fun resolve(ctx: Context): Planet? }}
Your implementations:
@Resolver("name")class CharacterDisplayNameResolver : CharacterResolvers.DisplayName() { override suspend fun resolve(ctx: Context): String? { return ctx.objectValue.getName() }}@Resolver("fragment _ on Character { homeworldId }")class CharacterHomeworldResolver @Inject constructor( private val planetRepository: PlanetRepository) : CharacterResolvers.Homeworld() { override suspend fun batchResolve( contexts: List<Context> ): List<FieldValue<Planet>> { val planetIds = contexts.map { it.objectValue.getHomeworldId() } val planets = planetRepository.findByIds(planetIds) return contexts.map { ctx -> val planetId = ctx.objectValue.getHomeworldId() FieldValue.ofValue(planets[planetId]!!) } }}
@Resolver("fragment _ on User { firstName lastName email }")class UserSummaryResolver : UserResolvers.Summary() { override suspend fun resolve(ctx: Context): String { val user = ctx.objectValue // All getters are suspend functions val name = "${user.getFirstName()} ${user.getLastName()}" val email = user.getEmail() return "$name <$email>" }}
@Resolverclass UserNodeResolver @Inject constructor( private val userService: UserService) : NodeResolvers.User() { override suspend fun resolve(ctx: Context): User { val userId = ctx.id.internalID val userData = userService.getUser(userId) // Use builder to construct GRT return User.Builder(ctx) .id(ctx.id) .firstName(userData.firstName) .lastName(userData.lastName) .email(userData.email) .build() }}
When building output objects, you don’t need to set all fields - only set fields that are actually requested in the query. Viaduct tracks which fields are “set” internally.
override suspend fun batchResolve( contexts: List<Context>): List<FieldValue<Planet>> { // Access fields on all contexts before any suspend val planetIds = contexts.map { it.objectValue.getHomeworldId() // Suspends once for all } // Batch load val planets = repository.findByIds(planetIds) // Map results return contexts.map { ctx -> FieldValue.ofValue(planets[ctx.objectValue.getHomeworldId()]!!) }}
Problem: Resolvers fail to compile after schema changesSolution: This is expected! The type safety ensures code stays in sync. Update your resolvers to match the new schema: