Getting GraphQL Caching Per Customer Group Right in Magento
AI generated
M2
di.xml
Magento 2 · GraphQL · Caching
GraphQL Caching Per Customer Group
how cache key factors handle customer group dependent prices correctly

Once prices or visibility depend on customer group, a naive cache can serve the same response to different customers, with wrong prices in the worst case. This article explains how Magento's built-in GraphQL response cache already folds customer group into its cache key by default, where custom resolver-level caches can still get it wrong, and how this plays together correctly with Varnish and a CDN in front of it.

14 min read CacheIdFactorProviderInterface Varnish · CDN edge caching

1. Why customer group dependent prices break naive response caching

Once prices, negotiated terms, or the visibility of individual products depend on customer group, the very same GraphQL query returns different, each individually correct, responses depending on the requesting customer. A cache that keys purely on URL or raw query text cannot represent that difference, and risks serving the wrong response to the wrong customer.

In the worst case, a customer group with negotiated terms gets served the response computed for a different group straight out of the cache, with wrong prices or incorrectly visible or invisible products. This failure mode is easy to miss in testing with a single test account, since that never compares two different customer groups against each other.

What makes this particularly tricky is that such a bug usually does not stand out immediately, the response is syntactically perfectly valid, the query returns data, and no error message points to a problem. Only a deliberate comparison between two customer groups, or a complaint from a customer who noticed a wrong price, actually surfaces the issue, which is why customer group aware caching deserves to be designed in from the start rather than patched in afterward.

2. Magento's built-in mechanism: CacheIdCalculator and the customer group factor

Since the Magento_GraphQlCache module, Magento\GraphQlCache\Model\CacheId\CacheIdCalculator computes an X-Magento-Cache-Id value for every cacheable request, a hash over several registered factors. Each factor implements CacheIdFactorProviderInterface with the methods getFactorName and getFactorValue.

Customer group is already included out of the box, Magento\CustomerGraphQl\CacheIdFactorProviders\CustomerGroupProvider supplies the customer group ID as the CUSTOMER_GROUP factor, complemented by store, currency, tax rate, and login status from neighboring providers. The built-in response cache already separates different customer groups correctly.


<?php
declare(strict_types=1);

namespace Magento\GraphQlCache\Model\CacheId;

use Magento\Framework\GraphQl\Query\Resolver\ContextInterface;

/**
 * Contract for a single factor that feeds into the X-Magento-Cache-Id calculation.
 */
interface CacheIdFactorProviderInterface
{
    /**
     * Unique name of the factor within the hash calculation.
     *
     * @return string
     */
    public function getFactorName(): string;

    /**
     * Current value of the factor for the given GraphQL request context.
     *
     * @param ContextInterface $context
     * @return string
     */
    public function getFactorValue(ContextInterface $context): string;
}

3. Where it still goes wrong: resolver-level caching as a separate layer

The response cache is not the only cache layer, Magento_GraphQlResolverCache allows caching individual, expensive resolver results through Resolver\IdentityInterface and its own cache key mechanism, independent of whether the complete response is response-cacheable at all.

This mechanism does not know about customer group automatically. Any custom resolver that participates in the resolver cache must fold the relevant context dimensions into its own cache key itself, otherwise exactly the mixing that the response cache already prevents happens at this layer instead.

4. Real-world example: a manually cached resolver missing the customer group component

An obvious, and in practice common, mistake happens when a developer caches an expensive custom resolver directly with CacheInterface, for example the result of an expensive ERP price calculation, and builds the cache key from the product SKU alone.

The corrected version takes the customer group ID from the GraphQL context as an additional component of the cache key, which guarantees that two customer groups share the same resolver code but never the same cache entry.


<?php
declare(strict_types=1);

namespace Vendor\ErpPricing\Model\Resolver;

use Magento\Framework\App\CacheInterface;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;

/**
 * Example resolver that caches an expensive ERP price calculation.
 */
class ErpNegotiatedPrice implements ResolverInterface
{
    /**
     * @param CacheInterface $cache
     */
    public function __construct(private readonly CacheInterface $cache)
    {
    }

    /**
     * @param Field $field
     * @param mixed $context
     * @param ResolveInfo $info
     * @param array|null $value
     * @param array|null $args
     * @return float
     */
    public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null): float
    {
        $sku = $value['sku'];

        // WRONG: the cache key only contains the SKU, every customer group shares the same entry.
        // $cacheKey = 'erp_price_' . $sku;

        // CORRECT: the customer group ID from the GraphQL context extends the cache key,
        // so two customer groups never share the same price cache entry.
        $customerGroupId = $context->getExtensionAttributes()->getCustomerGroupId();
        $cacheKey = 'erp_price_' . $sku . '_group_' . $customerGroupId;

        $cached = $this->cache->load($cacheKey);
        if ($cached !== false) {
            return (float) $cached;
        }

        $price = $this->fetchNegotiatedPriceFromErp($sku, $customerGroupId);
        $this->cache->save((string) $price, $cacheKey, [], 600);

        return $price;
    }

    /**
     * @param string $sku
     * @param int $customerGroupId
     * @return float
     */
    private function fetchNegotiatedPriceFromErp(string $sku, int $customerGroupId): float
    {
        // ... expensive ERP call, heavily simplified here
        return 0.0;
    }
}

5. Writing a custom CacheIdFactorProviderInterface for extra context

If customer group alone is not enough, for example because B2B companies within the same customer group get different negotiated prices through a shared catalog, an extra factor can be added analogous to the core pattern.

Registration happens as another array item on the idFactorProviders constructor argument of CacheIdCalculator, following exactly the same pattern the core already uses to wire up store, currency, and customer group providers.


<?php
declare(strict_types=1);

namespace Vendor\B2bCaching\Model\CacheIdFactorProviders;

use Magento\Framework\GraphQl\Query\Resolver\ContextInterface;
use Magento\GraphQlCache\Model\CacheId\CacheIdFactorProviderInterface;

/**
 * Adds the B2B company ID as its own factor in the cache ID calculation,
 * for cases where the same customer group has different company prices.
 */
class CompanyIdProvider implements CacheIdFactorProviderInterface
{
    /**
     * @return string
     */
    public function getFactorName(): string
    {
        return 'COMPANY_ID';
    }

    /**
     * @param ContextInterface $context
     * @return string
     */
    public function getFactorValue(ContextInterface $context): string
    {
        return (string) ($context->getExtensionAttributes()->getCompanyId() ?? '0');
    }
}

6. Interaction with Varnish: the header flows into the VCL hash

The bundled Varnish VCL folds the X-Magento-Cache-Id header computed by Magento directly into its own hash calculation, complemented by store and Content-Currency headers as well as an addition for authenticated requests carrying a bearer token.

Varnish itself does not need to know anything about customer groups for this, it relies entirely on Magento performing the calculation correctly. A response is additionally only ever cached when the cache ID value sent with the request matches the one in the response.


# Excerpt from the bundled varnish6.vcl, simplified
sub process_graphql_headers {
    if (req.http.X-Magento-Cache-Id) {
        hash_data(req.http.X-Magento-Cache-Id);
        hash_data(req.http.Store);
        hash_data(req.http.Content-Currency);
        if (req.http.Authorization) {
            hash_data("Authorized");
        }
    }
}

7. CDN edge caching in front of Varnish: the actual risk

An additional CDN in front of Varnish brings its own caching rules that do not necessarily respect Magento's headers. If a CDN caches GraphQL responses purely by URL, for example for GET requests using persisted queries, it can serve incorrect, cross-group responses despite correct Magento and Varnish behavior.

The fix is either to configure the CDN so that the relevant Magento headers explicitly feed into its own cache key, or to exclude GraphQL requests from caching at the CDN entirely and leave that job fully to Varnish.

8. How this differs from the general Varnish GraphQL article

A general article on Varnish configuration for GraphQL typically covers fundamentals such as VCL structure, cache tags for invalidation, and timeouts. This article picks up exactly where customer group concretely flows into the cache key calculation, at both the Magento and the CDN level.

Anyone combining both topics should first make sure the basic Varnish setup is correct, and only then check the customer group specific factors and resolver cache pitfalls described here, since a broken base configuration makes any further optimization worthless.

9. Testing and verifying against price leakage between customer groups

The most reliable test runs the same query with two different, authenticated customer groups and compares both the returned prices and the returned X-Magento-Cache-Id value, both must differ between the groups.

An automated regression test for exactly this comparison is worth adding as well, so a future, carelessly added manual resolver cache does not silently reopen exactly the gap the built-in mechanism already closed.

Layer Mechanism Customer group included automatically? Responsible for
GraphQL response cache CacheIdCalculator plus CacheIdFactorProviderInterface Yes, via CustomerGroupProvider Caching and reusing the complete response
Resolver or field cache Magento_GraphQlResolverCache, Resolver\IdentityInterface No, must be added per resolver Caching individual expensive field results
Custom manual resolver cache Direct CacheInterface call inside resolver code No, only if the developer adds it Ad-hoc caching in custom resolvers
Varnish VCL hash over X-Magento-Cache-Id, Store, Content-Currency Yes, indirectly via the adopted header Edge caching complete HTTP responses
External CDN in front of Varnish Depends on the CDN configuration Only if headers are explicitly respected Global edge caching

Mironsoft

Magento development, module consulting, and system architecture

A Magento project that needs a second opinion or experienced execution?

We build custom Magento modules, advise on architecture decisions, and take on complex implementations, from service contract planning to production-ready deployment.

Architecture Consulting

Have module and system architecture thought through properly before you build.

Custom Module Development

Build custom Magento modules cleanly, following best practices.

Code Review & Audit

Have existing modules reviewed for performance, security, and maintainability.

10. Summary

GraphQL Caching Per Customer Group in Magento: The Essentials

Good starting point

The built-in response cache already accounts for customer group by default via CustomerGroupProvider.

Real risk

Custom, manually cached resolver results missing the customer group component in the cache key.

Extension

Add additional context dimensions such as a B2B company ID through a custom CacheIdFactorProviderInterface.

Edge layer

Varnish adopts the header Magento computes, a CDN in front of it must respect it explicitly.

11. FAQ: GraphQL Caching Per Customer Group in Magento: The Essentials

1Does Magento's GraphQL response cache automatically split by customer group?
Yes, since Magento_GraphQlCache, customer group is part of the X-Magento-Cache-Id calculation by default via the bundled CustomerGroupProvider.
2Where does the risk of wrong cached prices still come from then?
Mostly from custom resolvers that additionally cache manually with CacheInterface and forget the customer group in their own hand-built cache key, independent of the correct response cache.
3What is the difference between CacheIdFactorProviderInterface and Resolver\IdentityInterface?
The former influences the cache key of the entire response cache, the latter provides tags for targeted invalidation of individual resources, both mechanisms complement each other but do not replace one another.
4Do I need to write my own factor provider for customer group?
No, not for plain customer group, core already ships CustomerGroupProvider, a custom provider only pays off for additional dimensions such as a B2B company ID.
5How do I write a custom cache ID factor?
Through a class implementing CacheIdFactorProviderInterface, registered via di.xml as an extra array item on CacheIdCalculator, analogous to how core wires up the store and currency providers.
6Is Magento's mechanism automatically enough for Varnish?
Yes, Varnish folds the X-Magento-Cache-Id header computed by Magento directly into its own VCL hash, along with Store and Content-Currency headers, without Varnish itself needing to know anything about customer groups.
7What happens if a CDN sits in front of Varnish?
If the CDN caches GraphQL responses on its own purely by URL and ignores the relevant headers, it can serve incorrect, cross-group responses despite correct Magento and Varnish behavior.
8How do I fix a CDN that caches GraphQL incorrectly?
Either configure the CDN so it folds the headers set by Magento into its own cache key, or exclude GraphQL requests from caching at the CDN entirely and leave caching fully to Varnish.
9How does this article differ from the general Varnish GraphQL article?
That one covers the basic Varnish configuration for GraphQL, this one specifically covers the cache key factors and how customer group dependent data correctly flows into them.
10How do I test that prices really do not leak between customer groups?
Run the same query with two different, authenticated customer groups and compare both the returned prices and the returned X-Magento-Cache-Id value, both must differ.