Re-entrancy is the ability for logic hosted on Viaduct to compose with other logic hosted on Viaduct by issuing GraphQL fragments and queries. It’s the key mechanism that enables modularity in large Viaduct applications.
Instead of modules calling each other’s code directly, they compose by issuing GraphQL fragments. This provides loose coupling while maintaining type safety and performance through automatic batching.
In traditional monolithic architectures, modules become tightly coupled through direct function calls:
Traditional Approach (Tight Coupling)
class DisplayNameResolver( private val userService: UserService // Direct dependency!) { fun resolve(userId: String): String { val user = userService.getUser(userId) return "${user.firstName} ${user.lastName.first()}." }}
Problems with this approach:
Tight coupling: Changes to UserService affect all consumers
No batching: Each call to getUser() is independent
Hard to test: Must mock the entire UserService
Monolith hazards: Circular dependencies, version conflicts, build coupling
Viaduct’s re-entrant approach solves these issues:
Viaduct Approach (Loose Coupling via Fragments)
@Resolver(""" fragment _ on User { firstName lastName }""")class DisplayNameResolver : UserResolvers.DisplayName() { override suspend fun resolve(ctx: Context): String { val first = ctx.objectValue.getFirstName() val last = ctx.objectValue.getLastName() return "$first ${last.first()}." }}
Benefits:
Loose coupling: Only depends on schema, not code
Automatic batching: Engine batches all requests for firstName and lastName
Type safety: Generated code ensures fields exist
Easy testing: Mock GraphQL responses, not internal services
Fragment-Based
Declare data needs as GraphQL fragments, not function calls
Automatic Batching
Engine automatically batches and deduplicates field requests
Type-Safe
Generated code enforces that requested fields exist in schema
Testable
Test resolvers in isolation with mocked GraphQL responses
@Resolver(""" fragment _ on Character { name birthYear }""")class CharacterDisplaySummaryResolver : CharacterResolvers.DisplaySummary() { override suspend fun resolve(ctx: Context): String { val name = ctx.objectValue.getName() val birthYear = ctx.objectValue.getBirthYear() return "$name (born $birthYear)" }}
The engine:
Sees your fragment requesting name and birthYear
Batches all requests for those fields across all resolver instances
Invokes the resolvers for those fields once
Makes results available via ctx.objectValue
The fragment name (the _ after fragment) is arbitrary. Viaduct only cares about the selection set inside the fragment.
For simple cases where you just delegate to another field:
@Resolver("name") // Shorthand for: fragment _ on Character { name }class CharacterDisplayNameResolver : CharacterResolvers.DisplayName() { override suspend fun resolve(ctx: Context): String? { return ctx.objectValue.getName() }}
@Resolver("fragment _ on User { id firstName lastName }")class UserDisplayNameResolver : UserResolvers.DisplayName() { override suspend fun resolve(ctx: Context): String? { val id = ctx.objectValue.getId() val fn = ctx.objectValue.getFirstName() val ln = ctx.objectValue.getLastName() // Determine if user is the logged-in user val query = ctx.query("{ viewer { user { id } } }") val isViewer = id == query.getViewer()?.getUser()?.getId() val suffix = if (isViewer) " (you!)" else "" return when { fn == null && ln == null -> null fn == null -> ln + suffix ln == null -> fn + suffix else -> "$fn $ln$suffix" } }}
@Resolverclass UpdateAndPublishResolver @Inject constructor( val client: ListingServiceClient) : MutationResolvers.UpdateAndPublish() { override suspend fun resolve(ctx: Context): Listing { // Update via external service client.update(ctx.arguments.input) // Publish via another mutation val result = ctx.mutation( "{ publishListing(id: \$id) { id title } }", variables = mapOf("id" to ctx.arguments.id) ) return ctx.nodeFor(ctx.arguments.id) }}
ctx.mutation() is only available in mutation resolver contexts. It executes top-level fields serially, matching standard GraphQL mutation semantics.
@Resolver(""" fragment _ on User { firstName lastName }""")class UserDisplayNameResolver @Inject constructor() : UserResolvers.DisplayName() { override suspend fun resolve(ctx: Context): String { val first = ctx.objectValue.getFirstName() val last = ctx.objectValue.getLastName() return "$first ${last?.first() ?: ""}." }}
query { allCharacters { name homeworld { name } # Each character needs this! }}
Without batching (N+1 problem):
// Called 100 times, once per characterfun resolveHomeworld(characterId: String): Planet { return planetClient.getPlanet(characterId) // 100 separate requests!}
With Viaduct’s re-entrancy:
@Resolver("fragment _ on Character { homeworldId }")class CharacterHomeworldResolver @Inject constructor( private val planetClient: PlanetClient) : CharacterResolvers.Homeworld() { // Engine batches all 100 characters together override suspend fun batchResolve( contexts: List<Context> // All 100 contexts at once! ): List<FieldValue<Planet>> { // Extract all homeworld IDs val planetIds = contexts.map { it.objectValue.getHomeworldId() } // Single batch request for all planets val planets = planetClient.getPlanets(planetIds) // Map results back to contexts return contexts.map { ctx -> val planetId = ctx.objectValue.getHomeworldId() FieldValue.ofValue(planets[planetId]!!) } }}
The engine automatically:
Collects all 100 characters that need homeworld
Calls batchResolve once with all 100 contexts
Enables a single batch request to the planet service
This batching happens automatically. You don’t need to set up data loaders or coordinate batch requests manually.
✓ Declare data needs in @Resolver fragments✓ Implement batchResolve for fields accessed by multiple parents✓ Use ctx.query() when data needs are conditional✓ Let the engine handle batching and deduplication✓ Test resolvers by mocking GraphQL responses