Skip to main content
Viaduct includes a set of built-in directives that are fundamental to the framework’s functionality. These directives are automatically available in every schema and cannot be overridden.

Core Directives

@resolver

Marks fields or types that require custom resolution logic. This is the primary mechanism for implementing data fetching in Viaduct.
directive @resolver on FIELD_DEFINITION | OBJECT
Locations: FIELD_DEFINITION, OBJECT Purpose:
  • Fields that fetch data from external services or databases
  • Fields that require custom business logic beyond simple property access
  • Object types that need node resolution for Global ID support
Field-level Example:
type Query {
  user(id: ID!): User @resolver
  searchUsers(query: String!): [User!]! @resolver
}
Type-level Example:
type User @resolver {
  id: ID!
  name: String
  email: String
}
Generated Code: When you apply @resolver to a field, Viaduct generates an abstract resolver class that you must implement:
// Generated by Viaduct
abstract class UserQueryResolver : ResolverBase<User> {
    abstract suspend fun resolve(ctx: FieldExecutionContext<...>): User?
}

// Your implementation
class UserQueryResolverImpl : UserQueryResolver() {
    override suspend fun resolve(ctx: FieldExecutionContext<...>): User? {
        val userId = ctx.arguments.id
        return fetchUserById(userId)
    }
}
Node Resolution: When applied to a type implementing Node, generates a node resolver:
abstract class UserNodeResolver : NodeResolverBase<User> {
    abstract suspend fun resolve(ctx: NodeExecutionContext<User>): User?
}

@backingData

Specifies the backing data class for a field, enabling type-safe data access in resolvers.
directive @backingData(
  class: String!
) on FIELD_DEFINITION
Locations: FIELD_DEFINITION Arguments:
class
String
required
Fully qualified name of the backing data class (Kotlin class name)
Purpose:
  • Mapping GraphQL types to internal data models
  • Providing type information for fields derived from backing data
  • Enabling Viaduct to automatically resolve fields from backing data without custom resolvers
Example:
type User {
  profile: UserProfile @backingData(class: "com.example.data.UserProfileData")
}

type UserProfile {
  bio: String
  avatarUrl: String
  location: String
}
Backing Data Class:
package com.example.data

data class UserProfileData(
    val bio: String?,
    val avatarUrl: String?,
    val location: String?
)
How It Works:
  1. Resolver fetches data: The resolver for User.profile returns a UserProfileData instance
  2. Viaduct maps fields: Viaduct automatically maps UserProfile fields to UserProfileData properties
  3. No field resolvers needed: Individual fields like bio, avatarUrl are resolved automatically
Benefits:
  • Separates transformation logic from data retrieval
  • Reduces boilerplate - no need to write resolvers for every field
  • Type-safe mapping between GraphQL and internal models
  • Centralizes data shaping logic

@scope

Controls field and type visibility across different schema scopes. This is a repeatable directive that enables multi-tenant or multi-variant schemas.
directive @scope(
  to: [String!]!
) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | FIELD_DEFINITION | ENUM_VALUE
Locations: OBJECT, INTERFACE, UNION, ENUM, INPUT_OBJECT, FIELD_DEFINITION, ENUM_VALUE Arguments:
to
[String!]!
required
List of scope names where this element is visible. Use ["*"] for universal visibility.
Purpose:
  • Creating public vs. internal API variants from the same codebase
  • Feature flagging schema elements
  • Multi-tenant schema visibility
  • Gradual rollout of new features
  • Permission-based field visibility
Type-level Example:
type User @scope(to: ["public"]) {
  id: ID!
  name: String!
  email: String @scope(to: ["internal"])
  adminNotes: String @scope(to: ["admin"])
}

type InternalMetrics @scope(to: ["internal"]) {
  requestCount: Long!
  errorRate: Float!
}
Field-level Example:
type Query {
  # Public API
  user(id: ID!): User @resolver @scope(to: ["public"])
  
  # Internal API only
  allUsers: [User!]! @resolver @scope(to: ["internal"])
  
  # Admin-only
  deleteUser(id: ID!): Boolean @resolver @scope(to: ["admin"])
}
Multiple Scopes:
type Feature @scope(to: ["beta", "internal"]) {
  id: ID!
  name: String
}
Runtime Behavior:
  • Schema filtering: Elements are included/excluded at schema compilation time
  • Access granted if ANY scope matches: If a user has scope internal, they can see elements marked @scope(to: ["internal", "admin"])
  • Introspection: Non-matching fields are omitted from introspection queries
  • Planning: Unauthorized elements are not planned or executed
Service Integration:
// Determine scopes from request (e.g., JWT claims, headers, session)
val scopes = extractScopesFromRequest(request) // ["default", "internal"]

// Create scoped schema ID
val schemaId = SchemaId.Scoped(
    id = "user-schema",
    scopeIds = scopes.toSet()
)

// Execute with scoped schema
val result = viaduct.execute(executionInput, schemaId)
Best Practices:
  • Define a default scope for general availability
  • Keep scopes orthogonal - avoid overlapping responsibilities
  • Apply @scope explicitly to sensitive fields
  • Log active scopes per request for auditability
  • Complement with application-level authorization for data-level controls

@idOf

Declares that a field represents a Global ID for a specific GraphQL type. This enables type-safe ID handling.
directive @idOf(
  type: String!
) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
Locations: FIELD_DEFINITION, INPUT_FIELD_DEFINITION, ARGUMENT_DEFINITION Arguments:
type
String
required
Name of the GraphQL type this ID references (must implement Node)
Purpose:
  • Type-safe Global ID handling in resolvers
  • Node interface implementations
  • Cross-type references with compile-time validation
  • Preventing ID type confusion
Query Argument Example:
type Query {
  user(id: ID! @idOf(type: "User")): User @resolver
  users(ids: [ID!]! @idOf(type: "User")): [User!]! @resolver
  
  post(id: ID! @idOf(type: "Post")): Post @resolver
}
Input Type Example:
input UpdateUserInput {
  userId: ID! @idOf(type: "User")
  name: String
  email: String
}

input CreatePostInput {
  authorId: ID! @idOf(type: "User")
  title: String!
  content: String!
}
Field Example:
type Post {
  id: ID!
  authorId: ID! @idOf(type: "User")
  author: User @resolver
}
Generated Code: Without @idOf:
interface UserQueryArguments : Arguments {
    val id: String  // Just a String
}
With @idOf:
interface UserQueryArguments : Arguments {
    val id: GlobalID<User>  // Type-safe Global ID
}
Usage in Resolvers:
class UserQueryResolverImpl : UserQueryResolver() {
    override suspend fun resolve(ctx: FieldExecutionContext<...>): User? {
        val userId: GlobalID<User> = ctx.arguments.id
        val internalId: String = userId.internalID
        
        return fetchUserById(internalId)
    }
}
Creating Global IDs:
val userId = ctx.globalIDFor(User.Reflection, "123")
val postId = ctx.globalIDFor(Post.Reflection, "456")
Benefits:
  • Type Safety: Prevents passing a Post ID where a User ID is expected
  • Compile-Time Validation: Catches ID type mismatches before runtime
  • Self-Documenting: Clear what type each ID references
  • Relay Compatibility: Works seamlessly with Relay’s Global Object Identification spec

Pagination Directives

@connection

Marks an object type as a Relay Connection type for pagination support.
directive @connection on OBJECT
Locations: OBJECT Purpose:
  • Build-time validation of connection type structure
  • Integration with Viaduct’s pagination utilities
  • Clear schema documentation of pagination patterns
Example:
type CharacterConnection @connection {
  edges: [CharacterEdge!]!
  pageInfo: PageInfo!
  totalCount: Int
}

type CharacterEdge @edge {
  node: Character!
  cursor: String!
}

type Query {
  allCharacters(
    first: Int
    after: String
    last: Int
    before: String
  ): CharacterConnection @resolver
}
Requirements: Types marked with @connection must:
  1. Have a name ending in Connection
  2. Have an edges field returning a list of edge types (marked with @edge)
  3. Have a pageInfo: PageInfo! field
Additional Fields: Connections can include extra fields beyond the Relay spec:
type FilmConnection @connection {
  edges: [FilmEdge!]!
  pageInfo: PageInfo!
  totalCount: Int         # Optional: total count of items
  hasNextPage: Boolean    # Optional: convenience field
}

@edge

Marks an object type as a Relay Edge type within connections.
directive @edge on OBJECT
Locations: OBJECT Purpose:
  • Build-time validation of edge type structure
  • Integration with Viaduct’s pagination utilities
  • Clear schema documentation of pagination patterns
Example:
type CharacterEdge @edge {
  node: Character!
  cursor: String!
}

type FilmEdge @edge {
  node: Film!
  cursor: String!
  releaseOrder: Int  # Optional: additional edge metadata
}
Requirements: Types marked with @edge must:
  1. Have a node field (any output type except list)
  2. Have a cursor: String! field (non-nullable String)
Edge Metadata: Edges can include additional fields for relationship metadata:
type FriendshipEdge @edge {
  node: User!
  cursor: String!
  since: DateTime      # When friendship was established
  mutualFriends: Int   # Relationship-specific data
}

Directive Summary

DirectiveLocationsPurposeGenerated Code Impact
@resolverFIELD_DEFINITION, OBJECTMarks fields/types requiring custom resolutionGenerates abstract resolver classes
@backingData(class: String!)FIELD_DEFINITIONSpecifies backing data classEnables automatic field resolution
@scope(to: [String!]!)OBJECT, INTERFACE, UNION, ENUM, INPUT_OBJECT, FIELD_DEFINITION, ENUM_VALUEControls visibility by scope (repeatable)Affects schema filtering
@idOf(type: String!)FIELD_DEFINITION, INPUT_FIELD_DEFINITION, ARGUMENT_DEFINITIONDeclares Global ID typeUses GlobalID<T> instead of String
@connectionOBJECTMarks Relay Connection typesEnables pagination validation
@edgeOBJECTMarks Relay Edge typesEnables pagination validation

Best Practices

Do

  • Apply @resolver to fields fetching external data - This is how Viaduct knows which fields need custom logic
  • Use @idOf for type-safe IDs - Leverage compile-time validation for ID references
  • Apply @scope explicitly to sensitive fields - Don’t rely on implicit visibility
  • Use @backingData to separate concerns - Keep transformation logic separate from retrieval
  • Follow Relay conventions with @connection and @edge - Use standard pagination patterns

Don’t

  • Don’t override core directives - @resolver, @backingData, @scope, @idOf, @connection, and @edge are framework-provided
  • Don’t forget to extend root types - Always use extend type Query, not type Query
  • Don’t use @scope as the only authorization mechanism - Complement with application-level checks
  • Don’t apply @resolver to every field - Use backing data classes for simple mappings

See Also

Build docs developers (and LLMs) love