{ }
type
GraphQL · Resolver · Architektur · Magento · Clean Code
Resolver-Architektur: schlanke Resolver
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.

15 Min. Lesezeit Delegation · Service Layer · Magento ResolverInterface GraphQL · PHP 8.4 · Magento 2

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.

11. FAQ: Resolver-Architektur in GraphQL

1Was darf ein Resolver maximal tun?
Argumente extrahieren, Kontext prüfen, an einen Service delegieren und das Ergebnis zurückgeben. Keine eigene Logik.
2Warum entsteht Business-Logik im Resolver?
Weil ein Service Layer fehlt oder nicht genutzt wird. Der Resolver ist der einfachste Ort – und so wächst er.
3Wie testet man einen schlanken Resolver?
Mit einem einzigen Mock des Service-Interface. Kein Datenbanksetup, kein Magento-Bootstrap nötig.
4Was ist ein DataProvider?
Eine separate Klasse, die interne Model-Objekte auf das GraphQL-Array-Format mappt. Trennt Ausgabe-Transformation vom Resolver.
5Wie viele Abhängigkeiten sollte ein Resolver haben?
Idealerweise eine bis drei. Mehr als vier ist ein Warnsignal für zu viel Verantwortung.
6Wie behandelt man Fehler im schlanken Resolver?
try-catch um den Service-Aufruf. Domain-Exceptions in GraphQL-Fehler übersetzen. Intern loggen, extern generische Meldung.
7Unterschied Query- vs. Mutation-Resolver?
Query-Resolver lesen und sind oft cachebar. Mutation-Resolver lösen Seiteneffekte aus. Beide sollen delegieren.
8Darf ein Resolver direkt ein Repository injizieren?
Nur bei trivialen Abfragen ohne Geschäftslogik. In komplexeren Fällen immer ein Service-Interface dazwischen.
9Wie erkennt man aufgeblähte Resolver in Reviews?
resolve() über 30 Zeilen, mehr als 3 Abhängigkeiten, ObjectManager-Aufrufe, direkter Collection-Zugriff oder duplizierte Validierung.
10Was bringt Delegation konkret in Magento?
Resolver unter 20 Zeilen, Unit-Tests ohne Magento-Instanz, parallele Entwicklung von Service- und Schema-Layer im Team.