Felder, Typen, Interfaces und Unions sinnvoll aufbauen
Ein schlecht designtes Schema ist schwer zu erweitern, erzwingt Breaking Changes und macht Resolver komplex. Die richtigen Nullability-Entscheidungen, Custom Scalars und der korrekte Einsatz von Interfaces und Unions entscheiden über die Langlebigkeit einer GraphQL-API.
Inhaltsverzeichnis
- 1. Schema-First als Entwicklungsstrategie
- 2. Nullability: die häufig unterschätzte Design-Entscheidung
- 3. Custom Scalars statt generischer String-Typen
- 4. Interfaces: gemeinsame Felder typisieren
- 5. Unions: heterogene Ergebnismengen
- 6. Interface vs. Union: wann was?
- 7. Schema-Design in Magento: graphqls-Dateien und Erweiterbarkeit
- 8. Schema-Design-Entscheidungen im Vergleich
- 9. Zusammenfassung
- 10. Das Wichtigste auf einen Blick
- 11. FAQ
1. Schema-First als Entwicklungsstrategie
Bei Schema-First wird das GraphQL-Schema in SDL (Schema Definition Language) als Vertragsartefakt definiert, bevor Frontend- oder Backend-Implementierung beginnt. Das Schema ist das gemeinsame Dokument zwischen Frontend-Entwicklern, die wissen müssen, welche Daten verfügbar sind, und Backend-Entwicklern, die wissen müssen, was implementiert werden muss. Im Gegensatz zu Code-First – bei dem das Schema aus dem Code generiert wird – zwingt Schema-First alle Beteiligten, Datenmodelle und Schnittstellen explizit zu diskutieren, bevor Code geschrieben wird.
Das praktische Ergebnis: Missverständnisse zwischen Teams werden früh aufgedeckt. Ein Frontend-Entwickler, der ein Feld discountPercent: Float erwartet, während der Backend-Entwickler discount_percent: Int implementiert, bemerkt diesen Konflikt sofort, wenn beide das Schema als Basis nehmen. In Magento manifestiert sich Schema-First in den .graphqls-Dateien, die jedes Modul definiert. Das Magento-Schema selbst ist ein gutes Beispiel für Schema-First-Design: alle GraphQL-Typen sind in SDL-Dateien definiert und durch den Magento-Schema-Stitching-Mechanismus zusammengeführt.
2. Nullability: die häufig unterschätzte Design-Entscheidung
Nullability in GraphQL ist eine bewusste Design-Entscheidung mit weitreichenden Konsequenzen. In GraphQL ist jedes Feld standardmäßig nullable – das bedeutet, ein Resolver darf null zurückgeben, ohne einen Fehler auszulösen. Non-nullable Felder werden mit ! gekennzeichnet (String!). Der Unterschied ist fundamental: wenn ein Non-nullable Feld null zurückgibt, propagiert GraphQL den Fehler nach oben und nullt das nächstgültige nullable Feld. In tiefen Typstrukturen kann das dazu führen, dass ein einzelner fehlgeschlagener Resolver den gesamten Teilbaum der Response auf null setzt.
Die Faustregel für Nullability-Entscheidungen: Felder, die immer vorhanden sein müssen, damit die API sinnvoll nutzbar ist, werden als Non-nullable definiert (id: ID!, sku: String!). Felder, die konzeptuell optional sind oder aus einem sekundären Datenquellenaufruf stammen, bleiben nullable. Zu viele Non-nullable Felder machen das Schema starr und erhöhen die Fehleranfälligkeit bei partiellen Datenbankausfällen. Zu viele nullable Felder zwingen das Frontend zu ständigen Null-Checks. Ein ausgewogenes Design nimmt Pflichtfelder als Non-nullable und optionale Kontextdaten als nullable.
# Nullability-Design: bewusste Entscheidungen für jeden Feldtyp
type Product {
# Non-nullable: ohne diese Felder ist das Produkt nicht darstellbar
id: ID!
sku: String!
name: String!
price_range: PriceRange!
url_key: String!
# Nullable: optionale Metadaten, kein Fehler wenn leer
description: String
meta_title: String
short_description: String
# Nullable mit Reason: stammt aus separatem Service-Aufruf
# Kann null sein bei Service-Ausfall ohne den Produkt-Resolver zu brechen
review_summary: ReviewSummary
# Non-nullable Liste, aber nullable Listenelemente
# Liste ist immer vorhanden (mindestens leer), Elemente könnten null sein
media_gallery: [MediaGalleryInterface]!
# Non-nullable Liste mit Non-nullable Elementen
# Garantiert: Liste vorhanden und alle Elemente vorhanden
categories: [CategoryInterface!]!
}
# Falsch: alles non-nullable macht den Resolver fragil
type ProductFragile {
id: ID!
sku: String!
review_summary: ReviewSummary! # Bricht, wenn Review-Service down
description: String! # Bricht, wenn kein Description-Wert
}
3. Custom Scalars statt generischer String-Typen
Custom Scalars lösen ein häufiges Schema-Problem: semantisch bedeutungsvolle Werte, die als primitive Typen modelliert werden. Ein Datum als String, eine URL als String, ein JSON-Blob als String – all das funktioniert technisch, gibt dem Client aber keine Typinformation darüber, wie er mit dem Wert umgehen soll. Custom Scalars fügen semantische Bedeutung hinzu: DateTime statt String signalisiert, dass ein ISO-8601-Datum erwartet wird. URL statt String kommuniziert, dass der Wert eine valide URL ist. Das verbessert nicht nur die Dokumentation, sondern ermöglicht Client-seitige Validierung durch Code-Generation-Tools.
Magento nutzt Custom Scalars an mehreren Stellen: Money für Geldbeträge (mit Wert und Währung), obwohl das dort als Object-Typ statt als Scalar gelöst ist. Für eigene Module empfiehlt sich der Custom Scalar DateTime für alle Zeitstempel statt String, PhoneNumber für Telefonnummern mit impliziter Formatvalidierung und JSON für strukturierte Daten, die in einem Feld gespeichert werden sollen, ohne dafür einen vollständigen Typen zu definieren. Letzteres sollte sparsam eingesetzt werden – ein expliziter Typ ist fast immer besser als ein JSON-Blob.
4. Interfaces: gemeinsame Felder typisieren
GraphQL Interfaces definieren eine Menge von Feldern, die alle implementierenden Typen aufweisen müssen. Sie lösen das Problem, wenn mehrere Typen konzeptuell verwandt sind und gemeinsame Felder teilen – diese Gemeinsamkeit wird im Schema explizit ausgedrückt, statt in jedem Typ einzeln zu definieren. Magento nutzt Interfaces extensiv: ProductInterface definiert alle Felder, die jedes Produkt haben muss, während SimpleProduct, ConfigurableProduct und BundleProduct dieses Interface implementieren und eigene zusätzliche Felder ergänzen.
Das Schlüsselprinzip beim Interface-Design: das Interface enthält nur Felder, die für alle implementierenden Typen sinnvoll sind. Ein Feld, das nur für konfigurierbare Produkte existiert (configurable_options), gehört nicht ins ProductInterface, sondern in den konkreten Typ ConfigurableProduct. Überladene Interfaces, die Felder aller möglichen Implementierungen enthalten, werden schwergewichtig und erschweren neue Implementierungen – weil jede neue Implementierung alle Felder bedienen muss, auch wenn semantisch sinnlos.
# Interface-Design: gemeinsame Felder klar definieren
interface ProductInterface {
# Fields required by ALL product types
id: ID!
sku: String!
name: String!
url_key: String!
price_range: PriceRange!
stock_status: ProductStockStatus!
categories: [CategoryInterface]
media_gallery: [MediaGalleryInterface]
}
# Each concrete type implements the interface and adds its own fields
type SimpleProduct implements ProductInterface {
id: ID!
sku: String!
name: String!
url_key: String!
price_range: PriceRange!
stock_status: ProductStockStatus!
categories: [CategoryInterface]
media_gallery: [MediaGalleryInterface]
# SimpleProduct-specific:
weight: Float
only_x_left_in_stock: Float
}
type ConfigurableProduct implements ProductInterface {
id: ID!
sku: String!
name: String!
url_key: String!
price_range: PriceRange!
stock_status: ProductStockStatus!
categories: [CategoryInterface]
media_gallery: [MediaGalleryInterface]
# ConfigurableProduct-specific:
configurable_options: [ConfigurableProductOption]
variants: [ConfigurableVariant]
}
# Client query using inline fragments on the interface
query GetProducts($search: String!) {
products(search: $search) {
items {
__typename
sku
name
price_range { minimum_price { final_price { value currency } } }
... on ConfigurableProduct {
configurable_options { attribute_code label }
}
... on SimpleProduct {
only_x_left_in_stock
}
}
}
}
5. Unions: heterogene Ergebnismengen
GraphQL Unions modellieren Ergebnismengen, die Werte unterschiedlicher Typen ohne gemeinsame Felder enthalten können. Der typische Anwendungsfall ist eine Suche, die verschiedene Entitätstypen zurückgeben kann: Produkte, Kategorien, CMS-Seiten. Diese Typen teilen keine sinnvollen gemeinsamen Felder – eine CMS-Seite hat keinen SKU, ein Produkt keinen Seiteninhalt. Trotzdem will der Client sie in einer einzigen Antwort empfangen. Unions ermöglichen genau das: union SearchResult = Product | Category | CmsPage.
Unions können keine gemeinsamen Felder deklarieren – der Client muss immer über __typename und Inline-Fragmente auf die konkreten Typen zugreifen. Das ist ein entscheidender Unterschied zu Interfaces. Wenn zwei Typen tatsächlich gemeinsame Felder haben, ist ein Interface die bessere Wahl – Unions sind für wirklich heterogene Mengen ohne semantische Überlappung. Magento verwendet Unions beispielsweise für Checkout-Zahlungsmethoden-Konfigurationen, wo PayPal-Konfiguration und Kreditkarten-Konfiguration völlig unterschiedliche Felder haben.
6. Interface vs. Union: wann was?
Die Entscheidung zwischen Interface und Union ist eine der häufigsten Designfragen beim Aufbau eines GraphQL-Schemas. Die Leitfrage: Gibt es gemeinsame Felder, die alle Typen aufweisen? Ja → Interface. Nein → Union. Wenn unterschiedliche Entitäten gemeinsame Grundfelder haben, die der Client ohne Inline-Fragmentierung nutzen kann, ermöglicht ein Interface flüssigere Queries. Wenn die Typen fundamental verschieden sind und kein sinnvolles gemeinsames Feld existiert, ist eine Union klarer.
Ein Grenzfall: manchmal haben Typen ein einziges gemeinsames Feld (etwa id: ID!), sind aber ansonsten völlig verschieden. In diesem Fall sollte man prüfen, ob das gemeinsame Feld tatsächlich semantisch identisch ist: die ID eines Produkts und die ID einer CMS-Seite sind technisch beide IDs, aber konzeptuell aus verschiedenen Namenräumen. Hier ist eine Union oft die ehrlichere Modellierung – ein Interface nur für ein technisches Feld wäre künstlich. Wenn in Zukunft mehr gemeinsame Felder hinzukommen könnten, ist ein Interface zukunftssicherer.
7. Schema-Design in Magento: graphqls-Dateien und Erweiterbarkeit
Magento implementiert GraphQL-Schema-Design über .graphqls-Dateien, die in jedem Modul im Verzeichnis etc/schema.graphqls liegen. Das Besondere: Magento fügt diese Schemata zur Laufzeit zusammen und erlaubt es, bestehende Typen zu erweitern (type Query wird in jedem Modul ergänzt). Das ist eine mächtige Erweiterbarkeit, hat aber auch Fallstricke: wenn ein Modul einen bestehenden Typ mit einem Feld ergänzt, das denselben Namen hat wie ein Feld aus einem anderen Modul, entsteht ein Konflikt, der schwer zu debuggen ist.
Das beste Schema-Design-Pattern für Magento-Module: eigene Typ-Namespaces verwenden. Statt ein generisches Feld extra_info zum ProductInterface hinzuzufügen, ein modulspezifisches Feld mironsoft_product_meta: MironsoftProductMeta ergänzen. Der Typ MironsoftProductMeta gehört vollständig dem Modul und kann ohne Konflikte mit anderen Modulen weiterentwickelt werden. Außerdem empfiehlt sich die Nutzung von @resolver-Direktiven in der graphqls-Datei, die direkt auf die zuständige PHP-Resolver-Klasse zeigen – so ist der Zusammenhang zwischen Schema und Implementierung sofort sichtbar.
# Magento-Modul-Schema: etc/schema.graphqls
# Erweiterung des ProductInterface mit modulspezifischem Namespace
# Bestehenden Typ erweitern ohne Konflikte:
type Query {
# Module-specific query — no naming conflict risk
mironsoftProductRecommendations(
sku: String!
limit: Int = 5
): MironsoftRecommendationResult! @resolver(class: "Mironsoft\\Catalog\\Model\\Resolver\\ProductRecommendations")
}
# Module-owned type: can be evolved independently
type MironsoftRecommendationResult {
items: [MironsoftRecommendedProduct!]!
algorithm: String!
generated_at: String!
}
type MironsoftRecommendedProduct {
sku: String!
score: Float!
reason: MironsoftRecommendationReason!
product: ProductInterface! @resolver(class: "Mironsoft\\Catalog\\Model\\Resolver\\RecommendedProductLoader")
}
enum MironsoftRecommendationReason {
FREQUENTLY_BOUGHT_TOGETHER
SIMILAR_ATTRIBUTES
VIEWED_TOGETHER
PRICE_RANGE_MATCH
}
# Extending ProductInterface — using module-namespaced field
type ProductInterface {
mironsoft_sustainability_score: Float @resolver(class: "Mironsoft\\Catalog\\Model\\Resolver\\SustainabilityScore")
mironsoft_lead_time_days: Int @resolver(class: "Mironsoft\\Catalog\\Model\\Resolver\\LeadTime")
}
8. Schema-Design-Entscheidungen im Vergleich
Die wichtigsten Schema-Design-Entscheidungen haben direkte Auswirkungen auf die Wartbarkeit, Erweiterbarkeit und Performance eines GraphQL-Schemas. Dieser Vergleich zeigt häufige Fehlentscheidungen und ihre bessere Alternative.
| Design-Entscheidung | Problematisches Muster | Empfohlenes Muster | Begründung |
|---|---|---|---|
| Typsicherheit | date: String |
date: DateTime |
Custom Scalar signalisiert Format, ermöglicht Code-Gen-Validierung |
| Nullability | Alles Non-nullable (!) |
Pflichtfelder non-nullable, optionale nullable | Zu viele ! macht Schema starr und bricht bei partiellen Fehlern |
| Polymorphie | Alles in einem Typ mit vielen nullable Feldern | Interface oder Union je nach Gemeinsamkeiten | Klare Typstruktur verbessert Tooling und Dokumentation |
| Magento-Erweiterung | Generische Feldnamen ohne Namespace | Modulspezifischer Präfix (mironsoft_*) |
Verhindert Namenskonflikte mit anderen Modulen |
| Entwicklungsstrategie | Code-First: Schema aus Code generiert | Schema-First: SDL als Vertragsartefakt | Frühe Abstimmung zwischen Teams, Mock-Server sofort möglich |
Das häufigste Anti-Pattern bei Magento-GraphQL-Schema-Design ist das undifferenzierte Erweitern des ProductInterface mit Feldern ohne Namespace. Nach mehreren Jahren und mehreren Modulen entsteht so ein überladenes Interface mit Feldern verschiedener Herkunft ohne klare Zuordnung. Das Namespacing-Pattern – modulspezifische Felder mit Modul-Präfix – löst dieses Problem dauerhaft, ohne Schema-Flexibilität zu opfern.
9. Zusammenfassung
Gutes GraphQL-Schema-Design trifft bewusste Entscheidungen bei Nullability, Typen, Interfaces und Unions. Nullability bestimmt, wie tolerant das Schema gegenüber partiellen Fehlern ist – zu viele Non-nullable Felder machen das Schema fragil. Custom Scalars geben primitiven Werten semantische Bedeutung und verbessern Tooling und Dokumentation. Interfaces modellieren gemeinsame Felder verwandter Typen und ermöglichen typsichere Queries ohne vollständige Inline-Fragmentierung. Unions modellieren heterogene Ergebnismengen ohne erzwungene gemeinsame Felder.
In Magento wird Schema-Design durch .graphqls-Dateien umgesetzt, die beim Deployment zusammengeführt werden. Modulspezifische Namespaces verhindern Konflikte zwischen Modulen und machen den Schema-Ursprung jedes Feldes nachvollziehbar. Schema-First als Entwicklungsstrategie stellt sicher, dass das Schema als Vertragsartefakt zwischen Frontend- und Backend-Teams steht, bevor Implementierung beginnt.
GraphQL Schema-Design — Das Wichtigste auf einen Blick
Nullability bewusst wählen
Pflichtfelder non-nullable (id!, sku!), optionale Kontextdaten nullable. Zu viele ! macht das Schema fragil bei partiellen Datenbankausfällen.
Custom Scalars statt String
DateTime, URL, PhoneNumber statt generischer Strings – verbessert Dokumentation, Code-Generation und Client-seitige Validierung.
Interface vs. Union
Gemeinsame Felder → Interface. Fundamental unterschiedliche Typen ohne gemeinsame Felder → Union. Beides löst Polymorphie-Probleme, aber für unterschiedliche Fälle.
Magento-Namespace-Pattern
Modulfelder mit Präfix (mironsoft_*) – verhindert Namenskonflikte zwischen Modulen und macht Feld-Herkunft im Schema sofort erkennbar.