PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

GraphQL batch loading – BatchServiceContractResolverInterface, cache tags, N+1 fix

by Henryk Tews / Tuesday, 01 July 2025 / Published in Magento 2

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.

About Henryk Tews

What you can read next

Xdebug – configuration, PHPStorm, debugging Magento plugins
Strategy pattern in PHP – and how Magento 2 uses it in pricing

© 2026 Created by

TOP
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Always active
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Manage options
  • Manage services
  • Manage {vendor_count} vendors
  • Read more about these purposes
Zobacz preferencje
  • {title}
  • {title}
  • {title}