Skip to main content
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):
schema/queries.graphqls
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:
schema/User.graphqls
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!
}

Connection Types (Pagination)

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:
1

Connection type

  • Name must end with Connection
  • Must have edges: [<EdgeType>!]! field
  • Must have pageInfo: PageInfo! field
  • Use @connection directive
2

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.

Input Types

Define input types for mutations and complex arguments:
schema/inputs.graphqls
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
}

OneOf Inputs

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

schema/enums.graphqls
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:
Date
ISO 8601 date
Kotlin: java.time.LocalDateExample: "2024-01-15"
DateTime
ISO 8601 date-time
Kotlin: java.time.InstantExample: "2024-01-15T14:30:00Z"
Long
64-bit integer
Kotlin: LongExample: 9223372036854775807
BigDecimal
Arbitrary precision decimal
Kotlin: java.math.BigDecimalExample: "123.456789012345"
BigInteger
Arbitrary precision integer
Kotlin: java.math.BigIntegerExample: "12345678901234567890"
JSON
Generic JSON object
Kotlin: com.fasterxml.jackson.databind.JsonNodeExample: {"key": "value", "count": 42}

Real-World Example

Here’s a complete schema for a blogging platform:
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

1

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!
2

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
}
3

Use descriptive names

Make field and type names clear and consistent:
# Good
publishedPosts(first: Int): PostConnection!

# Less clear
posts(first: Int): PostConnection!
4

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

Build docs developers (and LLMs) love