Viaduct uses a schema-first approach where you define your GraphQL schema in .graphqls files, and the framework generates type-safe Kotlin code for you to implement.
Schema Files
Schema files must be placed in src/main/viaduct/schema/ within each module:
modules/users/
└── src/main/viaduct/schema/
├── User.graphqls
└── queries.graphqls
Use the .graphqls extension for schema files. Viaduct will automatically discover and process all schema files in this directory.
Basic Schema Structure
Extending Root Types
Always use extend type for Query and Mutation (never define them directly):
extend type Query {
user ( id : ID ! @idOf ( type : "User" )): User @resolver
users ( first : Int , after : String ): UserConnection ! @resolver
}
extend type Mutation {
createUser ( input : CreateUserInput ! ): User @resolver
updateUser ( id : ID ! @idOf ( type : "User" ), input : UpdateUserInput ! ): User @resolver
}
Never write type Query { ... } or type Mutation { ... }. Always use extend type.
Defining Node Types
Types that implement the Node interface are globally identifiable by ID:
type User implements Node {
id : ID !
name : String !
email : String !
createdAt : DateTime !
# Fields with custom business logic
displayName : String @resolver
isActive : Boolean @resolver
# Relationships to other nodes
posts ( first : Int , after : String ): PostConnection ! @resolver
}
Viaduct Directives
Viaduct provides four core directives for schema definition:
@resolver
Marks fields or types that require custom resolution logic:
type Query {
# Simple query field
currentUser : User @resolver
# With arguments
user ( id : ID ! @idOf ( type : "User" )): User @resolver
}
type User implements Node {
id : ID !
firstName : String
lastName : String
# Derived field - computed from firstName and lastName
displayName : String @resolver
# Field backed by different data source
profileImage : Image @resolver
}
When to use @resolver:
Fields with arguments Any field that accepts arguments should have its own resolver
Different data sources When a field comes from a separate service or database
Computed values Fields derived from other fields with business logic
Expensive operations Fields that should only execute when explicitly requested
@idOf
Declares that an ID field references a specific Node type:
type Query {
user ( id : ID ! @idOf ( type : "User" )): User @resolver
users ( ids : [ ID ! ] ! @idOf ( type : "User" )): [ User ! ] ! @resolver
}
input CreatePostInput {
title : String !
authorId : ID ! @idOf ( type : "User" )
}
type Post implements Node {
id : ID !
title : String !
# Without @idOf, this would be a String
authorId : ID ! @idOf ( type : "User" )
}
Using @idOf gives you type-safe GlobalID<User> in your Kotlin code instead of plain strings. This prevents accidentally mixing up IDs of different types.
@backingData
Specifies the backing data class for automatic field resolution:
type User {
profile : UserProfile @backingData ( class : "com.example.myapp.data.UserProfileData" )
}
type UserProfile {
bio : String
location : String
website : String
}
With @backingData, Viaduct can automatically resolve nested fields without requiring a custom resolver.
@scope
Controls visibility of fields and types across schema variants:
type User @scope ( to : [ "public" , "internal" ]) {
id : ID !
name : String !
# Only visible in internal schema
email : String @scope ( to : [ "internal" ])
lastLoginAt : DateTime @scope ( to : [ "internal" ])
}
# Only exists in admin schema
type AdminMetrics @scope ( to : [ "admin" ]) {
totalUsers : Int !
activeUsers : Int !
}
Define paginated lists using @connection and @edge:
schema/connections.graphqls
type UserConnection @connection {
edges : [ UserEdge ! ] !
pageInfo : PageInfo !
totalCount : Int # Optional additional field
}
type UserEdge @edge {
node : User !
cursor : String !
joinedAt : DateTime # Optional additional field
}
Requirements:
Connection type
Name must end with Connection
Must have edges: [<EdgeType>!]! field
Must have pageInfo: PageInfo! field
Use @connection directive
Edge type
Name must end with Edge
Must have node field (any type except list)
Must have cursor: String! field
Use @edge directive
See Pagination for implementation details.
Define input types for mutations and complex arguments:
input CreateUserInput {
name : String !
email : String !
bio : String
# References
organizationId : ID @idOf ( type : "Organization" )
}
input UpdateUserInput {
name : String
email : String
bio : String
}
input UserFilterInput {
status : UserStatus
createdAfter : DateTime
createdBefore : DateTime
}
Use @oneOf for inputs where exactly one field must be provided:
input SearchUserInput @oneOf {
byId : ID @idOf ( type : "User" )
byEmail : String
byUsername : String
}
extend type Query {
searchUser ( search : SearchUserInput ! ): User @resolver
}
Enums
enum UserStatus {
ACTIVE
INACTIVE
SUSPENDED
DELETED
}
enum UserRole {
ADMIN
MODERATOR
USER
}
Interfaces
schema/interfaces.graphqls
interface Timestamped {
createdAt : DateTime !
updatedAt : DateTime !
}
type User implements Node & Timestamped {
id : ID !
name : String !
createdAt : DateTime !
updatedAt : DateTime !
}
type Post implements Node & Timestamped {
id : ID !
title : String !
createdAt : DateTime !
updatedAt : DateTime !
}
Built-in Scalars
Viaduct provides extended scalars beyond GraphQL defaults:
Kotlin: java.time.LocalDateExample: "2024-01-15"
Kotlin: java.time.InstantExample: "2024-01-15T14:30:00Z"
Kotlin: LongExample: 9223372036854775807
BigDecimal
Arbitrary precision decimal
Kotlin: java.math.BigDecimalExample: "123.456789012345"
BigInteger
Arbitrary precision integer
Kotlin: java.math.BigIntegerExample: "12345678901234567890"
Kotlin: com.fasterxml.jackson.databind.JsonNodeExample: {"key": "value", "count": 42}
Real-World Example
Here’s a complete schema for a blogging platform:
User.graphqls
Post.graphqls
mutations.graphqls
queries.graphqls
type User implements Node {
id : ID !
username : String !
email : String !
bio : String
avatarUrl : String
createdAt : DateTime !
# Computed fields
displayName : String @resolver
isVerified : Boolean @resolver
# Relationships
posts ( first : Int , after : String ): PostConnection ! @resolver
followers ( first : Int , after : String ): UserConnection ! @resolver
following ( first : Int , after : String ): UserConnection ! @resolver
}
type UserConnection @connection {
edges : [ UserEdge ! ] !
pageInfo : PageInfo !
totalCount : Int !
}
type UserEdge @edge {
node : User !
cursor : String !
followedAt : DateTime
}
Schema Organization Tips
One type per file: Keep each major type in its own .graphqls file for better organization and git history.
Group related types: Place connections, edges, and inputs near the types they relate to.
Separate queries and mutations: Create dedicated queries.graphqls and mutations.graphqls files.
Best Practices
Use @idOf for all Node references
This provides type safety and prevents mixing up different ID types: # Good
authorId : ID ! @idOf ( type : "User" )
# Bad - just a string
authorId : ID !
Add @resolver to fields with logic
Mark fields that need computation or come from different sources: type User {
firstName : String # Direct field
lastName : String # Direct field
displayName : String @resolver # Computed
}
Use descriptive names
Make field and type names clear and consistent: # Good
publishedPosts ( first : Int ): PostConnection !
# Less clear
posts ( first : Int ): PostConnection !
Document with descriptions
Add GraphQL descriptions for better schema documentation: \ "\" \ "
A user account in the system.
\" \ "\"
type User implements Node {
\ "\" \ "
The user's unique identifier.
\" \ "\"
id : ID !
}
Next Steps
Writing Resolvers Learn how to implement resolvers for your schema fields
Global IDs Understand type-safe global identifiers