statt Business-Logik-Monster
Ein GraphQL-Resolver, der Datenbankabfragen, Validierungen, Berechtigungsprüfungen und Transformationslogik in einer Klasse vereint, ist kein Resolver mehr – er ist ein Mini-Service ohne Grenzen. Dieser Artikel zeigt, wie man Resolver schlank hält, klare Schichten einzieht und Business-Logik dorthin verschiebt, wo sie hingehört.
Inhaltsverzeichnis
- 1. Was ein Resolver wirklich leisten soll
- 2. Wie Resolver zu Monstern werden
- 3. Das Delegations-Prinzip in der Praxis
- 4. Service Layer und Repositories richtig einsetzen
- 5. Resolver-Architektur in Magento konkret
- 6. Falsch / Richtig im direkten Vergleich
- 7. Typische Fehlerbilder und ihre Ursachen
- 8. Testbarkeit als Architekturindikator
- 9. Resolver-Typen im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was ein Resolver wirklich leisten soll
Ein GraphQL-Resolver hat genau eine Verantwortung: er empfängt den Kontext der Anfrage, delegiert die eigentliche Arbeit an die richtige Schicht und gibt das Ergebnis in einem Format zurück, das das Schema erwartet. Das klingt trivial, wird in der Praxis aber erstaunlich oft falsch umgesetzt. Resolver sind der Einstiegspunkt in die Datenschicht – sie sind die Brücke zwischen dem GraphQL-Schema und der Anwendungslogik. Diese Brücke soll schlank sein, nicht belastbar mit Logik.
In einem gut strukturierten System liest man einen Resolver und versteht innerhalb von zehn Zeilen, was er tut: er prüft, ob der Aufrufer berechtigt ist, extrahiert die relevanten Argumente, delegiert an einen Service oder ein Repository und gibt das Ergebnis zurück. Mehr braucht ein Resolver nicht. Was er keinesfalls enthalten sollte: Datenbankabfragen, komplexe Validierungsregeln, Berechnungen oder Transformationen, die auch in Unit-Tests außerhalb des GraphQL-Kontextes sinnvoll zu testen wären.
2. Wie Resolver zu Monstern werden
Die Entstehung eines Business-Logik-Monsters im Resolver ist selten ein bewusster Entschluss. Meistens beginnt es mit einer kleinen Ausnahme: eine Validierung, die „nur hier" benötigt wird, eine Transformation, die „schnell noch" eingefügt wird, eine Datenbankabfrage, die „eigentlich zum Repository gehört, aber fürs Erste hier steht". Jede dieser Ausnahmen ist für sich harmlos – in der Summe entsteht ein Resolver, der Dutzende Abhängigkeiten hat, hunderte Zeilen lang ist und ohne vollständige Kenntnis aller internen Zustände nicht mehr testbar ist.
Ein weiterer Treiber ist fehlende Schichtentrennung in der Architektur. Wenn das Projekt kein Service Layer hat, wenn Repository-Klassen direkt in Resolver injiziert werden und dort komplexe Abfragelogik ausführen, wenn Berechtigungsprüfungen manuell in jedem Resolver dupliziert werden – dann hat man strukturell keinen Weg, den Resolver schlank zu halten. Die Lösung liegt nicht im Resolver selbst, sondern in der Architektur darum herum. Ein schlanker Resolver ist ein Indikator dafür, dass der Rest des Systems gut strukturiert ist.
3. Das Delegations-Prinzip in der Praxis
Das Delegations-Prinzip für Resolver lässt sich in einem Satz zusammenfassen: der Resolver entscheidet, wer die Arbeit macht – er macht sie nicht selbst. In der Umsetzung bedeutet das: Argumente aus $args extrahieren, an einen Service oder ein Repository übergeben, Ergebnis zurückgeben. Optionale Ergänzung: Kontextprüfung am Anfang. Das war es. Alles, was über diese drei Schritte hinausgeht, gehört in eine andere Klasse.
Dieser Ansatz hat messbare Vorteile: der Resolver kann ohne GraphQL-Infrastruktur getestet werden, weil er keine eigene Logik enthält. Der Service darunter kann unabhängig vom GraphQL-Layer weiterentwickelt werden. Änderungen an der Geschäftslogik berühren nicht die Resolver-Klasse. Und wenn man das Schema erweitert, müssen keine Resolver refactored werden – nur die Mapping-Schicht zwischen Schema-Feldern und Service-Rückgaben. Delegation schafft Wartbarkeit.
# Clean resolver schema: the resolver only maps, not decides
type Query {
customerOrders(pageSize: Int = 10, currentPage: Int = 1): OrderList!
}
type OrderList {
total_count: Int!
items: [Order!]!
}
type Order {
order_number: String!
status: String!
grand_total: Float!
created_at: String!
}
4. Service Layer und Repositories richtig einsetzen
Die sauberste Architektur für GraphQL-Resolver in PHP folgt einem einfachen Muster: der Resolver kennt einen Service, der Service kennt ein Repository, das Repository kennt die Datenbank. Jede Schicht hat genau eine Verantwortung. Der Service enthält die Geschäftslogik – also Validierungen, Berechnungen, Zugriffsregeln. Das Repository übersetzt Datenanfragen in konkrete Datenbankoperationen. Der Resolver verbindet die GraphQL-Welt mit dem Service.
In Magento bedeutet das: Resolver implementieren ResolverInterface, injizieren ein Service-Interface aus dem Api/-Verzeichnis des Moduls und rufen genau eine Methode darauf auf. Die Validierung von Argumenten liegt in der Service-Klasse. Die Authentifizierungsprüfung liegt in einer Middleware oder einem Plugin auf dem Resolver. Das Mapping von internen Model-Objekten auf das GraphQL-Ausgabeformat liegt in einer separaten Data-Provider-Klasse. So hat jede Klasse maximal fünf bis zehn Abhängigkeiten, und keine wird zur Gottklasse.
# Resolver delegates to service — no business logic here
# PHP equivalent: $this->orderService->getCustomerOrders($customerId, $args)
query CustomerOrders {
customerOrders(pageSize: 5, currentPage: 1) {
total_count
items {
order_number
status
grand_total
}
}
}
5. Resolver-Architektur in Magento konkret
In Magento 2 ist die Schnittstelle für GraphQL-Resolver Magento\Framework\GraphQl\Query\ResolverInterface. Jeder Resolver implementiert eine einzige Methode: resolve(). Diese Methode empfängt das Feld, den Kontext, ResolveInfo und die Argumente. Der häufigste Fehler bei Magento-Resolvern: der gesamte Lookup-Code, die EAV-Attribut-Transformation und die Berechtigungsprüfung landen alle in dieser einen Methode. Nach sechs Monaten versteht kein Teammitglied mehr, warum welche Zeile was tut.
Die Alternative: resolve() hat maximal 15 Zeilen. Argument-Extraktion, Authentifizierungscheck über den Kontext ($context->getUserId()), ein Aufruf an ein injiziertes Service-Interface, Rückgabe des gemappten Ergebnisses. Der Service übernimmt alles andere. Für die Ausgabe-Transformation gibt es eine separate DataProvider-Klasse, die ein internes Model auf ein assoziatives Array für die GraphQL-Response mappt. Diese Trennung macht Magento-Resolver testbar, wartbar und erweiterbar – ohne dass man bei jedem neuen Feature eine gewachsene Methode öffnen muss.
# Anti-pattern: resolver does too much (conceptual illustration)
# The resolver fetches, transforms, validates and decides — all in one place
# Good pattern: resolver only coordinates
# 1. Extract args
# 2. Check context (authenticated?)
# 3. Delegate to service interface
# 4. Return mapped result
type Mutation {
submitContactForm(input: ContactFormInput!): ContactFormResult!
}
input ContactFormInput {
name: String!
email: String!
message: String!
}
type ContactFormResult {
success: Boolean!
reference_id: String
}
6. Falsch / Richtig im direkten Vergleich
Der direkteste Weg, das Problem zu illustrieren, ist ein konkreter Vergleich. Der schlechte Resolver öffnet eine Datenbankverbindung, prüft mehrere Bedingungen mit verschachtelten if-Blöcken, transformiert Ergebnismengen mit komplexen array_map-Konstrukten und wirft bei Fehlern direkt GraphQL-Exceptions mit internen Fehlermeldungen. Der gute Resolver hat eine try-catch-Struktur um einen einzigen Service-Aufruf, mapped das Ergebnis mit einer dedizierten Klasse und loggt unerwartete Fehler, bevor er eine generische Fehlermeldung nach außen gibt.
| Aspekt | Falscher Resolver | Richtiger Resolver | Konsequenz |
|---|---|---|---|
| Datenzugriff | Direkte Repository-Calls im Resolver | Delegation an Service-Interface | Testbarkeit ohne DB-Setup |
| Validierung | if-Blöcke im Resolver | Exception aus Service-Schicht | Wiederverwendung der Validierung |
| Ausgabe-Mapping | array_map im Resolver | DataProvider-Klasse | Einfache Schema-Erweiterung |
| Berechtigungen | Manuelle Context-Checks | Middleware / Plugin | Keine duplizierte Auth-Logik |
| Fehlermeldungen | Interne Details nach außen | Generisch + Logging intern | Keine Informationslecks |
Die Tabelle zeigt: es geht nicht darum, den Resolver zu einer leeren Durchleitklasse zu machen. Es geht darum, jede Logikart an den richtigen Ort zu verschieben. Ein Resolver darf Argumente extrahieren, den Kontext prüfen und das Ergebnis zurückgeben. Alles andere ist eine Verantwortung, die in einer anderen Klasse besser aufgehoben ist – und dort auch unabhängig testbar ist.
7. Typische Fehlerbilder und ihre Ursachen
Das häufigste Fehlerbild bei aufgeblähten Resolvern ist die Gottklasse: ein Resolver, der 300 Zeilen hat, zehn Abhängigkeiten per Konstruktor injiziert und in der resolve()-Methode mehrere if-else-Zweige durchläuft, bevor er ein Ergebnis zurückgibt. Das ist kein Resolver mehr, sondern ein Controller ohne Framework. Die Ursache liegt fast immer in fehlenden Schnittstellen: wenn es kein Service-Interface gibt, an das der Resolver delegieren kann, landet die Logik zwangsläufig im Resolver.
Ein zweites Fehlerbild: Resolver, die direkt mit ObjectManager arbeiten, weil die korrekte Dependency Injection zu aufwendig erscheint. In Magento ist das besonders kritisch, weil der ObjectManager den Testzugriff erschwert und die Abhängigkeiten unsichtbar macht. Wer in einem Resolver ObjectManager::getInstance() findet, hat ein starkes Signal, dass die Klasse historisch gewachsen ist und dringend refactored werden sollte. Das dritte Fehlerbild: fehlende Error-Handling-Strategie, sodass Datenbank-Exceptions mit Stack-Traces direkt als GraphQL-Fehler an den Client weitergegeben werden.
8. Testbarkeit als Architekturindikator
Testbarkeit ist der zuverlässigste Indikator dafür, ob ein Resolver gut architekturiert ist. Ein schlanker Resolver lässt sich mit einem einzigen Mock testen: man mockt das Service-Interface, definiert das erwartete Rückgabeobjekt, ruft resolve() mit den entsprechenden Argumenten auf und prüft, ob das gemappte Ergebnis korrekt ist. Kein Datenbanksetup, kein Magento-Bootstrap, kein komplexes Fixture-Management.
Wenn man für einen Resolver-Test zehn Mocks benötigt oder ohne Datenbankverbindung keinen Test schreiben kann, ist das ein klares Zeichen, dass der Resolver zu viel tut. In Magento-Projekten ist das keine theoretische Überlegung: ein aufgeblähter Resolver, der EAV-Daten direkt lädt, Preisberechnungen durchführt und Sichtbarkeitsprüfungen enthält, kann nur mit einer vollständig installierten Magento-Instanz getestet werden. Ein schlanker Resolver, der das alles an einen ProductDataServiceInterface delegiert, lässt sich in Millisekunden in einem Unit-Test prüfen.
# Testing a lean resolver: only one mock needed
# The service interface is mocked — no DB, no Magento bootstrap required
# Resolver under test:
# resolve() -> $this->productService->getProductBySku($args['sku'])
# -> $this->dataProvider->map($product)
# -> return $mapped
query ProductBySku {
productBySku(sku: "MH01-XS-Black") {
sku
name
price
stock_status
}
}
9. Resolver-Typen im Vergleich
Nicht alle Resolver-Typen in GraphQL haben dieselben Anforderungen. Ein Query-Resolver, der Daten liest, hat andere Anforderungen als ein Mutation-Resolver, der Daten schreibt, oder ein Field-Resolver, der einen einzelnen Wert eines übergeordneten Typs aufzulöst. Ein gutes Verständnis der Unterschiede hilft, die richtige Architektur für jeden Typ zu wählen. Mutation-Resolver sind besonders anfällig für Aufblähung, weil sie oft komplexe Seiteneffekte auslösen müssen.
Resolver-Architektur — Das Wichtigste auf einen Blick
Kernprinzip
Resolver delegieren — sie entscheiden, wer die Arbeit macht, aber tun sie nicht selbst. resolve() hat maximal 15 Zeilen.
Service Layer
Geschäftslogik gehört ins Service-Interface. Resolver injizieren Services, keine Repositories oder Models direkt.
Warnsignal
Mehr als 3 injizierte Abhängigkeiten, ObjectManager-Aufrufe oder if-else-Kaskaden in resolve() — sofort refactoren.
Testbarkeit
Ein schlanker Resolver braucht genau einen Mock. Braucht man mehr, ist zu viel Logik im falschen Layer.
10. Zusammenfassung
Die wichtigste Erkenntnis zu schlanken GraphQL-Resolvern ist, dass ein aufgeblähter Resolver immer ein Symptom eines strukturellen Problems ist – fehlende Service-Layer, fehlende Schnittstellen oder fehlende Konventionen im Team. Der Resolver selbst ist nicht das Problem; er ist die sichtbarste Folge davon. Wer Resolver refactored, muss gleichzeitig die Schicht darunter aufbauen: Service-Interfaces, Repositories mit klar definierter Verantwortung und Data-Provider für die Ausgabe-Transformation.
In Magento ist das besonders relevant, weil das Framework mit Service Contracts, Repository-Patterns und dem DI-Container alle notwendigen Bausteine liefert. Man muss sie nur konsequent nutzen. Ein Resolver, der ResolverInterface implementiert, ein Service-Interface injiziert und in resolve() genau einen Aufruf macht, ist wartbar, testbar und erweiterbar – unabhängig davon, wie komplex das Schema darüber oder die Datenbankschicht darunter ist.