Understanding the N+1 Problem and Solving It With Batch Resolvers
Understanding the N+1 Problem and Solving It With Batch Resolvers
~9 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
The is_favorite field from chapter 17 works correctly - but it has a hidden performance problem that hits with every list of multiple events. This chapter makes the problem concrete and solves it with a batch resolver.
Making the problem visible
A list of 20 events with is_favorite in the selection set calls IsFavorite::resolve() twenty times - each call internally triggers its own SELECT query against mironsoft_event_customer_favorite. A single GraphQL request thus causes 21 database queries (one for the list itself, plus 20 for is_favorite) instead of the two that would actually be needed. That's exactly the classic N+1 problem, named after this formula: one query for the list (1), plus one per list entry (N).
query {
events(pageSize: 20) {
items {
title
is_favorite # triggers 20 separate DB queries
}
}
}The solution: batching all requests of one pass
A batch resolver doesn't resolve a single value - it receives all pending requests for the same field within a single GraphQL pass bundled together - here, all 20 is_favorite calls at once. That makes it possible to reduce the database query from 20 individual queries down to a single WHERE event_id IN (...) query.
/**
* Loads the set of event IDs the given customer has favorited, restricted
* to the given candidate event IDs - a single batched query.
*
* @param int $customerId
* @param int[] $eventIds
* @return int[] Event IDs that are favorites
*/
public function getFavoriteEventIds(int $customerId, array $eventIds): array
{
if ($eventIds === []) {
return [];
}
$connection = $this->resourceConnection->getConnection();
$select = $connection->select()
->from($this->resourceConnection->getTableName(self::TABLE_NAME), ['event_id'])
->where('customer_id = ?', $customerId)
->where('event_id IN (?)', $eventIds);
return array_map('intval', $connection->fetchCol($select));
}<?php
declare(strict_types=1);
namespace Mironsoft\Event\Model\Resolver\Batch;
use Magento\Authorization\Model\UserContextInterface;
use Magento\Framework\GraphQl\Query\Resolver\BatchRequestItemInterface;
use Magento\Framework\GraphQl\Query\Resolver\BatchResponse;
use Magento\Framework\GraphQl\Query\Resolver\BatchResolverInterface;
use Mironsoft\Event\Api\Data\EventInterface;
use Mironsoft\Event\Model\ResourceModel\EventFavorite;
/**
* Batched resolver for the is_favorite field - collapses N per-event lookups
* into a single query per GraphQL request.
*/
class IsFavorite implements BatchResolverInterface
{
/**
* @param EventFavorite $eventFavoriteResource Resource model for the favorites linkage table
*/
public function __construct(
private readonly EventFavorite $eventFavoriteResource,
) {
}
/**
* Resolves is_favorite for every requested event in a single batch.
*
* @param BatchRequestItemInterface[] $requests One entry per Event in the current selection
* @return BatchResponse
*/
public function resolve(array $requests): BatchResponse
{
$response = new BatchResponse();
$context = $requests !== [] ? $requests[0]->getContext() : null;
$isCustomer = $context !== null
&& $context->getUserType() === UserContextInterface::USER_TYPE_CUSTOMER
&& $context->getExtensionAttributes()->getIsCustomer();
if (!$isCustomer) {
foreach ($requests as $request) {
$response->addResponse($request, false);
}
return $response;
}
$eventIds = [];
foreach ($requests as $request) {
/** @var EventInterface|null $event */
$event = ($request->getValue())['model'] ?? null;
if ($event !== null && $event->getEventId() !== null) {
$eventIds[] = $event->getEventId();
}
}
$customerId = (int) $context->getUserId();
$favoriteEventIds = $this->eventFavoriteResource->getFavoriteEventIds($customerId, $eventIds);
foreach ($requests as $request) {
/** @var EventInterface|null $event */
$event = ($request->getValue())['model'] ?? null;
$isFavorite = $event !== null && in_array($event->getEventId(), $favoriteEventIds, true);
$response->addResponse($request, $isFavorite);
}
return $response;
}
}Instead of N individual calls, resolve() here collects all requests of the current pass ($requests), determines in exactly one database query which of the contained event_ids are favorites, and then maps the answers back to the individual requests - the database only ever sees one query instead of twenty.
The schema registration doesn't change
extend type Event {
is_favorite: Boolean!
@resolver(class: "Mironsoft\\Event\\Model\\Resolver\\Batch\\IsFavorite")
}The @resolver directive automatically detects whether the referenced class implements ResolverInterface or BatchResolverInterface - no extra schema flag is needed, just the swapped class name.
Achtung: Batch resolvers only bundle requests within a single GraphQL request - not across multiple HTTP requests. A single request with 20 events gets bundled into one database query, but 20 separate HTTP requests with one event each still trigger 20 individual database queries - only caching (chapter 21) or client-side response batching helps there.
Tipp: Not every N+1 problem needs a batch resolver right away. For fields with few expected list entries (e.g. at most 5) or rare calls, the extra code often isn't worth it - a good rule of thumb is to only batch once you notice response time impact, or for lists that are foreseeably large (like the events list with pageSize up to 20+).
With the N+1 problem solved, chapter 21 turns to a related but different optimization layer: caching entire GraphQL responses.