Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

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.

app/code/Mironsoft/Event/Model/ResourceModel/EventFavorite.php (added method)
/**
 * 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));
}
app/code/Mironsoft/Event/Model/Resolver/Batch/IsFavorite.php
<?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.