Solve the N+1 problem with efficient batch loading using Viaduct’s built-in DataLoader pattern
Batch resolution is Viaduct’s built-in solution to the N+1 problem in GraphQL. Instead of implementing separate DataLoader classes, you simply override the batchResolve function in your resolver, and Viaduct handles batching automatically.
Viaduct solves this by allowing you to implement batchResolve instead of the standard resolve function. The framework automatically batches contexts before passing them to your resolver.
Here’s a batch node resolver for the Listing type:
@Resolverclass ListingNodeResolver @Inject constructor( val listingClient: ListingClient) : NodeResolvers.Listing() { override suspend fun batchResolve(contexts: List<Context>): List<FieldValue<Listing>> { // Extract all IDs from the batch val listingIds = contexts.map { it.id.internalID } // Single batch call to the service val responses = listingClient.fetchBatch(listingIds) // Map results back to contexts in the same order return contexts.map { ctx -> val listingId = ctx.id.internalID val response = responses[listingId] if (response != null) { FieldValue.ofValue( Listing.Builder(ctx) .title(response.title) .description(response.description) .build() ) } else { FieldValue.ofError( IllegalArgumentException("Listing not found: $listingId") ) } } }}
Here’s a batch field resolver that fetches related data:
@Resolver(objectValueFragment = "fragment _ on Character { id }")class CharacterFilmCountResolver @Inject constructor( val characterFilmsRepository: CharacterFilmsRepository) : CharacterResolvers.FilmCount() { override suspend fun batchResolve(contexts: List<Context>): List<FieldValue<Int>> { // Extract unique character IDs val characterIds = contexts.map { it.objectValue.getId().internalID }.toSet() // Single batch query for all film counts val filmCounts = characterIds.associateWith { characterId -> characterFilmsRepository.findFilmsByCharacterId(characterId).size } // Map results back to contexts return contexts.map { ctx -> val characterId = ctx.objectValue.getId().internalID FieldValue.ofValue(filmCounts[characterId] ?: 0) } }}
override suspend fun batchResolve(contexts: List<Context>): List<FieldValue<User>> { val ids = contexts.map { it.id.internalID } val results = userService.fetchBatch(ids) // May return partial results return contexts.map { ctx -> val id = ctx.id.internalID val user = results[id] if (user != null) { FieldValue.ofValue(buildUser(ctx, user)) } else { // Return error for missing users, but allow request to continue FieldValue.ofError( IllegalArgumentException("User not found: $id") ) } }}
In objectValueFragment, request only the fields you actually need:
// ✅ Good: Request only what's needed@Resolver(objectValueFragment = "fragment _ on Character { id }")// ❌ Bad: Requesting unnecessary fields@Resolver(objectValueFragment = "fragment _ on Character { id name email avatar }")
Deduplicate IDs
Remove duplicate IDs before hitting the data layer:
val uniqueIds = contexts.map { it.id.internalID }.toSet()val results = dataSource.fetchBatch(uniqueIds.toList())
Maintain Order
Always return results in the same order as input contexts:
// ✅ Good: Map each context to its resultreturn contexts.map { ctx -> FieldValue.ofValue(results[ctx.id.internalID])}// ❌ Bad: Returning results in arbitrary orderreturn results.values.map { FieldValue.ofValue(it) }