Execute GraphQL queries and mutations from within resolvers using ctx.query() and ctx.mutation()
Subqueries enable resolvers to execute GraphQL operations against the root Query or Mutation types during field resolution. This provides a powerful way to compose complex resolver logic by reusing existing GraphQL operations.
@Resolver( objectValueFragment = "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() // Execute subquery to check if this user is the viewer 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 ln == null -> fn else -> "$fn $ln$suffix" } }}
Subquery variables are scoped to the subquery itself:
They don’t inherit from the parent request’s variables
They don’t leak back to the parent
Two subqueries with the same selection but different variables are fully independent
// These are two independent subqueriesval query1 = ctx.query( "{ listing(id: \$id) { title } }", variables = mapOf("id" to "123"))val query2 = ctx.query( "{ listing(id: \$id) { title } }", variables = mapOf("id" to "456") // Different variables)
Field getters on subquery results are suspend functions:
val query = ctx.query("{ currentUser { id name email } }")// These are suspend functionsval id = query.getCurrentUser()?.getId() // Suspends if not resolved yetval name = query.getCurrentUser()?.getName() // Suspends if not resolved yetval email = query.getCurrentUser()?.getEmail() // Suspends if not resolved yet
Your resolver can continue executing before the subquery has fully resolved. If you access a field that hasn’t resolved yet, the getter suspends until the value is available.
@Resolverclass UpdateAndPublishResolver @Inject constructor( val client: ListingServiceClient) : MutationResolvers.UpdateAndPublish() { override suspend fun resolve(ctx: Context): Listing { // Update the listing first client.update(ctx.arguments.input) // Execute a submutation to publish it val result = ctx.mutation( selection = "{ publishListing(id: \$id) { id title status } }", variables = mapOf("id" to ctx.arguments.id) ) // Return the published listing return ctx.nodeFor(ctx.arguments.id) }}
Mutation resolvers can freely call ctx.query() as well:
@Resolverclass CreateListingResolver : MutationResolvers.CreateListing() { override suspend fun resolve(ctx: Context): CreateListingPayload { // Create the listing val listingId = listingService.create(ctx.arguments.input) // Query the current user val query = ctx.query("{ currentUser { id name } }") val userId = query.getCurrentUser()?.getId() // Log the creation auditLog.record("Listing $listingId created by user $userId") return CreateListingPayload.Builder(ctx) .listingId(listingId) .build() }}
Subqueries run against the full schema, not any restricted client-facing view:
// Even if the parent request has scope restrictions,// subqueries can access the full internal schemaval query = ctx.query(""" { internalMetrics { # May not be visible to external clients requestCount errorRate } }""")
When a resolver issues a subquery, it’s consulting the complete graph, regardless of the request’s scope restrictions.
Subqueries can issue their own subqueries. A resolver invoked during subquery execution has the same ctx.query() and ctx.mutation() capabilities:
// Parent resolver@Resolverclass ListingRecommendationsResolver : QueryResolvers.ListingRecommendations() { override suspend fun resolve(ctx: Context): List<Listing> { // First subquery val query = ctx.query("{ currentUser { id preferences } }") val preferences = query.getCurrentUser()?.getPreferences() // Fetch recommendations based on preferences return recommendationService.getListings(preferences) }}// A resolver called during the subquery above might also use subqueries@Resolverclass UserPreferencesResolver : UserResolvers.Preferences() { override suspend fun resolve(ctx: Context): Preferences { // Nested subquery val query = ctx.query("{ defaultPreferences { theme language } }") val defaults = query.getDefaultPreferences() // Merge with user preferences return mergePreferences(ctx.objectValue, defaults) }}
All nested subqueries share the parent execution context and request-scoped state.
val query = ctx.query("{ currentUser { id } }")try { val email = query.getCurrentUser()?.getEmail() // Not in selection} catch (e: UnsetFieldException) { logger.error("Attempted to access field not in subquery selection")}
Errors from field resolution flow into the result’s error list:
val query = ctx.query("{ listing(id: \"invalid\") { title } }")// The query executes, but errors are trackedval listing = query.getListing() // May be null if resolution failed// Errors are separate from the parent queryif (listing == null) { logger.warn("Subquery returned null, check subquery errors")}
Errors from subqueries are attributed separately from the parent query, so they won’t silently contaminate the parent result.
The engine doesn’t know what you need until your resolver calls it
Each call triggers a separate execution with its own query plan
Useful for dynamic or conditional data requirements
@Resolver(objectValueFragment = "fragment _ on User { id }")class UserDisplayNameResolver : UserResolvers.DisplayName() { override suspend fun resolve(ctx: Context): String { val id = ctx.objectValue.getId() // Dynamic: only fetch if needed val query = if (shouldCheckViewer(id)) { ctx.query("{ currentUser { id } }") } else { null } // ... }}
Use @Resolver fragments when data requirements are known at registration time:
// ✅ Good: Static requirements@Resolver(objectValueFragment = "fragment _ on User { id name }")// ❌ Bad: Using subquery for static data@Resolverclass MyResolver { override suspend fun resolve(ctx: Context): String { val query = ctx.query("{ currentUser { id name } }") // ... }}
Request Only What You Need
Keep subquery selections minimal:
// ✅ Good: Request only needed fieldsctx.query("{ currentUser { id } }")// ❌ Bad: Over-fetchingctx.query("{ currentUser { id name email avatar preferences } }")
Handle Errors Gracefully
Always consider that subqueries can fail:
val query = ctx.query("{ currentUser { id } }")val userId = query.getCurrentUser()?.getId()if (userId == null) { logger.warn("Could not fetch current user") // Handle gracefully}
Use Variables for Dynamic Values
Never interpolate values directly into query strings:
// ✅ Good: Using variablesctx.query( "{ user(id: \$id) { name } }", variables = mapOf("id" to userId))// ❌ Bad: String interpolation (injection risk)ctx.query("{ user(id: \"$userId\") { name } }")