GraphQL Schema Stitching: Externe Services in Magentos Produkt-Schema einbinden
AI generated
M2
di.xml
Magento 2 · GraphQL · Schema Stitching
GraphQL Schema Stitching
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.

15 Min. Lesezeit Resolver-Delegation · externe APIs Magento 2.4.x · GraphQL

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.

11. FAQ: GraphQL Schema Stitching in Magento: Das Wichtigste auf einen Blick

1Unterstützt Magento GraphQL Federation nativ?
Nein, Magento bietet keine Apollo-Federation-artige Zusammenführung mehrerer Live-Schemas. Die Schema-Merge-Mechanik funktioniert nur build-zeitlich für eigene schema.graphqls-Dateien, ein externer Live-Service muss über einen delegierenden Resolver angebunden werden.
2Muss das neue Feld immer nullable sein?
Ja, für praktisch jedes extern befüllte Feld. Ein non-null Feld, das bei Ausfall des externen Service eine Exception wirft, lässt GraphQL den Fehler bis zum nächsten nullable Vorfahren durchreichen und kann so den kompletten Produktknoten leeren.
3Wie verhindere ich, dass ein langsamer externer Service die Query verzögert?
Kurzes Timeout setzen, deutlich unter einer Sekunde, bei Überschreitung sauber null zurückgeben statt zu blockieren, und bei wiederholten Ausfällen den Aufruf für ein kurzes Zeitfenster ganz aussetzen.
4Wie vermeide ich N+1-Aufrufe bei Produktlisten?
Über BatchResolverInterface die angeforderten IDs innerhalb einer Ausführungsphase sammeln und in einem einzigen Aufruf an einen Batch-Endpunkt des externen Service senden, statt pro Produkt einzeln zu fragen.
5Wie invalidiere ich den Cache, wenn sich nur der externe Wert ändert?
Über eine eigene Implementierung von Resolver\IdentityInterface, die einen eigenen Cache-Tag-Namespace zurückgibt, sodass ein Webhook des externen Systems gezielt nur diesen Tag leeren kann statt den gesamten Produkt-Cache.
6Darf ich den Auth-Token des Endkunden an den externen Service weiterreichen?
Besser nicht direkt. Ein eigenes Service-Credential für die Server-zu-Server-Verbindung trennt die Berechtigungsebenen und verhindert, dass ein kompromittierter externer Dienst mit Kundenrechten agieren kann.
7Wie teste ich die Integration ohne den echten externen Service anzusprechen?
Über eine di.xml-Preference, die den HTTP-Client in der Testumgebung durch eine Fake-Implementierung ersetzt, kombiniert mit GraphQlAbstract-Integrationstests, die Timeout- und Erfolgsszenarien simulieren.
8Lohnt sich ein echtes Schema-Stitching-Gateway für ein einzelnes Zusatzfeld?
In der Regel nicht. Der Betriebsaufwand eines eigenen Gateways rechtfertigt sich erst, wenn mehrere Teams eigenständige Subgraphs pflegen, für ein oder zwei externe Felder reicht Resolver-Delegation völlig aus.
9Wie überwache ich, ob der externe Service zuverlässig antwortet?
Aufrufdauer und Status jedes externen Requests strukturiert loggen, idealerweise mit eigenem Log-Kanal, und die P95-Latenz sowie Fehlerquote in ein bestehendes Monitoring einspeisen.
10Kann ich mehrere externe Felder mit demselben Muster ergänzen?
Ja, jedes zusätzliche Feld bekommt einen eigenen Resolver und idealerweise einen eigenen HTTP-Client mit eigenem Timeout, damit ein langsamer Dienst nicht automatisch alle anderen externen Felder mit ausbremst.