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.
Table of Contents
- 1. The starting point: when an external service belongs in the product schema
- 2. Placing schema stitching: federation, extension, and delegation
- 3. Declaring the new field on ProductInterface
- 4. Resolver implementation: delegating to the external service
- 5. Error handling when the external service is unreachable
- 6. Cache tags for the external value: using IdentityInterface
- 7. Batching: one HTTP call per product list instead of per node
- 8. Resilience and security considerations for the integration
- 9. Testing and monitoring the schema stitching integration
- 10. Summary
- 11. FAQ
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.