externe Services sauber ins Produkt-Schema einbinden
Ein externes PIM mit zusätzlichen Marketing-Attributen oder ein separater Bewertungsdienst liefert oft genau die Daten, die im Produkt-Schema fehlen, ohne dass sich ein zweiter Request für den Client lohnt. Dieser Artikel zeigt, wie sich ein einzelnes Feld eines externen Services sauber per Resolver-Delegation ins bestehende ProductInterface einbinden lässt, inklusive Fehlerbehandlung, wenn der externe Service einmal nicht erreichbar ist, und Batching, damit eine Produktliste nicht einen HTTP-Aufruf pro Knoten auslöst.
Inhaltsverzeichnis
- 1. Ausgangslage: Wann ein externer Service ins Produkt-Schema gehört
- 2. Schema Stitching einordnen: Föderation, Erweiterung und Delegation
- 3. Neues Feld am ProductInterface deklarieren
- 4. Resolver-Implementierung: Delegation an den externen Service
- 5. Fehlerbehandlung bei nicht erreichbarem externen Service
- 6. Cache-Tags für den externen Wert: IdentityInterface nutzen
- 7. Batching: Einen HTTP-Aufruf pro Produktliste statt pro Knoten
- 8. Resilienz und Sicherheitsaspekte der Integration
- 9. Testing und Monitoring der Schema-Stitching-Integration
- 10. Zusammenfassung
- 11. FAQ
1. Ausgangslage: Wann ein externer Service ins Produkt-Schema gehört
Ein typischer Anwendungsfall: Ein externes PIM pflegt zusätzliche Marketing-Attribute, die nie in Magentos eigenem Katalog landen sollen, oder ein separater Bewertungsdienst aggregiert Rezensionen über mehrere Kanäle hinweg. Der Storefront-Client möchte diese Werte trotzdem zusammen mit den regulären Produktdaten in einer einzigen Query erhalten, statt zwei getrennte Requests koordinieren und deren Antworten client-seitig zusammenführen zu müssen.
Wichtig ist dabei die Abgrenzung zu echter GraphQL-Föderation, wie sie Apollo Federation mit eigenständigen, live zusammengeführten Subgraphs kennt. Magento bietet dafür kein Gegenstück, es gibt weder ein _entities-Feld noch ein Gateway, das mehrere unabhängig laufende GraphQL-Server dynamisch verschmilzt. Was für einzelne zusätzliche Felder trotzdem funktioniert, ist eine gezielte Delegation auf Resolver-Ebene.
2. Schema Stitching einordnen: Föderation, Erweiterung und Delegation
Schema Stitching bezeichnet im GraphQL-Ökosystem allgemein das Zusammenführen mehrerer Schemas zu einem gemeinsamen, für Clients einheitlichen Schema. Magentos eigener Build-Prozess betreibt bereits eine Form davon: Die schema.graphqls-Dateien aller aktiven Module werden beim Compile zu einem einzigen großen Schema zusammengeführt. Das funktioniert aber ausschließlich für Schema-Definitionen, die selbst als Magento-Modul vorliegen, nicht für einen live laufenden, externen GraphQL- oder REST-Endpunkt.
Für einen echten externen Service bleibt deshalb der pragmatische Weg: Ein bestehender Typ wird um ein neues Feld erweitert, und ein delegierender Resolver holt den Wert zur Laufzeit von außen. Der Aufbau eines vollwertigen Gateways lohnt sich erst, wenn mehrere Teams eigenständige Subgraphs pflegen wollen, für ein oder zwei zusätzliche Felder ist Resolver-Delegation der deutlich schlankere und wartbarere Ansatz.
3. Neues Feld am ProductInterface deklarieren
Der erste Schritt bleibt reguläre Magento-GraphQL-Schema-Erweiterung: Ein eigenes Modul deklariert in seiner schema.graphqls ein zusätzliches Feld am ProductInterface und verweist über die @resolver-Direktive auf eine eigene PHP-Klasse. Magento merged diese Erweiterung beim nächsten setup:di:compile automatisch in das Gesamtschema, ohne dass der ursprüngliche Typ verändert werden muss.
Entscheidend ist, das Feld als nullable zu deklarieren, hier also Float statt Float!. Ein extern befülltes Feld darf niemals als garantiert vorhanden modelliert werden, weil ein nicht erreichbarer Service sonst zu einer GraphQL-Fehlerpropagierung führt, die weit über das eigentliche Feld hinausreicht, dazu mehr im Abschnitt zur Fehlerbehandlung.
# app/code/Vendor/PimBridge/etc/schema.graphqls
extend type ProductInterface {
pim_marketing_score: Float
@doc(description: "Marketing-Relevanz-Score aus dem externen PIM, 0 bis 100. Null, wenn der externe Service nicht erreichbar war.")
@resolver(class: "Vendor\\PimBridge\\Model\\Resolver\\PimMarketingScore")
}
4. Resolver-Implementierung: Delegation an den externen Service
Der Resolver implementiert ResolverInterface::resolve() und bekommt einen eigenen HTTP-Client sowie dessen Konfiguration (Basis-URL, Timeout) per Konstruktor injiziert. Die Konfigurationswerte liegen als Argumente eines eigenen virtualType in di.xml, statt hart im Resolver zu stehen, damit sich Timeout oder Endpunkt pro Umgebung anpassen lassen, ohne den Code zu ändern.
Der eigentliche Aufruf bleibt bewusst schlank: eine synchrone HTTP-Anfrage an den externen Endpunkt, gefolgt von einem defensiven Mapping der Antwort auf den erwarteten Skalarwert. Fehlt ein erwartetes Feld in der externen Antwort oder liefert der Service einen unerwarteten Typ, gibt der Resolver null zurück statt einen unvalidierten Wert weiterzureichen.
<?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, der den Marketing-Score für ein Produkt aus einem externen PIM nachlädt.
*/
class PimMarketingScore implements ResolverInterface
{
/**
* @param PimHttpClient $pimHttpClient Schlanker HTTP-Client mit eigenem Timeout und Basis-URL aus der di.xml.
*/
public function __construct(private readonly PimHttpClient $pimHttpClient)
{
}
/**
* Löst den Marketing-Score für das aktuell resolvte Produkt auf.
*
* @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. Fehlerbehandlung bei nicht erreichbarem externen Service
Der HTTP-Client kapselt Timeout und Fehlerbehandlung an einer einzigen Stelle: ein kurzes Timeout von deutlich unter einer Sekunde, ein try/catch um den eigentlichen Aufruf, und im Fehlerfall null statt einer Exception, wenn das Feld für die Query nicht kritisch ist. Erst für Felder, die zentral für die Aussagekraft der ganzen Antwort sind, ist es sinnvoll, stattdessen eine GraphQlServerException zu werfen, die dem Client einen expliziten Fehlereintrag im errors-Array liefert.
Dabei lohnt sich ein genauer Blick auf die GraphQL-Fehlerpropagierung: Wirft ein non-null deklariertes Feld eine Exception, reicht GraphQL den Fehler bis zum nächsten nullable Vorfahren im Antwortbaum weiter, im ungünstigsten Fall wird dadurch der gesamte Produktknoten auf null gesetzt, obwohl alle anderen Felder problemlos aufgelöst werden konnten. Genau deshalb bleibt das externe Feld nullable, ein Ausfall des PIM darf niemals die restliche Produktantwort mitreißen.
<?php
declare(strict_types=1);
namespace Vendor\PimBridge\Model;
use Magento\Framework\HTTP\Client\Curl;
use Psr\Log\LoggerInterface;
/**
* Dünner HTTP-Client für den externen Marketing-Score-Endpunkt mit striktem Timeout.
*/
class PimHttpClient
{
/**
* @param Curl $curl Wiederverwendbarer Curl-Client aus dem Framework.
* @param LoggerInterface $logger Eigener Logger-Kanal für Ausfälle des externen Service.
* @param string $baseUrl Basis-URL des externen PIM, konfiguriert via di.xml.
* @param int $timeoutMs Timeout in Millisekunden, bewusst kurz gehalten.
*/
public function __construct(
private readonly Curl $curl,
private readonly LoggerInterface $logger,
private readonly string $baseUrl,
private readonly int $timeoutMs = 800
) {
}
/**
* Ruft den Marketing-Score für eine Produkt-ID ab, liefert bei Fehlern ein leeres Array.
*
* @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 für den externen Wert: IdentityInterface nutzen
GraphQL-Antworten werden in Magento über Cache-Tags invalidiert, die jeder beteiligte Resolver optional über Resolver\IdentityInterface::getIdentities() beisteuert. Ein Resolver, dessen Wert sich unabhängig vom restlichen Produkt ändert, sollte einen eigenen Tag-Namespace zurückgeben, statt sich ausschließlich auf die vom Katalog ohnehin gesetzten Produkt-Tags zu verlassen.
So kann ein Webhook des externen PIM gezielt nur die betroffenen Tags leeren, sobald sich ein Marketing-Score ändert, ohne den kompletten Produkt-Cache anzufassen. Wie sich Cache-Keys zusätzlich um Kontext wie die Kundengruppe erweitern lassen, ist ein eigenständiges Thema mit eigenem Mechanismus und wird hier bewusst nicht wiederholt, weil es eine andere Cache-Ebene betrifft als die reine Tag-basierte Invalidierung.
<?php
declare(strict_types=1);
namespace Vendor\PimBridge\Model\Resolver;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\Resolver\IdentityInterface;
/**
* Liefert einen eigenen Cache-Tag-Namespace für den extern befüllten Marketing-Score.
*/
class PimMarketingScoreIdentity implements IdentityInterface
{
private const CACHE_TAG = 'pim_marketing_score';
/**
* Baut eigene Cache-Tags für das aufgelöste Produkt, getrennt von den Katalog-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: Einen HTTP-Aufruf pro Produktliste statt pro Knoten
Wird das Feld innerhalb einer Produktliste mit fünfzig Einträgen angefragt, führt eine naive Implementierung zu fünfzig sequenziellen HTTP-Aufrufen an den externen Service, ein klassisches N+1-Problem. Magento löst dieses Problem für interne Resolver über BatchResolverInterface, das Anfragen innerhalb einer Ausführungsphase sammelt und gebündelt auflöst, konzeptionell verwandt mit dem DataLoader-Muster, dessen Detailimplementierung an anderer Stelle bereits ausführlich behandelt wird.
Für einen externen Service lohnt sich dasselbe Prinzip nur, wenn dieser tatsächlich einen Batch-Endpunkt anbietet, der mehrere Produkt-IDs in einem einzigen Request beantwortet. Fehlt ein solcher Endpunkt, bleibt zumindest eine Parallelisierung der einzelnen Aufrufe über mehrere gleichzeitig offene Verbindungen eine spürbare Verbesserung gegenüber rein sequenzieller Abarbeitung.
<?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;
/**
* Sammelt alle innerhalb einer Ausführungsphase angefragten Produkt-IDs
* und ruft den externen Batch-Endpunkt nur einmal pro Produktliste auf.
*/
class PimMarketingScoreBatch implements BatchResolverInterface
{
/**
* @param PimHttpClient $pimHttpClient
*/
public function __construct(private readonly PimHttpClient $pimHttpClient)
{
}
/**
* Löst alle gesammelten Requests einer Ausführungsphase in einem Aufruf auf.
*
* @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. Resilienz und Sicherheitsaspekte der Integration
Kurze Timeouts allein reichen nicht, wenn ein extern angebundener Service über längere Zeit ausfällt: Ein einfacher, über CacheInterface gesetzter Marker kann für ein kurzes Zeitfenster signalisieren, dass Aufrufe an den Service aktuell übersprungen werden sollen, statt jede einzelne Anfrage erneut in ein Timeout laufen zu lassen. Das entlastet sowohl Magento als auch den ohnehin schon angeschlagenen externen Dienst.
Interne Fehlermeldungen, Stacktraces oder rohe Antwortkörper des externen Service gehören niemals ungefiltert in die GraphQL-Antwort. Ebenso sollte der Server-zu-Server-Aufruf mit einem eigenen, dedizierten Service-Credential erfolgen, statt den Auth-Token des anfragenden Endkunden unverändert weiterzureichen, damit ein kompromittierter externer Dienst niemals mit Kundenrechten agieren kann.
9. Testing und Monitoring der Schema-Stitching-Integration
Integrationstests gegen GraphQlAbstract lassen sich mit einer testspezifischen di.xml-Preference kombinieren, die den echten HTTP-Client durch eine Fake-Implementierung ersetzt. So lassen sich sowohl der Erfolgsfall als auch ein simuliertes Timeout gezielt prüfen, ohne dass die Tests von der tatsächlichen Erreichbarkeit des externen Service abhängen.
Im laufenden Betrieb sollte jeder externe Aufruf mit Dauer und Status strukturiert geloggt werden, idealerweise über einen eigenen Log-Kanal statt im allgemeinen System-Log. Eine steigende P95-Latenz oder Fehlerquote des ursprünglich lokalen Produkt-Feldes zeigt so früh an, dass die externe Abhängigkeit degradiert, lange bevor Endkunden das über spürbare Ladezeiten bemerken.
| Ansatz | Wann sinnvoll | Komplexität | Fehler-Isolation |
|---|---|---|---|
| Resolver-Delegation (dieser Artikel) | Ein bis wenige externe Felder an bestehendem Typ | Niedrig bis mittel | Pro Feld, dank nullable Typ |
| Eigener GraphQL-Gateway mit Föderation | Mehrere Teams pflegen eigenständige Subgraphs | Hoch | Pro Subgraph |
| Serverseitiger REST-Proxy vor GraphQL | Legacy-Client ohne GraphQL-Unterstützung | Mittel | Global, schwer granular |
| Regelmäßiger Import ins PIM-Attribut | Daten ändern sich selten, Echtzeit nicht nötig | Niedrig | Entkoppelt, aber Datenlatenz |
| Zweiter Request direkt vom Client | Kein Backend-Zugriff auf externen Service möglich | Niedrig im Backend, hoch im Frontend | Vollständig getrennt |
Mironsoft
Magento-Entwicklung, Modul-Beratung und Systemarchitektur
Magento-Projekt, das eine zweite Meinung oder erfahrene Umsetzung braucht?
Wir entwickeln individuelle Magento-Module, beraten bei Architekturentscheidungen und übernehmen komplexe Umsetzungen, von der Service-Contract-Planung bis zum produktionsreifen Deployment.
Architektur-Beratung
Modul- und Systemarchitektur vor der Umsetzung fundiert durchdenken lassen.
Custom-Modul-Entwicklung
Individuelle Magento-Module nach Best Practices sauber umsetzen.
Code-Review & Audit
Bestehende Module auf Performance, Sicherheit und Wartbarkeit prüfen lassen.
10. Zusammenfassung
GraphQL Schema Stitching in Magento: Das Wichtigste auf einen Blick
Empfohlener Ansatz
Resolver-Delegation mit nullable Feldtyp und eigenem HTTP-Client statt Live-Schema-Föderation.
Fehlerstrategie
Kurzes Timeout, try/catch, nullable Rückgabe statt harter Exception bei unkritischen Feldern.
Caching
Eigene Cache-Tags über IdentityInterface, getrennt von den Produkt-Tags des Katalogs.
Performance
BatchResolverInterface nutzen, um pro Produktliste nur einen statt vieler externer Aufrufe zu erzeugen.