GraphQL Schema Stitching: Wiring External Services Into Magento's Product Schema
AI generated
M2
di.xml
Magento 2 · GraphQL · Schema Stitching
GraphQL Schema Stitching
wiring external services into the product schema the clean way

An external PIM with extra marketing attributes, or a separate review aggregation service, often holds exactly the data missing from the product schema, without justifying a second round trip for the client. This article shows how to cleanly attach a single external field to the existing ProductInterface through resolver delegation, including error handling for when the external service is unreachable, and batching so a product list does not trigger one HTTP call per node.

15 min read Resolver delegation · external APIs Magento 2.4.x · GraphQL

1. The starting point: when an external service belongs in the product schema

A typical use case: an external PIM maintains extra marketing attributes that should never live in Magento's own catalog, or a separate review service aggregates ratings across multiple channels. The storefront client still wants those values alongside the regular product data in a single query, instead of coordinating two separate requests and merging their responses on the client.

It is worth distinguishing this from real GraphQL federation, the kind Apollo Federation implements with independently deployed subgraphs merged live at request time. Magento has no equivalent, there is no _entities field and no gateway that dynamically merges several independently running GraphQL servers. What does work for a handful of extra fields is targeted delegation at the resolver level.

2. Placing schema stitching: federation, extension, and delegation

In the wider GraphQL ecosystem, schema stitching generally means merging several schemas into one unified schema for clients. Magento's own build process already performs a form of this: the schema.graphqls files of every active module get merged into one large schema at compile time. That only works for schema definitions shipped as a Magento module, though, not for a live, external GraphQL or REST endpoint.

For a genuinely external service the pragmatic path is therefore to extend an existing type with a new field, and let a delegating resolver fetch the value at runtime. Building a full gateway only pays off once multiple teams want to maintain independent subgraphs, for one or two extra fields resolver delegation is the far leaner and more maintainable option.

3. Declaring the new field on ProductInterface

The first step is a regular Magento GraphQL schema extension: a custom module declares an extra field on ProductInterface in its own schema.graphqls and points to a custom PHP class through the @resolver directive. Magento merges this extension into the overall schema on the next setup:di:compile, without touching the original type at all.

Declaring the field as nullable is essential, so Float rather than Float!. An externally sourced field must never be modeled as guaranteed present, because an unreachable service would otherwise trigger GraphQL error propagation that reaches far beyond the field itself, covered in more detail in the error handling section.


# app/code/Vendor/PimBridge/etc/schema.graphqls
extend type ProductInterface {
    pim_marketing_score: Float
        @doc(description: "Marketing relevance score from the external PIM, 0 to 100. Null when the external service was unreachable.")
        @resolver(class: "Vendor\\PimBridge\\Model\\Resolver\\PimMarketingScore")
}

4. Resolver implementation: delegating to the external service

The resolver implements ResolverInterface::resolve() and gets its own HTTP client and its configuration, base URL and timeout, injected through the constructor. Those configuration values live as arguments of a dedicated virtualType in di.xml instead of being hard coded in the resolver, so timeout and endpoint can be adjusted per environment without touching the code.

The actual call stays deliberately thin: a synchronous HTTP request to the external endpoint, followed by defensive mapping of the response onto the expected scalar value. If an expected field is missing from the external response, or the service returns an unexpected type, the resolver returns null instead of forwarding an unvalidated value.


<?php
declare(strict_types=1);

namespace Vendor\PimBridge\Model\Resolver;

use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Vendor\PimBridge\Model\PimHttpClient;

/**
 * Resolver that fetches a product's marketing score from an external PIM.
 */
class PimMarketingScore implements ResolverInterface
{
    /**
     * @param PimHttpClient $pimHttpClient Lightweight HTTP client with its own timeout and base URL from di.xml.
     */
    public function __construct(private readonly PimHttpClient $pimHttpClient)
    {
    }

    /**
     * Resolves the marketing score for the currently resolved product.
     *
     * @param Field $field
     * @param mixed $context
     * @param ResolveInfo $info
     * @param array|null $value
     * @param array|null $args
     * @return float|null
     */
    public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null): ?float
    {
        $productId = (int) ($value['model']->getId() ?? 0);
        if ($productId === 0) {
            return null;
        }

        $payload = $this->pimHttpClient->fetchMarketingScore($productId);

        return isset($payload['score']) && is_numeric($payload['score'])
            ? (float) $payload['score']
            : null;
    }
}

5. Error handling when the external service is unreachable

The HTTP client centralizes timeout and error handling in a single place: a short timeout well under one second, a try/catch around the actual call, and null instead of an exception whenever the field is not critical to the query. Only for fields that are central to the overall answer does it make sense to throw a GraphQlServerException instead, giving the client an explicit entry in the errors array.

It is worth studying GraphQL error propagation closely here: when a field declared non-null throws, GraphQL bubbles the error up to the nearest nullable ancestor in the response tree, in the worst case nulling out the entire product node even though every other field resolved fine. That is exactly why the external field stays nullable, a PIM outage must never take down the rest of the product response with it.


<?php
declare(strict_types=1);

namespace Vendor\PimBridge\Model;

use Magento\Framework\HTTP\Client\Curl;
use Psr\Log\LoggerInterface;

/**
 * Thin HTTP client for the external marketing score endpoint with a strict timeout.
 */
class PimHttpClient
{
    /**
     * @param Curl $curl Reusable curl client from the framework.
     * @param LoggerInterface $logger Dedicated log channel for outages of the external service.
     * @param string $baseUrl Base URL of the external PIM, configured via di.xml.
     * @param int $timeoutMs Timeout in milliseconds, kept deliberately short.
     */
    public function __construct(
        private readonly Curl $curl,
        private readonly LoggerInterface $logger,
        private readonly string $baseUrl,
        private readonly int $timeoutMs = 800
    ) {
    }

    /**
     * Fetches the marketing score for a product ID, returns an empty array on any failure.
     *
     * @param int $productId
     * @return array<string, mixed>
     */
    public function fetchMarketingScore(int $productId): array
    {
        try {
            $this->curl->setTimeout($this->timeoutMs);
            $this->curl->get(rtrim($this->baseUrl, '/') . '/scores/' . $productId);

            if ($this->curl->getStatus() !== 200) {
                return [];
            }

            $decoded = json_decode($this->curl->getBody(), true);
            return is_array($decoded) ? $decoded : [];
        } catch (\Throwable $exception) {
            $this->logger->warning('PIM marketing score unreachable: ' . $exception->getMessage(), [
                'product_id' => $productId,
            ]);
            return [];
        }
    }
}

6. Cache tags for the external value: using IdentityInterface

Magento invalidates GraphQL responses through cache tags that every participating resolver can optionally contribute via Resolver\IdentityInterface::getIdentities(). A resolver whose value changes independently of the rest of the product should return its own tag namespace, instead of relying solely on the product tags the catalog sets anyway.

That way a webhook from the external PIM can flush exactly the affected tags whenever a marketing score changes, without touching the entire product cache. Extending cache keys with additional context such as customer group is a separate topic with its own mechanism, and deliberately not repeated here since it concerns a different cache layer than plain tag based invalidation.


<?php
declare(strict_types=1);

namespace Vendor\PimBridge\Model\Resolver;

use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\Resolver\IdentityInterface;

/**
 * Provides its own cache tag namespace for the externally sourced marketing score.
 */
class PimMarketingScoreIdentity implements IdentityInterface
{
    private const CACHE_TAG = 'pim_marketing_score';

    /**
     * Builds dedicated cache tags for the resolved product, separate from catalog tags.
     *
     * @param array $resolvedData
     * @return string[]
     */
    public function getIdentities(array $resolvedData): array
    {
        $productId = $resolvedData['model']->getId() ?? null;
        return $productId ? [self::CACHE_TAG . '_' . $productId] : [];
    }
}

7. Batching: one HTTP call per product list instead of per node

When the field is requested inside a product list of fifty entries, a naive implementation triggers fifty sequential HTTP calls to the external service, a classic N+1 problem. Magento solves this for internal resolvers through BatchResolverInterface, which collects requests within one execution phase and resolves them together, conceptually related to the DataLoader pattern, whose implementation details are already covered in depth elsewhere.

The same principle only pays off for an external service if it actually exposes a batch endpoint that answers multiple product IDs in one request. If no such endpoint exists, at least parallelizing the individual calls across several concurrently open connections is still a noticeable improvement over pure sequential execution.


<?php
declare(strict_types=1);

namespace Vendor\PimBridge\Model\Resolver;

use Magento\Framework\GraphQl\Query\Resolver\BatchResolverInterface;
use Magento\Framework\GraphQl\Query\Resolver\BatchRequestItemInterface;
use Magento\Framework\GraphQl\Query\Resolver\Batch\RequestItemInterface;
use Vendor\PimBridge\Model\PimHttpClient;

/**
 * Collects every product ID requested within one execution phase and calls
 * the external batch endpoint only once per product list.
 */
class PimMarketingScoreBatch implements BatchResolverInterface
{
    /**
     * @param PimHttpClient $pimHttpClient
     */
    public function __construct(private readonly PimHttpClient $pimHttpClient)
    {
    }

    /**
     * Resolves every request collected during one execution phase in a single call.
     *
     * @param BatchRequestItemInterface[] $requests
     * @return array
     */
    public function resolve(array $requests): array
    {
        $productIds = array_map(
            static fn (RequestItemInterface $item): int => (int) $item->getValue()['model']->getId(),
            $requests
        );

        $scores = $this->pimHttpClient->fetchMarketingScoresBatch($productIds);

        $results = [];
        foreach ($requests as $request) {
            $productId = (int) $request->getValue()['model']->getId();
            $results[] = $scores[$productId] ?? null;
        }

        return $results;
    }
}

8. Resilience and security considerations for the integration

Short timeouts alone are not enough when an externally connected service is down for an extended period: a simple marker set through CacheInterface can signal for a short window that calls to the service should currently be skipped, instead of letting every single request run into another timeout. That relieves load on both Magento and the already struggling external service.

Internal error messages, stack traces, or raw response bodies from the external service must never end up unfiltered in the GraphQL response. Likewise, the server-to-server call should use its own dedicated service credential rather than forwarding the requesting end customer's auth token unchanged, so that a compromised external service can never act with customer level permissions.

9. Testing and monitoring the schema stitching integration

Integration tests against GraphQlAbstract can be combined with a test-specific di.xml preference that swaps the real HTTP client for a fake implementation. That way both the success case and a simulated timeout can be tested deliberately, without the tests depending on the actual availability of the external service.

In production every external call should be logged with duration and status in a structured way, ideally through a dedicated log channel rather than the general system log. A rising P95 latency or error rate on what used to be a purely local product field signals early that the external dependency is degrading, well before end customers notice it through slower page loads.

Approach When it makes sense Complexity Failure isolation
Resolver delegation (this article) One or a few external fields on an existing type Low to medium Per field, thanks to the nullable type
Custom GraphQL gateway with federation Multiple teams maintain independent subgraphs High Per subgraph
Server side REST proxy in front of GraphQL Legacy client without GraphQL support Medium Global, hard to make granular
Scheduled import into a PIM attribute Data changes rarely, real time not required Low Decoupled, but data latency
Second request made directly by the client No backend access to the external service possible Low in the backend, high in the frontend Fully separated

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 Schema Stitching in Magento: The Essentials

Recommended approach

Resolver delegation with a nullable field type and a dedicated HTTP client instead of live schema federation.

Error strategy

Short timeout, try/catch, nullable return instead of a hard exception for non critical fields.

Caching

Dedicated cache tags via IdentityInterface, separate from the catalog's product tags.

Performance

Use BatchResolverInterface so a product list triggers one external call instead of many.

11. FAQ: GraphQL Schema Stitching in Magento: The Essentials

1Does Magento GraphQL support federation natively?
No, Magento offers no Apollo-Federation-style merging of multiple live schemas. The schema merge mechanism only works at build time for a module's own schema.graphqls files, a genuinely external live service has to be attached through a delegating resolver.
2Does the new field always have to be nullable?
Yes, for practically every externally sourced field. A non-null field that throws on an outage lets GraphQL bubble the error up to the nearest nullable ancestor, which can null out the entire product node.
3How do I stop a slow external service from delaying the whole query?
Set a short timeout, well under one second, return null cleanly instead of blocking when it is exceeded, and skip the call entirely for a short window after repeated failures.
4How do I avoid N+1 calls for product lists?
Collect the requested IDs within one execution phase through BatchResolverInterface and send a single request to a batch endpoint on the external service, instead of asking per product.
5How do I invalidate the cache when only the external value changes?
Through a custom implementation of Resolver\IdentityInterface that returns its own cache tag namespace, so a webhook from the external system can flush exactly that tag instead of the whole product cache.
6Am I allowed to forward the end customer's auth token to the external service?
Better not directly. A dedicated service credential for the server to server connection keeps the permission levels separate and prevents a compromised external service from acting with customer level rights.
7How do I test the integration without calling the real external service?
Through a di.xml preference that swaps the HTTP client for a fake implementation in the test environment, combined with GraphQlAbstract integration tests that simulate timeout and success scenarios.
8Is a real schema stitching gateway worth it for a single extra field?
Usually not. The operational cost of a dedicated gateway only pays off once multiple teams maintain independent subgraphs, resolver delegation is plenty for one or two external fields.
9How do I monitor whether the external service is answering reliably?
Log call duration and status for every external request in a structured way, ideally with a dedicated log channel, and feed P95 latency and error rate into existing monitoring.
10Can I add several external fields using the same pattern?
Yes, each additional field gets its own resolver and ideally its own HTTP client with its own timeout, so that a slow service does not automatically drag down every other external field.