Input, Service Contract, Response
Eine GraphQL-Mutation ist mehr als ein Resolver mit Seiteneffekten. Wer Input-Typen nicht im Schema modelliert, Geschäftslogik direkt in den Resolver schreibt oder Fehler nicht als strukturierte Response zurückgibt, baut eine API, die schwer zu testen, zu dokumentieren und zu erweitern ist.
Inhaltsverzeichnis
- 1. Was eine GraphQL-Mutation in Magento von einer Query unterscheidet
- 2. Das Mutation-Schema: Input-Typ und Response-Typ korrekt definieren
- 3. Der Mutation-Resolver: nur delegieren, nie selbst persistieren
- 4. Service Contract als Mutations-Backend
- 5. Vollständiges Beispiel: Kundenwunschliste per Mutation anlegen
- 6. Falsch gegen richtig: Logik im Resolver vs. Service Contract
- 7. Typische Fehlerbilder bei Magento-Mutationen
- 8. Input-Validierung: wo sie hingehört
- 9. Mutations-Tests: PHPUnit und GraphQL-Integrationstests
- 10. Zusammenfassung
- 11. FAQ
1. Was eine GraphQL-Mutation in Magento von einer Query unterscheidet
Technisch sind Mutations und Queries in GraphQL fast identisch: Beide definieren Felder im Schema, beide werden von Resolvern verarbeitet, und beide liefern eine typisierte Antwort zurück. Der fundamentale Unterschied liegt in der Semantik und der Ausführungsreihenfolge: Mutations werden sequenziell ausgeführt — wenn mehrere Mutations in einem Request ankommen, werden sie nacheinander verarbeitet. Queries hingegen können parallel aufgelöst werden. Außerdem signalisiert eine Mutation dem Client und allen Caching-Schichten, dass sich der Systemzustand verändert hat.
In Magento hat dieser Unterschied konkrete Konsequenzen. Eine Mutation, die einen Warenkorb verändert, muss sicherstellen, dass die Session-ID korrekt verarbeitet wird und dass das Ergebnis nicht aus einem alten Cache stammt. Magento's GraphQL-Infrastruktur behandelt Mutations automatisch als nicht-cacheable — aber das schützt nur auf HTTP-Ebene. Resolver, die intern auf gecachte Objekte zurückgreifen, können trotzdem veraltete Daten liefern. Das Verstehen dieser Grenzen ist die Voraussetzung für korrekte Mutation-Implementierungen.
Für eigene Magento-Module bedeutet das: Eine neue Mutation braucht einen dedizierten Input-Typ im Schema, einen schlanken Resolver, der ausschließlich delegiert, und einen Service Contract, der die eigentliche Fachlogik kapselt. Diese drei Schichten sind nicht verhandelbar — wer sie zusammenführt, baut eine Mutation, die weder testbar noch erweiterbar ist.
2. Das Mutation-Schema: Input-Typ und Response-Typ korrekt definieren
Das Schema ist der Vertrag zwischen Frontend und Backend. Für Mutations bedeutet das: Der Input-Typ definiert exakt, welche Daten der Client übermitteln muss und welche optional sind. Pflichtfelder werden mit einem Ausrufezeichen markiert (String!), optionale Felder bleiben ohne. Der Response-Typ definiert, was der Client nach erfolgreicher Mutation zurückbekommt — das sollte immer ein spezifischer Output-Typ sein, kein allgemeiner Boolean. Strukturierte Responses ermöglichen es, sowohl Erfolgsfelder als auch Fehlerinformationen in einem einzigen Typ zu kodieren.
Ein häufig gemachter Fehler: Das Mutations-Schema gibt nur einen Boolean zurück. Das mag für einfache Bestätigungen ausreichen, aber es verhindert, dass das Frontend zusätzliche Kontextinformationen erhält — wie eine generierte ID, einen Status oder eine Nachricht. Ein gut modellierter Response-Typ kann erweitert werden, ohne dass das Schema für bestehende Clients bricht. Felder können hinzugefügt werden, ohne eine Breaking Change auszulösen.
# Mutation schema definition
# File: Vendor/WishlistModule/etc/schema.graphqls
type Mutation {
createWishlistEntry(
input: CreateWishlistEntryInput!
): CreateWishlistEntryOutput
@resolver(class: "Vendor\\WishlistModule\\Model\\Resolver\\CreateWishlistEntry")
}
input CreateWishlistEntryInput {
customer_id: Int!
product_sku: String!
note: String
quantity: Float = 1.0
}
type CreateWishlistEntryOutput {
entry_id: Int
customer_id: Int
product_sku: String
quantity: Float
created_at: String
user_errors: [WishlistUserError]
}
type WishlistUserError {
code: WishlistErrorCode!
message: String!
path: [String]
}
enum WishlistErrorCode {
PRODUCT_NOT_FOUND
CUSTOMER_NOT_FOUND
DUPLICATE_ENTRY
PERMISSION_DENIED
}
3. Der Mutation-Resolver: nur delegieren, nie selbst persistieren
Der Mutations-Resolver hat genau eine Aufgabe: Er empfängt die validierten GraphQL-Argumente, übergibt sie an den Service Contract und gibt das Ergebnis in der vom Schema erwarteten Struktur zurück. Persistierung, Validierung, Autorisierung und Fehlerbehandlung gehören nicht in den Resolver. Diese Trennung ist nicht nur stilistisch — sie ist die Voraussetzung dafür, dass der Service Contract in anderen Kontexten (REST-API, CLI-Command, andere GraphQL-Endpunkte) ebenfalls genutzt werden kann.
Magento's GraphQL-Infrastruktur stellt für Resolver das ResolverInterface bereit, das eine einzige Methode definiert: resolve(). In dieser Methode sollte die Autorisierungsprüfung an erster Stelle stehen — Magento stellt dafür den $context-Parameter bereit, über den der aktuelle Kunden-Token ausgewertet werden kann. Danach folgt die Weitergabe der Input-Daten an den Service und die Transformation des Ergebnisses in das Schema-Format. Exception-Handling im Resolver sollte gezielt Magento-spezifische Exceptions abfangen und in user_errors-Felder im Response-Typ übersetzen — statt als generische GraphQL-Errors zurückgegeben zu werden.
4. Service Contract als Mutations-Backend
Der Service Contract ist das Herzstück jeder Magento-Mutation. Er definiert das Interface (WishlistManagementInterface), die konkrete Implementierung (WishlistManagement) und die DI-Konfiguration (di.xml), die beide verbindet. Die Implementierung enthält die vollständige Fachlogik: Existenzprüfungen, Duplikat-Erkennung, Persistierung über das Repository und das Auslösen von Events für Plugins und Observer.
Wer die Fachlogik direkt im Resolver implementiert, verliert diese Flexibilität. Ein Plugin auf dem Service Contract kann Mutationen abfangen und erweitern — zum Beispiel um Logging, Benachrichtigungen oder A/B-Tests. Ein Plugin auf dem Resolver wäre zwar technisch möglich, würde aber die gesamte GraphQL-Infrastruktur des Resolvers mit verändern. Die Trennung von Resolver und Service Contract hält die Erweiterungspunkte sauber und unabhängig von der API-Schicht.
# Client-side mutation call with variables
mutation CreateEntry($input: CreateWishlistEntryInput!) {
createWishlistEntry(input: $input) {
entry_id
customer_id
product_sku
quantity
created_at
user_errors {
code
message
path
}
}
}
# Variables:
# {
# "input": {
# "customer_id": 42,
# "product_sku": "MH12-XS-Black",
# "note": "Geschenk für Geburtstag",
# "quantity": 2.0
# }
# }
5. Vollständiges Beispiel: Kundenwunschliste per Mutation anlegen
Das Beispiel kombiniert alle drei Schichten: Schema, Resolver und Service Contract. Der Resolver prüft zunächst, ob ein authentifizierter Kunde im Kontext vorhanden ist — andernfalls wird ein strukturierter user_errors-Eintrag zurückgegeben, kein generischer GraphQL-Fehler. Danach übergibt er die Input-Daten an den WishlistManagementInterface-Service und mappt das Ergebnis auf den CreateWishlistEntryOutput-Typ.
Eine wichtige Designentscheidung: Fehler werden im user_errors-Array zurückgegeben, nicht als HTTP-Level-Fehler oder GraphQL-Level-Errors. Dieses Muster — auch als "Errors as Data" bekannt — ist in Magento's eigenem GraphQL-Schema weit verbreitet (Checkout, Cart) und ermöglicht dem Frontend, spezifisch auf verschiedene Fehlerzustände zu reagieren, ohne den gesamten Response als Fehler zu behandeln. Das vereinfacht die Fehlerbehandlung im Frontend erheblich.
# Possible response structures for the wishlist mutation
# Success case
{
"data": {
"createWishlistEntry": {
"entry_id": 157,
"customer_id": 42,
"product_sku": "MH12-XS-Black",
"quantity": 2.0,
"created_at": "2026-05-09T10:30:00+00:00",
"user_errors": []
}
}
}
# Error case: product not found
{
"data": {
"createWishlistEntry": {
"entry_id": null,
"customer_id": null,
"product_sku": null,
"quantity": null,
"created_at": null,
"user_errors": [
{
"code": "PRODUCT_NOT_FOUND",
"message": "Product with SKU MH12-XS-Black not found",
"path": ["input", "product_sku"]
}
]
}
}
}
6. Falsch gegen richtig: Logik im Resolver vs. Service Contract
Die Trennlinie zwischen Resolver und Service Contract zu ziehen fällt erfahrenen Entwicklern leicht — für alle anderen ist sie eine der häufigsten Quellen von schlecht wartbarem Magento-GraphQL-Code. Ein Resolver, der direkt ein Repository aufruft, Duplikate per SQL-Query prüft und Events selbst feuert, ist kein Resolver mehr — er ist ein Service, der als Resolver verkleidet ist.
| Aspekt | Falsch: Logik im Resolver | Richtig: Service Contract | Vorteil |
|---|---|---|---|
| Wiederverwendung | Nur über GraphQL nutzbar | REST, CLI, andere Resolver | Fachlogik kanalagnostisch |
| Testbarkeit | GraphQL-Kontext nötig | Reiner PHP-Unit-Test | Schnellere, isolierte Tests |
| Erweiterbarkeit | Kein Plugin-Punkt | Plugin auf Interface | Nachträgliche Erweiterungen ohne Resolver-Änderung |
| Fehlerbehandlung | GraphQL-Error-Format zwingend | Exception-Typen frei wählbar | Resolver übersetzt in user_errors |
| Events | Events im Resolver sind unüblich | Events im Service Standard | Observer-Muster konsistent |
7. Typische Fehlerbilder bei Magento-Mutationen
Das häufigste Fehlerbild: Der Resolver gibt eine generische GraphQlInputException zurück, ohne die Fehlerursache zu kategorisieren. Das Frontend bekommt eine Fehlermeldung, aber keinen Code, keinen Pfad und keine Möglichkeit, den Fehler maschinenlesbar auszuwerten. Korrekt ist es, Fehler als user_errors-Einträge in den Response-Typ zu verpacken — mit einem Enum-Code, der programmatisch auswertbar ist.
Ein zweites Fehlerbild: Der Input-Typ im Schema hat Pflichtfelder, aber die Validierung im Service Contract prüft sie trotzdem noch einmal manuell. Das ist redundant, wenn das Schema korrekt konfiguriert ist — Magento's GraphQL-Infrastruktur erzwingt Pflichtfelder bereits auf Schema-Ebene und gibt einen Validierungsfehler zurück, bevor der Resolver überhaupt aufgerufen wird. Zusätzliche Null-Checks im Resolver oder Service sind trotzdem sinnvoll für Felder, deren Validierung von Datenbankzustand abhängt — etwa ob eine Produkt-SKU wirklich existiert.
8. Input-Validierung: wo sie hingehört
Die Antwort auf diese Frage hängt von der Art der Validierung ab. Strukturelle Validierung — ist ein Pflichtfeld vorhanden, ist ein Enum-Wert gültig, ist eine Zahl positiv — gehört ins Schema. GraphQL übernimmt diese Prüfung automatisch und gibt Validierungsfehler zurück, bevor der Resolver aufgerufen wird. Semantische Validierung — existiert das Produkt mit dieser SKU, hat der Kunde die nötige Berechtigung, ist die Menge innerhalb des erlaubten Bereichs — gehört in den Service Contract.
Was nicht in den Resolver gehört: Datenbankabfragen zur Validierung. Ein Resolver, der zuerst prüft, ob ein Produkt existiert, bevor er den Service aufruft, macht die Arbeit doppelt. Der Service muss das sowieso prüfen, weil er der einzige Ort ist, der über alle nötigen Informationen verfügt. Doppelte Datenbankabfragen im Resolver-Service-Tandem sind eines der häufigsten Performance-Probleme in schlecht strukturierten Magento-Modulen.
9. Mutations-Tests: PHPUnit und GraphQL-Integrationstests
Mutations lassen sich auf zwei Ebenen testen. Auf Unit-Test-Ebene wird der Service Contract direkt aufgerufen — mit gemockten Repositories und einem kontrollierten Eingabedatensatz. Dieser Test prüft, ob die Fachlogik korrekt implementiert ist: Werden Duplikate erkannt? Werden Events gefeuert? Werden Exceptions korrekt ausgelöst? Auf Integrationstest-Ebene wird der echte GraphQL-Endpunkt mit einem vollständigen Request aufgerufen — Magento stellt dafür GraphQlMutationTest bereit, eine Basisklasse für HTTP-basierte Mutationstests.
Ein oft übersehener Testfall: Was passiert, wenn dieselbe Mutation zweimal mit denselben Eingabedaten aufgerufen wird? Ist Idempotenz gewünscht oder soll ein Duplikat-Fehler erzeugt werden? Dieser Fall muss explizit getestet werden, weil Magento's GraphQL-Layer keine eingebaute Idempotenz-Mechanik hat. Das Ergebnis bei Doppelaufruf hängt ausschließlich von der Implementierung im Service Contract ab.
# Schema introspection: verify mutation is correctly registered
query VerifyMutationSchema {
__schema {
mutationType {
fields {
name
args {
name
type {
name
kind
ofType {
name
kind
}
}
}
type {
name
kind
}
}
}
}
}
# Filter results for "createWishlistEntry" to verify:
# - arg "input" of type "CreateWishlistEntryInput!" (NON_NULL INPUT_OBJECT)
# - return type "CreateWishlistEntryOutput" (OBJECT)
10. Zusammenfassung
Eine Mutation in Magento richtig zu bauen bedeutet: Input-Typen und Response-Typen im Schema sauber modellieren, den Resolver auf reine Delegation beschränken und die Fachlogik vollständig im Service Contract kapseln. Diese drei Schichten sind das Fundament für testbare, erweiterbare und wartbare Mutations-Implementierungen. Fehler werden als user_errors in strukturierten Response-Typen zurückgegeben — nicht als generische GraphQL-Errors.
Der größte Hebel liegt in der konsequenten Trennung von Resolver und Service Contract. Wer Fachlogik im Service Contract kapselt, gewinnt automatisch einen Plugin-Punkt für Erweiterungen, einen REST-API-kompatiblen Service und einen Unit-testbaren Code-Pfad ohne GraphQL-Kontext. Das ist kein Mehraufwand — es ist die Grundlage dafür, dass Magento-Module langfristig wartbar bleiben.
Mutation in Magento bauen — Das Wichtigste auf einen Blick
Schema-Design
Input-Typ mit Pflichtfeldern und Response-Typ mit user_errors definieren. Kein Boolean als Rückgabewert.
Resolver-Prinzip
Nur delegieren. Kein SQL, keine Events, keine Duplikatprüfung im Resolver. Alles gehört in den Service Contract.
Errors as Data
user_errors im Response-Typ statt GraphQL-Level-Errors. Enum-Code für maschinenlesbare Fehlerauswertung im Frontend.
Testbarkeit
Service Contract direkt mit PHPUnit testen. GraphQL-Integrationstest für Endpunkt-Verifikation. Duplikat-Verhalten explizit testen.