Viaduct provides a field classification system to control the visibility and usage of fields within your GraphQL schema. This system distinguishes between fields that are publicly accessible, internal-only, or used purely for backing data storage.
Field Types
Viaduct recognizes three main categories of fields:
Public Fields - Accessible to clients through the GraphQL API
Private Fields - Internal-only fields for resolver logic
Backing Data Fields - Storage fields with dynamic typing
Public Fields
Public fields are standard GraphQL fields that are exposed to clients based on their scope configuration.
Basic Public Field
type User @scope ( to : [ "viaduct" , "viaduct:public" ]) {
id : ID !
name : String
email : String
avatar : String
}
All fields in this example are public and visible to any client with access to the viaduct or viaduct:public scopes.
Scope-Restricted Public Fields
You can further restrict public fields using type extensions:
type User @scope ( to : [ "viaduct" , "viaduct:public" ]) {
id : ID !
name : String
}
extend type User @scope ( to : [ "viaduct" ]) {
email : String
internalNotes : String
}
Here, email and internalNotes are public fields but only visible to clients with the viaduct scope.
Private Fields
Private fields are fields that are only visible to the defining tenant module. They are used when you want to hold certain data only for internal calculation purposes.
Purpose
Private fields are used for:
Intermediate computation results
Caching data between resolvers within the same request
Storing data that should not be accessible to other tenant modules
Internal implementation details
Scope Annotation
To make a field private to the Viaduct service (not accessible to client services), use the viaduct-private scope:
type User @scope ( to : [ "viaduct" ]) {
id : ID !
name : String
}
extend type User @scope ( to : [ "viaduct-private" ]) {
cachedPreferences : JSON
computedScore : Float
}
As of the current version, “private to tenant module” visibility is not yet supported. Use @scope(to: ["viaduct-private"]) to prevent fields from being visible to Viaduct client services.
Accessing Private Fields
Private fields are accessed in resolvers using fragments:
@Resolver (
object ValueFragment = """
fragment _ on User {
id
name
cachedPreferences
}
"""
)
class UserRecommendationsResolver : UserResolvers . Recommendations () {
override suspend fun resolve (ctx: Context ): List < Recommendation > {
val userId = ctx. object Value. getId ()
val preferences = ctx. object Value. getCachedPreferences ()
return recommendationService. getRecommendations (userId, preferences)
}
}
Backing Data Fields
Backing data fields use the special BackingData scalar type, which allows dynamic typing and is only allowed on private fields .
The BackingData Scalar
For all types other than BackingData, code generation for private fields happens normally. For BackingData fields:
Getters and setters are not generated
Runtime casting is used via dynamic setter/builder and getter methods
Type checking happens at write time
Declaring Backing Data Fields
Use the @backingData directive to specify the actual type:
type Product @scope ( to : [ "viaduct" ]) {
id : ID !
name : String
}
extend type Product @scope ( to : [ "viaduct-private" ]) {
cachedDetails : BackingData @backingData ( class : "com.example.data.ProductDetails" )
}
Working with Backing Data
Backing data fields require special handling in resolvers:
// Setting backing data
@Resolver
class ProductNodeResolver @Inject constructor (
private val productService: ProductService
) : NodeResolvers . Product () {
override suspend fun resolve (ctx: Context ): Product {
val productId = ctx.id.internalID
val details = productService. fetchDetails (productId)
return Product. Builder (ctx)
. name (details.name)
. set ( "cachedDetails" , details) // Dynamic setter
. build ()
}
}
// Reading backing data
@Resolver (
object ValueFragment = """
fragment _ on Product {
id
cachedDetails
}
"""
)
class ProductDescriptionResolver : ProductResolvers . Description () {
override suspend fun resolve (ctx: Context ): String {
// Dynamic getter with runtime casting
val details = ctx. object Value. get ( "cachedDetails" ) as ProductDetails
return formatDescription (details)
}
}
When to Use Backing Data
Use backing data fields when:
You need to cache complex data structures between resolvers
The data structure is internal and doesn’t need to be part of the GraphQL schema
You want to avoid creating intermediate GraphQL types
The data is purely for resolver implementation details
Backing data fields provide flexibility but lose type safety. Use them sparingly and consider creating proper GraphQL types when possible.
Field Stability Annotations
While not enforced by Viaduct itself, many teams use documentation conventions to mark field stability:
Stable Fields
Fields considered part of the stable public API:
type User @scope ( to : [ "viaduct:public" ]) {
"Stable field: Unique identifier for the user"
id : ID !
"Stable field: User's display name"
name : String !
}
Beta Fields
Fields that may change:
extend type User @scope ( to : [ "viaduct:public" ]) {
"Beta field: Experimental preference system. Subject to change."
preferences : UserPreferences @deprecated ( reason : "Use preferencesV2" )
"Beta field: New preference system"
preferencesV2 : UserPreferencesV2
}
Internal Fields
Fields explicitly marked as internal:
extend type User @scope ( to : [ "viaduct" ]) {
"Internal only: Admin notes and moderation data"
internalNotes : String
"Internal only: System flags for internal processing"
systemFlags : [ String ]
}
Deprecation
Use the @deprecated directive to mark fields for removal:
type User @scope ( to : [ "viaduct:public" ]) {
id : ID !
name : String @deprecated ( reason : "Use firstName and lastName instead" )
firstName : String
lastName : String
}
Deprecated fields:
Remain in the schema for backward compatibility
Show deprecation warnings in introspection
Can be removed after client migration
Classification Best Practices
Use Public Fields by Default Most fields should be public and controlled via scopes. Only use private fields when necessary.
Document Field Purpose Add descriptions to fields explaining their purpose and stability guarantees.
Minimize Backing Data Use backing data sparingly. Prefer proper GraphQL types for better type safety.
Plan Deprecations Use @deprecated directive and plan migration timelines before removing fields.
Example: Complete Classification
# Public fields for all clients
type Listing @scope ( to : [ "viaduct" , "viaduct:public" ]) {
id : ID !
title : String !
description : String
price : BigDecimal !
}
# Internal fields for backend services
extend type Listing @scope ( to : [ "viaduct" ]) {
hostId : ID !
internalStatus : String
moderationNotes : String
}
# Private fields for resolver implementation
extend type Listing @scope ( to : [ "viaduct-private" ]) {
# Backing data for caching
cachedDetails : BackingData @backingData ( class : "com.example.ListingDetails" )
# Computed intermediate values
scoreComponents : JSON
}
# Deprecated fields
extend type Listing @scope ( to : [ "viaduct:public" ]) {
oldPriceField : String @deprecated ( reason : "Use price instead" )
}
Resolver Example: Using Different Field Types
@Resolver
class ListingNodeResolver @Inject constructor (
private val listingService: ListingService
) : NodeResolvers . Listing () {
override suspend fun resolve (ctx: Context ): Listing {
val listingId = ctx.id.internalID
val details = listingService. fetchDetails (listingId)
return Listing. Builder (ctx)
// Public fields
. title (details.title)
. description (details.description)
. price (details.price)
// Internal fields (only in viaduct scope)
. hostId (GlobalID. from ( "User" , details.hostId))
. internalStatus (details.status)
// Private backing data field
. set ( "cachedDetails" , details)
. build ()
}
}
@Resolver (
object ValueFragment = """
fragment _ on Listing {
id
cachedDetails
scoreComponents
}
"""
)
class ListingScoreResolver : ListingResolvers . Score () {
override suspend fun resolve (ctx: Context ): Float {
// Access private backing data field
val details = ctx. object Value. get ( "cachedDetails" ) as ListingDetails
// Access private computed field
val components = ctx. object Value. getScoreComponents ()
return calculateScore (details, components)
}
}
Type Safety Considerations
Public Fields: Full Type Safety
// Compile-time type checking
val title: String = ctx. object Value. getTitle ()
val price: BigDecimal = ctx. object Value. getPrice ()
Private Fields: Full Type Safety
// Compile-time type checking (except BackingData)
val preferences: JSON = ctx. object Value. getCachedPreferences ()
val score: Float = ctx. object Value. getComputedScore ()
Backing Data Fields: Runtime Type Safety
// Runtime casting required
val details = ctx. object Value. get ( "cachedDetails" ) as ListingDetails
// Type checking happens at write time
builder. set ( "cachedDetails" , details) // Type checked
builder. set ( "cachedDetails" , "wrong" ) // Runtime error
See Also
Schema Scopes Learn about controlling field visibility with @scope
Schema Management Best practices for evolving your schema over time