Viaduct provides type-safe global identifiers for Node objects through the GlobalID<T> type. Instead of plain strings, you get compile-time type safety that prevents mixing up IDs of different types.
Why Global IDs?
Type Safety Prevent passing a User ID where a Post ID is expected at compile time
Encode Type Info IDs contain both the type name and internal identifier
Relay Compatible Works with the Relay node(id: ID!) query pattern
Opaque to Clients Internal ID structure is hidden from API consumers
The Node Interface
Types implementing Node are globally identifiable:
interface Node {
id : ID !
}
type User implements Node {
id : ID ! # This is a GlobalID<User>
name : String !
email : String !
}
type Post implements Node {
id : ID ! # This is a GlobalID<Post>
title : String !
content : String !
}
Viaduct automatically provides the Node interface and node(id: ID!) query when your schema uses Node types.
GlobalID vs String
Viaduct uses two Kotlin types to represent GraphQL ID fields:
GlobalID<T> is used for:
The id field of Node types
type User implements Node {
id : ID ! # GlobalID<User> in Kotlin
}
Fields with @idOf directive
type Post {
authorId : ID ! @idOf ( type : "User" ) # GlobalID<User> in Kotlin
}
String is used for:
All other ID fields without @idOf
type User implements Node {
id : ID ! # GlobalID<User>
internalId : ID ! # String
userId : ID ! @idOf ( type : "User" ) # GlobalID<User>
legacyId : ID ! # String
}
Using @idOf
The @idOf directive declares that an ID field references a specific Node type:
On Field Definitions
type Post implements Node {
id : ID !
title : String !
# This is a GlobalID<User> in Kotlin
authorId : ID ! @idOf ( type : "User" )
}
input CreatePostInput {
title : String !
content : String !
# Type-safe: must be a User ID
authorId : ID ! @idOf ( type : "User" )
# List of Tag IDs
tagIds : [ ID ! ] @idOf ( type : "Tag" )
}
On Arguments
extend type Query {
user ( id : ID ! @idOf ( type : "User" )): User @resolver
posts (
authorId : ID @idOf ( type : "User" )
first : Int
): PostConnection ! @resolver
}
extend type Mutation {
deletePost ( id : ID ! @idOf ( type : "Post" )): Boolean @resolver
}
Working with GlobalIDs
In Resolvers
GlobalIDs are automatically used in generated resolver signatures:
import viaduct.api.globalid.GlobalID
import viaduct.api.grts.User
import viaduct.api.grts.Post
@Resolver
class CreatePostMutation @Inject constructor (
private val postService: PostService
) : MutationResolvers . CreatePost () {
override suspend fun resolve (ctx: Context ): Post {
val input = ctx.arguments.input
// authorId is GlobalID<User>, not String
val authorId: GlobalID < User > = input.authorId
val tagIds: List < GlobalID < Tag >> = input.tagIds
// Extract internal ID
val internalAuthorId: String = authorId.internalID
val postId = postService. createPost (
title = input.title,
content = input.content,
authorId = internalAuthorId,
tagIds = tagIds. map { it.internalID }
)
return ctx. nodeFor (postId)
}
}
Creating GlobalIDs
Use ctx.globalIDFor() to create GlobalIDs:
import viaduct.api.grts.User
@Resolver
class SomeResolver : SomeResolvers . SomeField () {
override suspend fun resolve (ctx: Context ): Result {
val userId = "12345"
// Create a GlobalID<User>
val globalId = ctx. globalIDFor (
reflection = User.Reflection,
internalID = userId
)
return Result. Builder (ctx)
. userId (globalId)
. build ()
}
}
Creating Node References
Use ctx.nodeFor() to create node references:
@Resolver
class PostAuthorResolver @Inject constructor (
private val postService: PostService
) : PostResolvers . Author () {
override suspend fun resolve (ctx: Context ): User {
val post = ctx. object Value
val authorId = post. getAuthorId () // GlobalID<User>
// Create node reference - will call User node resolver
return ctx. nodeFor (authorId)
}
}
Access the wrapped internal ID:
val globalId: GlobalID < User > = ctx.arguments.userId
val internalId: String = globalId.internalID
// Use internal ID with your service layer
val user = userService. getUser (internalId)
GlobalID Structure
GlobalIDs encode both type and internal ID:
val userId = ctx. globalIDFor (User.Reflection, "12345" )
// Serialized format (opaque to clients):
// "User:12345" (actual encoding may vary)
// Access components:
val typeName: String = userId.typeName // "User"
val internalId: String = userId.internalID // "12345"
The exact encoding format is internal to Viaduct and may change. Never parse or construct GlobalID strings manually.
Type Safety Benefits
Compile-Time Checks
fun createPost (authorId: GlobalID < User >, categoryId: GlobalID < Category >) {
// This compiles
userService. getUser (authorId.internalID)
// This won't compile - type mismatch!
userService. getUser (categoryId.internalID)
}
Preventing Mix-ups
// Without GlobalID (unsafe)
fun deletePost (userId: String , postId: String ) {
// Easy to accidentally swap these!
postService. delete (userId, postId) // Wrong order!
}
// With GlobalID (safe)
fun deletePost (userId: GlobalID < User >, postId: GlobalID < Post >) {
// Won't compile if you swap them
postService. delete (postId, userId) // Compile error!
}
Relay Node Query
Viaduct automatically provides the node query:
extend type Query {
node ( id : ID ! ): Node
nodes ( ids : [ ID ! ] ! ): [ Node ] !
}
Usage
query GetUser {
node ( id : "User:12345" ) {
... on User {
id
name
email
}
}
}
query GetMultipleNodes {
nodes ( ids : [ "User:1" , "Post:2" , "User:3" ]) {
... on User {
id
name
}
... on Post {
id
title
}
}
}
The node query automatically routes to the correct node resolver based on the type encoded in the GlobalID.
Interface Constraints
If a Node type implements an interface with an id field, that interface must also implement Node:
# Good
interface Timestamped implements Node {
id : ID !
createdAt : DateTime !
updatedAt : DateTime !
}
type User implements Node & Timestamped {
id : ID !
name : String !
createdAt : DateTime !
updatedAt : DateTime !
}
# Bad - interface has id but doesn't implement Node
interface HasId {
id : ID ! # Error: must implement Node
}
Real-World Examples
Schema
Creating IDs
Using IDs
Mutation with IDs
type User implements Node {
id : ID !
username : String !
posts ( first : Int ): PostConnection ! @resolver
}
type Post implements Node {
id : ID !
title : String !
# GlobalID reference
authorId : ID ! @idOf ( type : "User" )
author : User @resolver
}
extend type Query {
user ( id : ID ! @idOf ( type : "User" )): User @resolver
post ( id : ID ! @idOf ( type : "Post" )): Post @resolver
}
extend type Mutation {
createPost ( input : CreatePostInput ! ): Post @resolver
}
input CreatePostInput {
title : String !
content : String !
authorId : ID ! @idOf ( type : "User" )
}
Reflection API
Each GRT provides a Reflection object for working with GlobalIDs:
import viaduct.api.grts.User
// Create GlobalID from string
val globalId = ctx. globalIDFor (User.Reflection, "12345" )
// Create from companion object method
val globalId2 = User.Reflection. globalId ( "12345" )
// Get type name
val typeName = User.Reflection.typeName // "User"
Best Practices
Always use @idOf for Node references
Declare ID types in your schema: # Good
authorId : ID ! @idOf ( type : "User" )
# Bad - just a string
authorId : ID !
Use ctx.nodeFor() for relationships
Create node references instead of building inline: // Good
return ctx. nodeFor (authorId)
// Bad
return User. Builder (ctx) .. . build ()
Extract internalID for service calls
Use the wrapped ID with your backend: val userId = ctx.arguments.id.internalID
userService. delete (userId)
Never parse GlobalID strings
Let Viaduct handle encoding/decoding: // Bad - don't do this!
val parts = globalIdString. split ( ":" )
// Good - use the API
val internalId = globalId.internalID
Comparison
Feature String IDs GlobalID<T> Type safety None Compile-time Relay compatible Manual Automatic Type encoding Manual Built-in Node routing Manual Automatic Accidental swaps Possible Prevented
Next Steps
Testing Learn how to test resolvers with GlobalIDs
Writing Resolvers Review resolver implementation patterns