The N+1 problem is especially acute in GraphQL because resolvers compose independently without awareness of each other. A query for 20 products that each have categories will trigger 20 separate category queries without batching. Magento 2’s BatchServiceContractResolverInterface and the DataLoader pattern solve this. I show both approaches with cache tag integration.
The N+1 problem in GraphQL resolvers
<?php
// A simple product resolver - looks innocent
class ProductResolver implements ResolverInterface
{
public function resolve(Field $field, $context, ResolveInfo $info, ?array $value = null, ?array $args = null): array
{
// For each product in the list, this resolver fires once
// If the query returns 20 products with categories,
// this fires 20 times -> 20 separate DB queries for categories!
$product = $this->productRepository->getById($value['id']);
return [
'sku' => $product->getSku(),
'name' => $product->getName(),
'price' => $product->getPrice(),
];
}
}
// GraphQL query that triggers N+1:
// {
// products(pageSize: 20) {
// items {
// name
// categories { name } <- triggers 1 resolver call per product
// }
// }
// }
// Result: 1 query for products + 20 queries for categories = 21 queries
BatchServiceContractResolverInterface - Magento's solution
<?php
declare(strict_types=1);
namespace Vendor\Module\Model\Resolver;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\Resolver\BatchRequestItemInterface;
use Magento\Framework\GraphQl\Query\Resolver\BatchServiceContractResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
class BatchCategoryResolver implements BatchServiceContractResolverInterface
{
public function __construct(
private \Magento\Catalog\Api\CategoryRepositoryInterface $categoryRepository,
private \Magento\Framework\Api\SearchCriteriaBuilder $searchCriteriaBuilder
) {}
// Return the service contract class and method to batch calls through
public function getServiceContract(): array
{
return [BatchCategoryService::class, 'getByProductIds'];
}
// Map a single resolver request to service contract arguments
public function convertToServiceArgument(BatchRequestItemInterface $request)
{
// Each call provides its product ID - Magento collects them all
$value = $request->getValue();
return new CategoryLookupRequest($value['entity_id']);
}
// Map service contract result back to GraphQL resolver result
public function convertFromServiceResult($result, BatchRequestItemInterface $request): array
{
if (!$result instanceof CategoryListResult) {
return [];
}
return array_map(fn($cat) => [
'id' => $cat->getId(),
'name' => $cat->getName(),
'url' => $cat->getUrl(),
], $result->getCategories());
}
}
// Batch service - receives ALL product IDs at once
class BatchCategoryService
{
public function __construct(
private \Magento\Catalog\Model\ResourceModel\Category\CollectionFactory $collectionFactory,
private \Magento\Framework\App\ResourceConnection $resourceConnection
) {}
/** @param CategoryLookupRequest[] $requests */
public function getByProductIds(array $requests): array
{
// Extract all product IDs from all requests
$productIds = array_map(fn($r) => $r->getProductId(), $requests);
// ONE query for all products instead of N separate queries
$connection = $this->resourceConnection->getConnection();
$select = $connection->select()
->from(['cp' => 'catalog_category_product'], ['product_id', 'category_id'])
->where('cp.product_id IN (?)', $productIds);
$rows = $connection->fetchAll($select);
// Group categories by product_id
$categoryIdsByProduct = [];
foreach ($rows as $row) {
$categoryIdsByProduct[$row['product_id']][] = (int)$row['category_id'];
}
// Load all categories in one query
$allCategoryIds = array_unique(array_merge(...array_values($categoryIdsByProduct)));
$categories = $this->loadCategories($allCategoryIds);
// Return results mapped back to each request
return array_map(function($request) use ($categoryIdsByProduct, $categories) {
$pid = $request->getProductId();
$catIds = $categoryIdsByProduct[$pid] ?? [];
$cats = array_filter($categories, fn($c) => in_array($c->getId(), $catIds));
return new CategoryListResult(array_values($cats));
}, $requests);
}
private function loadCategories(array $ids): array
{
if (empty($ids)) return [];
$collection = $this->collectionFactory->create();
$collection->addAttributeToFilter('entity_id', ['in' => $ids]);
$collection->addAttributeToSelect(['name', 'url_key', 'is_active']);
return $collection->getItems();
}
}
Cache tags integration
<?php
declare(strict_types=1);
namespace Vendor\Module\Model\Resolver;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
// Adding cache tags to GraphQL responses for Varnish invalidation
class CachedProductResolver implements ResolverInterface
{
public function __construct(
private \Magento\Catalog\Api\ProductRepositoryInterface $productRepository,
private \Magento\Framework\App\Cache\Tag\Resolver $tagResolver
) {}
public function resolve(Field $field, $context, ResolveInfo $info, ?array $value = null, ?array $args = null): array
{
$product = $this->productRepository->getById((int)($args['id'] ?? 0));
// Collect cache tags for this product
$cacheTags = $this->tagResolver->getTags($product);
// Add tags to the response context so Magento can set X-Magento-Tags header
$context->getExtensionAttributes()->addData([
'cache_tags' => $cacheTags
]);
return [
'id' => $product->getId(),
'sku' => $product->getSku(),
'name' => $product->getName(),
'price' => $product->getPrice(),
'model' => $product, // pass model for child resolvers
];
}
}
Summary
GraphQL N+1 is a silent performance killer. Without batching, a query for 20 products with categories generates 21 queries. BatchServiceContractResolverInterface collects all resolver calls for the same field, runs ONE batch query, and maps results back to individual resolvers. The pattern requires more code upfront but eliminates database load proportional to result set size. Combined with cache tag integration for Varnish invalidation, you get both fast uncached responses and correct cache invalidation.
