eigene Felder, Mutationen und Batch-Resolver sauber verdrahten
Wer im eigenen Magento-Modul Felder, Mutationen oder komplexe Types zum GraphQL-Schema hinzufügt, braucht mehr als extend-type-Syntax: Resolver-Architektur, modulübergreifendes Schema-Merging und Batch-Resolving gegen N+1-Abfragen gehören untrennbar dazu. Dieser Artikel zeigt anhand von echtem Code, wie eine eigene Schema-Erweiterung in Magento 2.4.8 sauber, testbar und versionssicher gebaut wird.
Inhaltsverzeichnis
- 1. Wann ein eigenes Schema-Feld nötig ist
- 2. Grundlagen von schema.graphqls im eigenen Modul
- 3. Eigenen Resolver implementieren
- 4. di.xml-Verdrahtung und Schema-Merging
- 5. Eine eigene Mutation bauen
- 6. Batch-Resolving mit BatchResolverInterface
- 7. Eigene komplexe Types: Interfaces und Unions
- 8. Testing der Schema-Erweiterung
- 9. Versionierung und Kompatibilität
- 10. Zusammenfassung
- 11. FAQ
1. Wann ein eigenes Schema-Feld nötig ist statt REST oder EAV-Custom-Attribute
In der Praxis entsteht der Wunsch, das GraphQL-Schema zu erweitern, selten aus reiner Vorliebe für GraphQL, sondern aus einem konkreten Datenproblem: Der Storefront braucht Informationen, die nicht als einzelnes EAV-Attribut am Produkt liegen, sondern aus mehreren Quellen aggregiert werden müssen, etwa Lagerbestand aus einem externen WMS, Preisstaffeln aus einem ERP und eine Bewertung aus einem Drittsystem in einem einzigen Feld. Ein zusätzliches EAV-Attribut löst das nicht, weil es nur statisch gespeicherte Werte abbildet, keine Berechnung zur Laufzeit.
Ein zweiter typischer Anwendungsfall sind Felder, deren Wert sich erst im Kontext des aktuellen Requests ergibt, zum Beispiel ein personalisierter Rabatt, eine Verfügbarkeitsprognose basierend auf der aktuellen Warenkorbmenge oder ein Score, der Kundengruppe und Lagerstandort gemeinsam berücksichtigt. Solche Berechnungen gehören in einen Resolver, nicht in ein separates REST-Endpoint, weil der Storefront-Client sie ohnehin im selben Produkt- oder Kategorie-Query mit abfragen will, ohne einen zweiten Roundtrip zum Server einzuplanen.
Der dritte Fall betrifft Datenquellen außerhalb von Magento: ein PIM, ein Bewertungsportal, ein Loyalty-System. Statt im Frontend zwei getrennte APIs aufzurufen und die Ergebnisse client-seitig zusammenzuführen, bringt man die Fremdsystem-Daten über einen eigenen GraphQL-Resolver direkt ins Schema. Das reduziert die Anzahl der Netzwerk-Roundtrips für den Storefront und zentralisiert die Zugriffslogik auf das Fremdsystem in einer Backend-Komponente, die getestet, versioniert und gecacht werden kann, statt in mehreren Frontend-Komponenten dupliziert zu werden.
2. Grundlagen von schema.graphqls im eigenen Modul
Jedes Modul, das das GraphQL-Schema erweitert, deklariert das in einer eigenen etc/schema.graphqls-Datei. Statt einen kompletten Type neu zu definieren, verwendet man extend type, um gezielt Felder an einen bereits vorhandenen Type wie Product, Category oder CustomerOutput anzuhängen. Diese Syntax gehört zum GraphQL-Schema-Definition-Language-Standard und wird von Magentos Schema-Generator zur Build-Zeit interpretiert, nicht bei jedem Request neu geparst, was den Overhead pro Anfrage gering hält.
Jedes neue Feld bekommt eine @resolver-Direktive, die auf eine PHP-Klasse verweist, welche ResolverInterface oder BatchResolverInterface implementiert. Ob ein Feld nullable oder non-nullable deklariert wird, ist keine kosmetische Entscheidung: Ein Non-Nullable-Feld, also ein Typ mit Ausrufezeichen wie String!, zwingt den Resolver, garantiert einen Wert zu liefern, sonst wirft der GraphQL-Server einen internen Fehler für die gesamte Query, selbst wenn nur dieses eine Feld fehlschlägt. Für Felder, die aus einem potenziell nicht erreichbaren Fremdsystem stammen, ist Nullable fast immer die richtige Wahl.
Mehrere Module können denselben Basistyp gleichzeitig erweitern, ohne sich gegenseitig zu kennen, solange die Feldnamen nicht kollidieren. Das Schema-Merging von Magento sammelt beim Build alle schema.graphqls-Dateien ein und fügt die extend type-Blöcke additiv zusammen. Das ist der entscheidende Unterschied zu einer Preference oder einem Plugin: Beim Schema selbst gibt es keinen "Override", nur additive Erweiterung. Ein Konflikt entsteht ausschließlich dann, wenn zwei Module denselben Feldnamen am selben Type registrieren.
# File: app/code/Mironsoft/GraphQlExtension/etc/schema.graphqls
extend type Product {
# Aggregated availability score computed at runtime, not stored in EAV
warehouse_availability_score: Float
@resolver(class: "Mironsoft\\GraphQlExtension\\Model\\Resolver\\Product\\WarehouseAvailabilityScore")
@doc(description: "Aggregated real time availability score across all connected warehouses, range 0 to 100.")
# Non-nullable custom object type, see section 7 for the type definition
supplier_info: SupplierInfo
@resolver(class: "Mironsoft\\GraphQlExtension\\Model\\Resolver\\Product\\SupplierInfoResolver")
}
type SupplierInfo {
supplier_name: String!
lead_time_days: Int!
is_preferred_supplier: Boolean!
}
input SupplierFeedbackInput {
product_sku: String!
rating: Int!
comment: String
}
3. Eigenen Resolver implementieren
Der Resolver ist die PHP-Klasse hinter der @resolver-Direktive und implementiert Magentos ResolverInterface mit der zentralen Methode resolve(). Diese Methode bekommt das Field-Objekt, den Context, ResolveInfo sowie das value-Array des Parent-Resolvers und die Query-Argumente übergeben. Wichtig für ein sauberes Modul-Design: Der Resolver greift niemals direkt auf ein Magento-Model oder das Repository eines fremden Moduls zu, sondern ausschließlich über eigene Service Contracts, also Interfaces im Api-Namespace mit dazugehörigem Repository.
Diese Trennung erlaubt es, den Resolver in einem Integrationstest ohne kompletten HTTP-Stack zu testen und die eigentliche Datenbeschaffung unabhängig vom GraphQL-Layer wiederzuverwenden, etwa auch aus einem CLI-Command oder einem REST-Controller. In PHP 8.4 wird der Konstruktor des Resolvers konsequent mit Constructor Property Promotion und typisierten readonly-Properties geschrieben, was den Boilerplate-Code für einfache Dependency-Injection auf eine Zeile pro Abhängigkeit reduziert.
Fehler innerhalb eines Resolvers sollten nie als generische Exception nach oben gereicht werden. Für fehlende Entitäten ist GraphQlNoSuchEntityException korrekt, für ungültige clientseitige Argumente GraphQlInputException. Beide Klassen werden vom GraphQL-Fehlerhandling erkannt und sauber als Teil des errors-Arrays der Response ausgegeben, statt die gesamte Anfrage mit einem Serverfehler abzubrechen.
<?php
declare(strict_types=1);
namespace Mironsoft\GraphQlExtension\Model\Resolver\Product;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Exception\GraphQlNoSuchEntityException;
use Magento\Framework\GraphQl\Query\Resolver\ContextInterface;
use Magento\Framework\GraphQl\Query\Resolver\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Mironsoft\WarehouseApi\Api\AvailabilityScoreRepositoryInterface;
use Magento\Framework\Exception\NoSuchEntityException;
/**
* Resolves the aggregated warehouse availability score for a single product.
*/
final class WarehouseAvailabilityScore implements ResolverInterface
{
/**
* @param AvailabilityScoreRepositoryInterface $availabilityScoreRepository Custom service contract, no direct model access.
*/
public function __construct(
private readonly AvailabilityScoreRepositoryInterface $availabilityScoreRepository
) {
}
/**
* @inheritDoc
*/
public function resolve(
Field $field,
$context,
ResolveInfo $info,
array $value = null,
array $args = null
) {
if (!isset($value['model'])) {
throw new GraphQlNoSuchEntityException(__('Product model not available in resolver chain.'));
}
/** @var \Magento\Catalog\Model\Product $product */
$product = $value['model'];
try {
$score = $this->availabilityScoreRepository->getByProductSku((string) $product->getSku());
return $score->getScore();
} catch (NoSuchEntityException $exception) {
// No score for this SKU yet, field is nullable, return null instead of failing the query
return null;
}
}
}
4. di.xml-Verdrahtung und modulübergreifendes Schema-Merging
Ein einfacher Feld-Resolver braucht in der Regel kein eigenes di.xml, weil der Object Manager die Konstruktor-Abhängigkeiten automatisch auflöst, sobald die Klasse per @resolver referenziert wird. di.xml wird dann relevant, wenn ein Interface über preference an eine konkrete Implementierung gebunden werden muss, oder wenn ein Resolver-Verhalten über ein Plugin verändert werden soll, ohne die Originalklasse zu duplizieren.
Wichtiger im Kontext von Schema-Erweiterungen ist die Registrierung eigener Type-Resolver für Interfaces und Union-Types, siehe Abschnitt 7. Magento nutzt dafür Composite-Type-Resolver-Klassen, in die einzelne Implementierungen per di.xml-Array-Argument eingehängt werden. Jedes Modul kann so seinen eigenen Type-Resolver beisteuern, ohne die Composite-Klasse selbst zu verändern, was demselben additiven Prinzip folgt wie das Schema-Merging der schema.graphqls-Dateien selbst.
Die Reihenfolge, in der Magento die schema.graphqls-Dateien verschiedener Module einliest, folgt der Modul-Sequenz aus module.xml. Für rein additive extend type-Blöcke spielt diese Reihenfolge in der Praxis keine Rolle, weil es keine Override-Semantik gibt. Sobald aber zwei Module über Plugins auf denselben Resolver zugreifen oder eine Type-Resolver-Composite gemeinsam befüllen, bestimmt die Sequenz, in welcher Reihenfolge Plugins ausgeführt werden, und das kann das Ergebnis eines Feldes direkt beeinflussen.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<!-- Bind the service contract interface used by the resolver to its concrete implementation -->
<preference for="Mironsoft\GraphQlExtension\Api\AvailabilityScoreRepositoryInterface"
type="Mironsoft\GraphQlExtension\Model\AvailabilityScoreRepository" />
<!-- Register a custom type resolver into the existing composite for the SupplierInfo union -->
<type name="Mironsoft\GraphQlExtension\Model\Resolver\TypeResolver\SupplierInfoTypeResolverComposite">
<arguments>
<argument name="typeResolvers" xsi:type="array">
<item name="preferred_supplier" xsi:type="object">Mironsoft\GraphQlExtension\Model\Resolver\TypeResolver\PreferredSupplierTypeResolver</item>
<item name="fallback_supplier" xsi:type="object">Mironsoft\GraphQlExtension\Model\Resolver\TypeResolver\FallbackSupplierTypeResolver</item>
</argument>
</arguments>
</type>
</config>
5. Eine eigene Mutation bauen
Für schreibende Operationen deklariert man im schema.graphqls zunächst einen Input-Type mit allen erwarteten Feldern, statt einzelne Scalar-Argumente direkt an die Mutation zu hängen. Ein eigener Input-Type macht die Mutation erweiterbar: Neue optionale Felder lassen sich später hinzufügen, ohne bestehende Client-Queries zu brechen, was bei einer flachen Argumentliste deutlich schwerer zu handhaben wäre.
Die Validierung der Eingabe gehört an den Anfang der resolve()-Methode, bevor irgendeine Schreiboperation angestoßen wird. Pflichtfelder, Wertebereiche und Format-Validierung, etwa für E-Mail-Adressen oder SKU-Muster, sollten dieselbe Validierungslogik verwenden wie ein äquivalenter REST-Endpoint oder ein Adminhtml-Formular, idealerweise über einen gemeinsamen Validator-Service, damit Business-Regeln nicht doppelt und potenziell inkonsistent gepflegt werden.
Für Fehlerfälle stehen zwei spezifische Exception-Klassen bereit, die im Frontend sauber ausgewertet werden können: GraphQlInputException für ungültige oder unvollständige Eingaben und GraphQlNoSuchEntityException, wenn sich eine referenzierte Entität, etwa eine Customer-ID oder Produkt-SKU, nicht auflösen lässt. Eine generische \Exception oder \RuntimeException zu werfen, ist hier keine gute Praxis, weil der GraphQL-Error-Handler sie als internen Serverfehler mit Status 500 behandelt und dem Storefront-Client keine Möglichkeit gibt, den Fehler gezielt am Feld anzuzeigen.
6. Batch-Resolving mit BatchResolverInterface
Ein einzelner Feld-Resolver wird für jedes Element einer Liste separat aufgerufen. Bei einem products-Query mit zwanzig Ergebnissen und einem eigenen Feld pro Produkt bedeutet das zwanzig einzelne Aufrufe von resolve(), und wenn jeder Aufruf eine eigene Datenbank- oder API-Abfrage auslöst, entsteht ein klassisches N+1-Problem, das bei steigender Seitengröße linear schlechter wird.
BatchResolverInterface löst das, indem das GraphQL-Resolver-Framework alle Einzelanfragen für ein Feld über die komplette Liste hinweg sammelt, bevor überhaupt ein resolve() aufgerufen wird. Die Implementierung bekommt ein Array von BatchRequestItemInterface-Objekten übergeben und kann daraus in einem einzigen Rutsch alle benötigten Keys extrahieren, eine einzige Bulk-Abfrage an die Datenquelle stellen und die Ergebnisse anschließend den ursprünglichen Requests wieder zuordnen.
Der Umbau eines bestehenden Field-Resolvers auf BatchResolverInterface ist technisch selten aufwendig, verändert aber die Denkweise: Statt "gib mir den Wert für dieses eine Produkt" lautet die Frage "gib mir die Werte für alle diese Produkte in einem Rutsch". Für Felder, die in Listen-Queries mit potenziell hunderten Zeilen auftauchen, wie Produktlisten oder Kategoriebäume, ist BatchResolverInterface praktisch Pflicht, sobald die Datenquelle nicht in-memory oder bereits gecacht ist.
<?php
declare(strict_types=1);
namespace Mironsoft\GraphQlExtension\Model\Resolver\Product;
use Magento\Framework\GraphQl\Query\Resolver\BatchResolverInterface;
use Magento\Framework\GraphQl\Query\Resolver\BatchResponse;
use Magento\Framework\GraphQl\Query\Resolver\BatchResponseFactory;
use Mironsoft\WarehouseApi\Api\AvailabilityScoreRepositoryInterface;
/**
* Batches warehouse availability score lookups across a whole product list
* to avoid one database round trip per product in a list query.
*/
final class WarehouseAvailabilityScoreBatch implements BatchResolverInterface
{
/**
* @param AvailabilityScoreRepositoryInterface $availabilityScoreRepository Bulk-capable service contract.
* @param BatchResponseFactory $batchResponseFactory Factory for the framework batch response envelope.
*/
public function __construct(
private readonly AvailabilityScoreRepositoryInterface $availabilityScoreRepository,
private readonly BatchResponseFactory $batchResponseFactory
) {
}
/**
* @inheritDoc
*/
public function resolve(array $requests): BatchResponse
{
$skusByRequest = [];
foreach ($requests as $request) {
$value = $request->getValue();
$skusByRequest[] = (string) ($value['model']->getSku() ?? '');
}
// Single bulk lookup for the whole list field instead of N single queries
$scoresBySku = $this->availabilityScoreRepository->getScoresBySkuList($skusByRequest);
$response = $this->batchResponseFactory->create();
foreach ($requests as $request) {
$value = $request->getValue();
$sku = (string) ($value['model']->getSku() ?? '');
$response->addResponse($request, $scoresBySku[$sku] ?? null);
}
return $response;
}
}
7. Eigene komplexe Types: verschachtelte Objekte, Interfaces und Unions
Sobald ein Feld mehr als einen einzelnen Skalarwert liefert, lohnt sich ein eigener Object-Type statt eines primitiven Strings oder eines JSON-kodierten Textfelds. Ein Type wie SupplierInfo mit klar benannten, typisierten Unterfeldern macht die Struktur für jeden GraphQL-Client selbstdokumentierend und erlaubt selektives Abfragen einzelner Unterfelder, was ein einzelnes JSON-String-Feld nicht kann, ohne dass der Client den kompletten Blob parsen und selbst interpretieren müsste.
Interfaces werden relevant, wenn mehrere konkrete Types eine gemeinsame Basis an Feldern teilen, sich aber in Details unterscheiden, etwa ein bevorzugter Lieferant mit Vertragsdaten gegenüber einem Fallback-Lieferant mit nur Basisdaten. Union-Types eignen sich, wenn die zurückgegebenen Objekte keine gemeinsamen Felder teilen müssen, etwa wenn ein Suchresultat entweder ein Produkt oder ein CMS-Inhalt sein kann. In beiden Fällen braucht Magento zur Laufzeit einen TypeResolver, der anhand der zurückgegebenen Rohdaten entscheidet, welcher konkrete GraphQL-Type tatsächlich zurückgegeben wird.
Die Entscheidung zwischen primitivem Feld und eigenem Type sollte sich an der erwarteten Weiterentwicklung orientieren: Wird absehbar ein zweites oder drittes Unterfeld dazukommen, spart ein eigener Type von Anfang an eine spätere Breaking-Change-Migration. Für ein Feld, das dauerhaft ein einzelner Wert bleibt, wie ein Float-Score, ist ein primitives Feld weiterhin die schlankere und schnellere Lösung, weil es keinen zusätzlichen Resolver-Aufruf für die Unterfelder benötigt.
8. Testing der Schema-Erweiterung
Der zuverlässigste Test für eine Schema-Erweiterung ist ein Integrationstest, der eine echte GraphQL-Query gegen den erweiterten Schema-Endpunkt ausführt, genau wie es ein Storefront-Client tun würde. Magentos eigene GraphQL-Testsuite bietet dafür die Basisklasse \Magento\TestFramework\TestCase\GraphQlAbstract, die eine Query als String entgegennimmt, sie gegen das vollständige, gemergte Schema ausführt und die Response als assoziatives Array zurückgibt, inklusive etwaiger errors.
Ein solcher Test deckt gleichzeitig drei Dinge ab: dass die schema.graphqls-Syntax valide ist und ins Gesamtschema gemergt wird, dass der referenzierte Resolver korrekt instanziiert wird und dass die zurückgegebenen Daten dem erwarteten Format entsprechen. Schema-Validierungsfehler, etwa ein Tippfehler im Feldnamen oder ein fehlender Type, fallen damit schon in der CI-Pipeline auf, nicht erst als Laufzeitfehler im Storefront.
Bei Breaking Changes ist besondere Vorsicht geboten: Ein Feld umzubenennen, seinen Rückgabetyp zu ändern oder von Nullable auf Non-Nullable umzustellen, bricht jeden bestehenden Client, der das alte Schema erwartet. Ein Integrationstest, der die exakte erwartete Struktur der Response prüft, macht solche Änderungen sichtbar, bevor sie in Produktion gehen, und sollte Teil jeder Pull-Request-Pipeline sein, die schema.graphqls-Dateien verändert.
{
products(filter: { sku: { eq: "24-MB01" } }) {
items {
sku
name
# Custom field added via extend type Product in schema.graphqls
warehouse_availability_score
supplier_info {
supplier_name
lead_time_days
is_preferred_supplier
}
}
}
}
9. Versionierung und Kompatibilität
Weil GraphQL kein eingebautes Versionierungskonzept wie /V1/ oder /V2/ in REST kennt, muss Kompatibilität über die Schema-Gestaltung selbst sichergestellt werden. Die wichtigste Regel: Neue Felder werden grundsätzlich additiv und nullable eingeführt, niemals als Ersatz für ein bestehendes Feld im selben Release. Bestehende Clients, die das alte Feld weiter abfragen, funktionieren unverändert weiter, während neue Clients das neue Feld zusätzlich nutzen können.
Für Felder, die langfristig entfernt werden sollen, ist die @deprecated-Direktive das richtige Werkzeug: Sie markiert ein Feld in der Introspection als veraltet, ohne es sofort zu entfernen, inklusive eines reason-Arguments, das auf das Nachfolgefeld verweist. Ein Feld sollte erst dann tatsächlich aus dem Schema entfernt werden, wenn über Logging oder Query-Analyse nachweisbar ist, dass keine relevanten Clients es mehr abfragen. Die Wahl des richtigen Resolver-Typs beeinflusst dabei auch, wie leicht sich ein Feld später austauschen lässt.
| Kriterium | Field Resolver | Batch Resolver | Custom Type Resolver |
|---|---|---|---|
| Einsatzzweck | Einzelnes Feld, ein Objekt pro Aufruf | Feld in Listen-Queries mit vielen Elementen | Interface- oder Union-Type, konkreten Type ermitteln |
| Performance bei Listen | Schwach, ein Aufruf pro Element | Stark, ein Bulk-Aufruf für alle Elemente | Neutral, betrifft Typermittlung, nicht Datenmenge |
| Komplexität | Gering, einfache resolve()-Methode | Mittel, Requests sammeln und zuordnen | Mittel, Composite-Registrierung per di.xml nötig |
| N+1-Risiko | Hoch bei externen Datenquellen | Praktisch eliminiert | Gering, meist reine In-Memory-Entscheidung |
10. Zusammenfassung
Ein eigenes GraphQL-Schema zu erweitern bedeutet in Magento 2.4.8 weit mehr als eine extend type-Zeile in schema.graphqls zu schreiben. Die Feldebene braucht einen sauberen Resolver über Service Contracts statt direktem Model-Zugriff, die Verdrahtung über di.xml betrifft vor allem Type-Resolver für Interfaces und Unions, und bei Listen-Feldern ist BatchResolverInterface der entscheidende Hebel gegen N+1-Abfragen. Mutationen brauchen eigene Input-Types und spezifisches Fehler-Handling mit GraphQlInputException und GraphQlNoSuchEntityException statt generischer Exceptions.
Testbarkeit und additive Versionierung sind kein nachträglicher Schritt, sondern Teil des Entwurfs: Ein Integrationstest gegen das gemergte Schema deckt Merging-Fehler, Resolver-Bugs und Breaking Changes ab, bevor sie den Storefront erreichen. Wer neue Felder konsequent nullable und additiv hält und veraltete Felder mit @deprecated markiert statt sie abrupt zu entfernen, hält das eigene GraphQL-Schema über Jahre kompatibel mit allen bestehenden Clients.
GraphQL-Schema erweitern, das Wichtigste auf einen Blick
Schema-Erweiterung
extend type in eigener schema.graphqls, additiv gemergt über alle Module, Konflikte nur bei doppelten Feldnamen am selben Type.
Resolver-Architektur
ResolverInterface mit Constructor Property Promotion, Zugriff nur über eigene Service Contracts, klare Exception-Typen statt generischer Fehler.
Batch-Resolving
BatchResolverInterface für Listen-Felder, ein Bulk-Aufruf statt N Einzelaufrufen, Pflicht bei externen Datenquellen.
Testing & Versionierung
Integrationstests gegen das gemergte Schema, neue Felder additiv und nullable, @deprecated statt abruptem Entfernen.
11. FAQ: GraphQL-Schema erweitern
1Unterschied zwischen extend type und einem neuen Type?
2Kann ich ein Core-Feld überschreiben?
3Namenskonflikte zwischen Modulen vermeiden?
4Eigener Resolver pro Feld nötig?
5Schema-Erweiterung ohne echten Request testen?
6Feld später wieder entfernen, was passiert?
7BatchResolverInterface auch bei Einzel-Feldern?
8Berechtigungen im eigenen Resolver prüfen?
9Mutation ohne serverseitige Validierung?
10Fehler beim Merging mehrerer schema.graphqls?
Mironsoft
Magento-2-GraphQL-Entwicklung und API-Architektur
Eigenes GraphQL-Schema, das sauber skaliert?
Wir erweitern euer Magento-GraphQL-Schema um eigene Felder, Mutationen und Batch-Resolver, mit sauberer di.xml-Verdrahtung, vollständiger Testabdeckung und einer Versionierungsstrategie, die bestehende Storefront-Clients nicht bricht.
Schema-Design
extend type, eigene Input-Types und Interfaces sauber gegen das Basisschema modelliert
Resolver & Batching
ResolverInterface und BatchResolverInterface gegen N+1-Abfragen bei Listen-Feldern
Testing & Versionierung
Integrationstests gegen das gemergte Schema und additive Deprecation-Strategie