Writings
Blog
Filter, Paginate, Sort in new Backend Architecture
28.05.25Filter, Sortierung und Paginierung in einer neuen Backend-Architektur
In unserem aktuellen Projektkontext wurde die bestehende Monolith-Architektur aufgebrochen und in getrennte Verantwortlichkeiten überführt. Mein Team betreut nun das Backend eines zuvor monolithischen Systems, das ursprünglich mit PHP und dem Yii2-Framework umgesetzt war. Die neue Architektur basiert auf einem Go-Backend mit einer modernen JavaScript-Frontendlösung.
Durch die Trennung liegt die Verantwortung für den Entwurf und die Implementierung von API-Funktionalitäten wie Filtern, Sortieren und Paginieren nun vollständig bei unserem Team. Während Yii2 in der Vergangenheit bestimmte Konventionen vorgab, stehen wir nun vor der Aufgabe, eine neue, konsistente und leicht konsumierbare Struktur zu definieren – eine, die sowohl flexibel als auch zukunftssicher ist.
Herausforderungen im Überblick
Mit der neuen Verantwortung für die Backend-API standen wir vor einer Reihe grundlegender Fragen, die es zu klären galt:
- HTTP-Caching: Können und wollen wir Caching auf HTTP-Ebene unterstützen – und wenn ja, unter welchen Bedingungen?
- Generisch vs. spezifisch: Sollten wir eine generische Filterlogik entwickeln, die für viele Anwendungsfälle flexibel einsetzbar ist, oder bewusst auf spezifische, stark kontrollierte Parameter setzen?
- Sicherheit: Wie verhindern wir Missbrauch durch dynamische Filterparameter, etwa SQL-Injection oder Performanceprobleme durch ungefilterte Queries?
- Komplexität der Abfragen: Müssen wir logische Operatoren wie AND/OR/XOR unterstützen, oder reicht ein einfacher Ansatz mit AND aus?
- Bedingungen: Müssen wir verschiedene Bedingungen wie
<
,>
,!
usw. unterstützen, oder ist=
genug ? - Syntax und Standards: Gibt es bewährte oder etablierte Standards, an die wir uns halten können – z. B. JSON:API oder Query-Sprachen wie ExtJS-Syntax?
- Limitierungen durch HTTP: Was tun, wenn URL-Längen durch komplexe Filter überschritten werden – und wie sieht ein sauberer Fallback in POST aus?
Diese und weitere Fragen haben wir in einem internen RFC dokumentiert und verschiedene Ansätze gegenübergestellt.
Vergleich
Positive Eigenschaften
Technik | 🗃️ Cachebar | 🔍 Leicht lestbar in DevTools | 🔀 Operatoren | ⚙️ < ,> ,! usw. | 🔧 Erweiterbar | 🖥️ Einfach zu implementieren - Server | 💻 Einfach zu implementieren - Client |
---|---|---|---|---|---|---|---|
GET mit klassischen Parametern | ✅ | ❌ (VS JSON in body) | ❌ | ❌/⚠️?filter[<field>][gte] | ⚠️ | ✅ | ✅ |
POST klassischen Parametern form-urlencoded | ❌ | ⚠️ | ❌ | ❌/⚠️?filter[<field>][gte] | ⚠️ | ✅ | ✅ |
POST mit JSON im Body | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️(uncommon) |
GET mit JSON im Parameter (q={...} ) | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ⚠️(uncommon) |
GET mit DSQL | ✅ | ✅ | ✅ | ✅ | ✅ | ❌/⚠️ (Parser, Lexer, Query Builder) | ✅ |
POST mit DSQL | ❌ | ✅ | ✅ | ✅ | ✅ | ❌/⚠️ (Parser, Lexer, Query Builder) | ✅ |
DSQL: Domain Speciffc Query Language. z.B. Ext.Data.Query
Negative Eigenschaften
Technik | 📜 Logs sichtbar | 🛡️ Angreifbar bei komplexen Queries | 📏 Längenbeschränkt |
---|---|---|---|
GET mit klassischen Parametern | 😞 | 😊👍 | 😞 |
POST mit klassischen Parametern form-urlencoded | 😊👍 | 😊👍 | 😊👍 |
POST mit JSON im Body | 😊👍 | 🤔 | 😊👍 |
GET mit JSON im Parameter (q={...} ) | 😞 | 🤔 | 😞 |
GET mit DSQL | 😞 | 🤔 | 😞 |
POST mit DSQL | 😊👍 | 🤔 | 😊👍 |
Emoji | Bedeutung |
---|---|
😞 | Schlecht – sollte möglichst vermieden werden |
😊👍 | Gut – kein Problem hier, unbedenklich |
🤔 | Kommt auf die Implementierung an – potenziell problematisch, aber nicht zwangsläufig |
Individuelles Schema
Neben diesen generellen Techniken ist es natürlich auch möglich, jeden Endpunkt mit einem individuellen Schema zu versehen. Dies kann z. B. für Routen ohne Authentifizierung, die öffentlich erreichbar sind, sinnvoll sein. In solchen Fällen sollte das Interface aus Sicherheitsgründen so minimalistisch wie möglich gestaltet sein.
URL-Parameter (GET) vs. Request-Body (POST)
Eine der ersten Überlegungen bei der Gestaltung einer Filter-, Sortier- und Paginierungslogik betrifft den Transportweg der Parameter: Soll die API Filterkriterien über die URL (GET) entgegennehmen oder über den Request-Body (POST)?
Beide Varianten haben klare Vor- und Nachteile:
GET (URL-Parameter)
Vorteile:
- Lesbarkeit und Debugging: URLs mit Query-Parametern lassen sich, bei einfachen Abfragen, einfach lesen, teilen und in Browsern oder Tools wie Postman direkt testen.
- HTTP-Cache: Caching auf Infrastruktur-Ebene (CDNs, Proxies) ist bei GET-Anfragen problemlos möglich.
- REST-Konformität: GET ist semantisch korrekt für rein lesende Zugriffe.
Nachteile:
- Längenbeschränkung: Komplexe Filterstrukturen stoßen schnell an technische Grenzen der maximalen URL-Länge.
- Begrenzte Ausdruckskraft: Verschachtelte Logik (AND/OR) wird schwer oder gar nicht abbildbar.
- Sicherheitsrisiken: Filterparameter – auch sensible – erscheinen in Browser-Historien, Logs oder Monitoring-Tools.
POST (Request-Body)
Vorteile:
- Komplexität möglich: JSON-Strukturen erlauben verschachtelte Filterlogik, Operatoren und Typisierung.
- Einfach zu debuggen mit IDE: Die meisten IDE´s können den Body eines JSON Request strukturiert darstellen.
- Keine Längenbeschränkung: Große Filterobjekte lassen sich problemlos übertragen.
- Trennung von Routing und Query-Logik: Die URL bleibt stabil, während komplexe Logik im Body steckt.
Nachteile:
- Nicht cachebar: POST-Anfragen werden in der Regel nicht über HTTP gecacht.
- Weniger transparent: Filter im Body sind in Logs, Monitoring-Tools und Debugging-Tools nicht sofort sichtbar.
- REST-Inkompatibilität: POST ist semantisch für schreibende Operationen gedacht. Eine klassische Filterabfrage per POST /resource wäre daher inkorrekt.
Lösungsansatz (REST-Inkompatibilität):
Zur Umgehung dieses Problems wurde ein gesonderter Endpunkt eingeführt: POST /api/resource/_filter
. Dieser ermöglicht
die Nutzung des POST-Body für Filteranfragen, ohne mit REST-konformen Operationen wie POST /resource
(
Ressourcenerstellung) zu kollidieren. Diese Lösung eröffnet die Option für POST-basierte Filterlogik, ohne sich bereits
fest auf POST als einzige Methode festzulegen.
Beide Transportformen bleiben valide Optionen. GET eignet sich für einfache, URL-kompatible Filterungen mit hoher Toolunterstützung und Cachingvorteilen. POST eröffnet die Möglichkeit, komplexe Filter als JSON-Objekt zu übertragen – insbesondere sinnvoll, wenn längere Abfragen oder verschachtelte Bedingungen unterstützt werden sollen.
Problem Logging
Ein weiterer wichtiger Aspekt ist das Logging und Monitoring: Filterparameter in GET-URLs tauchen typischerweise in Serverlogs und Access-Logs auf – was Debugging vereinfacht, aber bei sensiblen Daten problematisch sein kann. POST-Bodies hingegen bleiben oft unsichtbar für Standardlogs, was aus Datenschutzsicht ein Vorteil, aus Operationssicht aber eine Herausforderung darstellt.
Problem Längenbeschränkung
Daten, die über die GET-URL übertragen werden, unterliegen typischerweise einer Längenbeschränkung von etwa 2000 Zeichen – je nach Browser, Webserver und Proxy-Infrastruktur. Diese Begrenzung betrifft sowohl den Pfad als auch die Query-Parameter und kann bei komplexen Datenstrukturen, umfangreichen Filterkriterien oder verschlüsselten Tokens schnell erreicht werden. In solchen Fällen drohen abgeschnittene oder fehlerhafte Requests. Darüber hinaus kann es durch URL-Encoding zu zusätzlichem Platzverbrauch kommen. Für größere Datenmengen oder strukturierte Nutzlasten ist daher die Verwendung von POST-Requests die robustere Wahl.
Formatvergleich: Query-Parameter, JSON im Body oder JSON als Query-String
Unabhängig vom HTTP-Verb stellt sich die Frage, in welchem Format Filter, Sortierung und Paginierung übermittelt werden sollen. Drei grundlegende Varianten wurden dabei evaluiert:
1. Klassische Query-Parameter (z. B. filter[status]=active)
Diese Methode ist besonders bei GET-Anfragen weit verbreitet. Sie orientiert sich an JSON:API-Konventionen oder Frameworks wie Yii2:
Beispiel:
GET /api/items?filter[status]=active&filter[age]=30&sort=-created_at&page=2&per-page=10
Vorteile:
- Gut unterstützt durch Frameworks, Tools und HTTP-Clients
- Direkt sichtbar in Logs und Monitoring
- Lesbar und testbar in URL-basierten Werkzeugen
Nachteile:
- Nur flache Filterlogik (implizites
AND
), keine Bedingungen wie<
,in
,like
etc. - Keine Verschachtelung (kein
OR
, keine Gruppenbildung) - Begrenzter Datentyp-Support (alles ist String)
2. JSON im Request-Body (z. B. bei POST /resource/_filter
)
Diese Variante nutzt ein vollständiges JSON-Objekt zur Abbildung von Filter-, Sortier- und Paginierungslogik:
Beispiel:
{ "filter": [ { "field": "status", "op": "=", "value": "active" }, { "field": "age", "op": ">", "value": 30 } ], "sort": [ { "field": "created_at", "direction": "desc" } ], "pagination": { "page": 2, "size": 10 } }
Vorteile:
- Klare Trennung von Feldern, Operatoren und Werten
- Unterstützt AND/OR-Nesting bei Bedarf
- Besser validierbar (z. B. via JSON Schema)
- Gut erweiterbar für zukünftige Anforderungen
Nachteile:
- Nur bei POST sinnvoll nutzbar
- Erfordert eigene Parselogik im Backend
- Weniger intuitiv für einfache Filter
3. JSON als Query-String-Parameter (z. B. q={...}
)
Hierbei wird das komplette JSON-Objekt als ein einzelner Query-Parameter übertragen, z. B.:
GET /api/items?q={"filter":[{"field":"status","op":"=","value":"active"}]}
Vorteile:
- JSON-Logik auch mit GET nutzbar
- Technisch flexibel, auch für komplexe Abfragen
Nachteile:
- Schwer zu lesen und manuell zu testen
- Erhöhtes Risiko durch fehlerhafte Encoding/Decoding-Probleme
- Kaum Unterstützung in Tools und Frameworks
- Unüblich – nicht als Best Practice etabliert
Auswahl
Jede Methode hat ihre Daseinsberechtigung. Während klassische Query-Parameter einfach und schnell einsetzbar sind, bieten JSON-Strukturen (im Body oder ggf. im Parameter) deutlich mehr Ausdruckskraft und Wartbarkeit – besonders bei wachsender Komplexität. Der Einsatz von JSON im Query-String wurde aus Gründen der Praktikabilität und Toolkompatibilität als nicht bevorzugt bewertet.
Struktur des JSON-Objekts für Filter, Sortierung und Paginierung
Für komplexere Anwendungsfälle und POST-basierte Anfragen wurde ein JSON-Format spezifiziert, das drei Hauptbereiche kapselt:
- filter: Liste von Filterkriterien
- sort: Liste von Sortierfeldern
- pagination: Angabe der Seite und Seitengröße
Beispiel:
{ "filter": [ { "field": "status", "con": "=", "value": "active" }, { "field": "age", "con": ">", "value": 30 } ], "sort": [ { "field": "created_at", "direction": "desc" } ], "pagination": { "page": 2, "size": 10 } }
Filter
- field: Der Name des zu filternden Feldes
- con: Die Bedingung (z. B.
=
,<
,>
,in
,like
) - value: Vergleichswert; bei in kann dies ein Array sein
Hinweise:
- Alle Filter innerhalb der Liste sind per Default mit AND verknüpft
- Bei Bedarf kann eine verschachtelte AND/OR-Logik definiert werden (siehe nächste Ausbaustufe)
Sortierung
- field: Das zu sortierende Feld
- direction: Sortierreihenfolge, entweder "asc" oder "desc"
Mehrere Sortierfelder können angegeben werden – die Reihenfolge im Array entspricht der Priorität.
Paginierung
- page: Seitenzahl (beginnend bei 1)
- size: Anzahl der Elemente pro Seite
Defaultwerte und Server-Limits (z. B. max size = 100) werden pro Endpoint festgelegt und dokumentiert.
Vorteile dieser Struktur
- Klar trennbar: Filter, Sortierung und Paginierung sind logisch und syntaktisch getrennt
- Valide JSON: Einfach zu validieren und zu versionieren
- Zukunftssicher: Erweiterbar für spätere Anforderungen (z. B. group, aggregates, search)
Diese Struktur bietet eine robuste Grundlage für konsistente API-Aufrufe mit filterbarer Datenbasis – sowohl für interne als auch externe Clients. Durch die klare Trennung und definierte Form lassen sich diese Anfragen systematisch validieren, loggen und analysieren.
Filter-, Sortier- und Paginierungsformat im Query Parameter: Orientierung an JSON:API
Die JSON:API-Spezifikation definiert kein konkretes Format für Filter, Sortierung oder Paginierung, sondern lediglich empfohlene Namenskonventionen. Die genaue Semantik und Struktur bleibt der jeweiligen Implementierung überlassen.
Quelle: JSON:API
Beispielhafte GET-URL nach JSON:API-Konvention
GET /api/items?filter[status]=active&filter[age]=30&sort=-created_at&page=2&per-page=10
Konventionen
Filter:
- Schlüssel:
filter[<feldname>]
- Nur Gleichheitsvergleiche (=) vorgesehen
- Alle Filterbedingungen sind logisch mit AND verknüpft
Sortierung:
- Schlüssel: sort
- Komma-separierte Liste von Feldnamen
- Vorangestelltes - für absteigende Reihenfolge
- Paginierung:
- Schlüssel:
page
undper-page
(abweichend von JSON:API, aber in bei uns gängiger) - Alternativ gemäß JSON:API:
page[number]
undpage[size]
Beispiel mit JSON:API-konformer Paginierung
GET /api/items?filter[status]=active&sort=name&page[number]=1&page[size]=25
Vorteile dieses Ansatzes
- Sehr gute URL-Lesbarkeit: Ideal für Browser, Bookmarks, Logs, Debugging
- Kompatibel mit HTTP-Caching: GET-Requests lassen sich einfach über Caching-Proxies oder CDNs optimieren
- Framework-Unterstützung: Viele Backend-Frameworks (z. B. Laravel, Yii2, Drupal) und Tools erwarten genau dieses Format
Nachteile
- Eingeschränkte Ausdruckskraft: Keine logischen Verknüpfungen (OR, Gruppenbildung) möglich
- Bedingungen fehlen: Nur Gleichheitsvergleiche vorgesehen – Bedingungen wie
<
,in
,like
sind nicht standardisiert - Typen oft Unklar: Oft wird alles as String angesehen und strenge Typisierung ist nicht möglich.
Der GET-basierte Ansatz im Stil von JSON:API eignet sich hervorragend für einfache Filter mit hohem Wiedererkennungswert und breiter Toolunterstützung. Sobald jedoch komplexe Bedingungen, mehrere Operatoren oder typisierte Werte notwendig werden, stößt diese Struktur an ihre Grenzen.
Alternative: Ausdrucksbasierte Query-Strings / DSQL
Ein weniger verbreiteter, aber technisch interessanter Ansatz besteht darin, Filterkriterien als menschenlesbare _
Domain Specific Query Language (DSQL)_ in einem einzelnen Query-Parameter zu übertragen – z. B. via q=....
Dieses
Format lehnt sich an SQL-Syntax an und wird unter anderem von ExtJS unterstützt. Wichtig ist hierbei zu beachten das der
Parser einen eigenen AST baut und nicht einfach SQL durchreicht.
Siehe: Ext.Data.Query
Beispiel:
GET /api/items?q=name="dieter" and (age >= 25 and age < 30)
Ausdrucksmöglichkeiten:
- Logische Operatoren: and, or
- Vergleiche:
=``,
!=``,<
,<=
,>
,>=
- Mengen:
in
,not
in
- Pattern-Matching:
like
,not like
- Klammerung zur Gruppenbildung
Intern erzeugt dieser Ausdruck eine Filterstruktur wie:
{ "filter": { "operator": "and", "conditions": [ { "field": "name", "con": "=", "value": "dieter" }, { "operator": "and", "conditions": [ { "field": "age", "con": ">=", "value": 25 }, { "field": "age", "con": "<", "value": 30 } ] } ] } }
Vorteile:
- Entwicklerfreundlich: Für technisch versierte Nutzer lesbar wie eine WHERE-Klausel
- Kompakt: Komplexe Logik in einem einzigen Parameter
- Kein JSON-Encoding nötig: Weniger Anfällig für Encoding-/Parsingprobleme
- Parserfrei für Clients: Clients können die Logik als String behandeln – Parsing erfolgt im Backend
Nachteile:
- Schwer zu validieren: Eingaben müssen manuell geparst, geprüft und sicher interpretiert werden
- Sicherheitsrisiken: Riskanter bei dynamischen SQL-Backends ohne ORM oder ORM-Filter-Schutz
- Implementierung: Das Backend muss einen Lexer und Parser implementieren (z.B. über participle)
Einsatzbereiche:
- Systeme, die Kunden komplexe Abfragen erlauben. z.B. Jira.
- Hoch dynamische Systeme
- ExtJS Applikationen oder andere erweiterte Frontend Frameworks**.
Warum wir erst nach den Inkonsistenzen analysiert haben – und weshalb Kontextwissen entscheidend bleibt
Als unsere Entwickler enthusiastisch begannen, KI-Modelle einzusetzen, entstanden in kurzer Zeit mehrere Endpunkte, die alle formal funktionierten, aber jeweils ein anderes Filterschema implementierten. Das lag weniger an der KI selbst als an fehlender Abstimmung: Ohne gemeinsame Richtlinien wählten die Modelle bei jedem Prompt leicht unterschiedliche Parameternamen, Operatoren oder Transportmechanismen – genau wie Entwickler, die nicht miteinander sprechen.
Erst als diese Divergenzen bereits im Code waren, starteten wir die hier vorgestellte Analyse, um ein einheitliches, überprüfbares Schema festzulegen. Dabei zeigte sich ein generelles Risiko bei LLM-Outputs:
- Kontextblindheit – Modelle kennen weder unsere Caching-Strategie noch Datenschutzauflagen. Sie schlagen problemlos Query-Parameter für personenbezogene Felder vor und ignorieren, dass diese Werte in Logs landen können.
- Plausible Defaults statt Fachlichkeit – Operatoren wie < oder in, die im Backoffice essenziell sind, fehlen, weil das Modell sie nicht für nötig hält.
- Kein Domänenwissen – Geschäftsregeln werden nicht berücksichtigt, wenn sie nicht explizit im Prompt stehen. ( besonders bei individuellen Schemata)
Fazit: LLMs liefern schnelle Vorlagen, ersetzen aber nicht das domänenspezifische Urteilsvermögen erfahrener Entwickler und Architekten. Unsere nachträgliche Analyse war nötig, um diese blinden Flecken zu schließen, ein konsistentes Schema festzulegen und künftige KI-Nutzungen klar einzurahmen.
Natürlich sind LLMs generell eine große Hilfe. Dieser Artikel wurde auch mit LLM Unterstützung erstellt.
Fazit: Kein „One Size Fits All“ – aber klare Einheitlichkeit pro Anwendungstyp
Es gibt keine Universallösung, die für jede API und jedes Frontend gleichermaßen optimal ist: Öffentliche Suchformulare mit strengen Datenschutz-Auflagen stellen andere Anforderungen als interne Backoffice-Grids, die maximale Filtertiefe brauchen. Deshalb muss das Transport- und Filterformat immer konkret am Anwendungsfall bemessen werden. Entscheidend ist jedoch, dass gleichartige Fälle identisch gelöst werden – etwa sämtliche Data-Tables im Admin-Backend oder alle öffentlichen Customer-Endpoints. Sobald solche Kernmuster definiert sind, dürfen sie nicht pro Mikroservice oder Entwicklerteam variiert werden; Einheitlichkeit sorgt hier für wiederverwendbare Komponenten, weniger Bugs und eine deutlich einfachere Wartung.