Batching and N+1
A field resolver runs once per row, so BatchResolverInterface collects those calls and lets you load everything in one query.
public function resolve(ContextInterface $context, Field $field, array $requests): BatchResponseN+1 is the shape of the problem: one query to fetch the list, then one more for every row in it. The products query runs a handful of queries for 20 products. A field resolver you added to ProductInterface then runs 20 times, and if each call asks a repository for data, you have added 20 round trips to a page that looked cheap.
BatchResolverInterface
A batch resolver is the same idea as an ordinary resolver, except Magento gathers every call to the same field and hands them over together. Implement Magento\Framework\GraphQl\Query\Resolver\BatchResolverInterface. Each item in $requests is a BatchRequestItemInterface exposing getValue(), getArgs() and getInfo() — the same three things an ordinary resolver gets, one set per call. You load once, then pair each request with its result through addResponse().
Registration does not change: the same @resolver directive in schema.graphqls. Magento inspects which interface the class implements. Magento\RelatedProductGraphQl\Model\Resolver\Batch\RelatedProducts is the core example to read.
Measuring it
The depth and complexity limits say nothing about database cost. Count the queries instead: bin/magento dev:query-log:enable --include-all-queries=true writes every statement to var/debug/db.log. Delete the file, send the query once, count, then bin/magento dev:query-log:disable. Passing the option is belt and braces: --include-all-queries already defaults to true, so the bare command logs everything too. What you must not do is pass --include-all-queries=false, because then only statements slower than --query-time-threshold are kept — 0.001 seconds unless you say otherwise — which hides exactly the problem you are looking for: many fast queries.
Example
<?php
// app/code/Learning/Faq/Model/Resolver/Product/FaqEntriesBatch.php
declare(strict_types=1);
namespace Learning\Faq\Model\Resolver\Product;
use Learning\Faq\Api\FaqRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\Resolver\BatchRequestItemInterface;
use Magento\Framework\GraphQl\Query\Resolver\BatchResolverInterface;
use Magento\Framework\GraphQl\Query\Resolver\BatchResponse;
use Magento\Framework\GraphQl\Query\Resolver\ContextInterface;
class FaqEntriesBatch implements BatchResolverInterface
{
public function __construct(private readonly FaqRepositoryInterface $faqRepository)
{
}
/**
* @inheritDoc
*/
public function resolve(ContextInterface $context, Field $field, array $requests): BatchResponse
{
$skus = [];
/** @var BatchRequestItemInterface $request */
foreach ($requests as $request) {
if (empty($request->getValue()['model'])) {
throw new LocalizedException(__('"model" value should be specified'));
}
/** @var ProductInterface $product */
$product = $request->getValue()['model'];
$skus[] = (string) $product->getSku();
}
// One query for the whole page, instead of one per product.
$entriesBySku = $this->faqRepository->getBySkus(array_unique($skus));
$response = new BatchResponse();
foreach ($requests as $request) {
$sku = (string) $request->getValue()['model']->getSku();
$response->addResponse($request, $entriesBySku[$sku] ?? []);
}
return $response;
}
}When to use it
- Replace a per-product repository call with one query for the whole product listing.
- Prove a slow GraphQL response is query count rather than PHP time.
- Resolve a list field such as related items or stock labels without multiplying queries.
- Keep a nested product list from selecting every attribute in the catalog.
More examples
Counting the queries before and after
This is the measurement that settles the argument. Run it twice, once with each resolver registered, and the difference is a number rather than an opinion. Leave query logging off afterwards: it writes on every request and the file grows quickly. Remember the log also records the queries Magento makes for the cache and the store config, so compare totals rather than expecting a round number.
rm -f var/debug/db.log
bin/magento dev:query-log:enable --include-all-queries=true
# DB query logging enabled.
curl -s -X POST https://example.test/graphql \
-H 'Content-Type: application/json' \
--data '{"query":"{ products(pageSize: 20) { items { sku faq_entries { question } } } }"}' \
> /dev/null
# Each statement is written on its own line as "SQL: <statement>",
# so match that prefix rather than the bare keyword.
grep -c '^SQL: SELECT' var/debug/db.log
# 26 <- per-product resolver: 20 extra selects
# 7 <- batch resolver: one select for the whole page
bin/magento dev:query-log:disable
# DB query logging disabled.Pairing results back, long and short
A batch resolver is mostly this kind of reshaping, so both forms turn up. The arrow function is a single expression with an implicit return; there is no block body and no use clause. If you need two statements, you need the long form. Core resolvers are full of the long form because they predate the short one, so read both fluently.
<?php
// Two ways to write the same mapping step inside a batch resolver.
// Longhand closure, which is the form core uses:
$items = array_map(
function ($entry) {
return ['uid' => $entry->getUid(), 'question' => $entry->getQuestion()];
},
$entries
);
// Shorthand arrow function, available since PHP 7.4. The same thing, except
// it captures $this and outer variables by value automatically.
$items = array_map(
fn ($entry) => ['uid' => $entry->getUid(), 'question' => $entry->getQuestion()],
$entries
);Loading only the fields the client asked for
Reach for this when your field returns products of its own, as related_products does. Loading every attribute for a nested product list is the second most common GraphQL performance problem after N+1, and it does not show up as extra queries, only as slower ones. Magento_RelatedProductGraphQl uses exactly this call before building its collection.
<?php
// Inside a batch resolver on a product field, before loading anything.
use Magento\CatalogGraphQl\Model\Resolver\Product\ProductFieldsSelector;
// $this->productFieldsSelector is injected through the constructor.
$fields = [];
foreach ($requests as $request) {
$fields[] = $this->productFieldsSelector->getProductFieldsFromInfo(
$request->getInfo(),
'faq_products' // the node name in the query to inspect
);
}
$attributes = array_unique(array_merge([], ...$fields));
// $attributes now holds only the product attributes this document needs,
// so the collection select stays narrow instead of loading everything.
Discussion