in REST APIs sauber entwerfen
List-Endpoints sind die komplexesten Teile einer REST API: Sie müssen Pagination, Filterung und Sortierung konsistent unterstützen, ohne die API-Oberfläche aufzublasen. Der Unterschied zwischen einem gut und einem schlecht entworfenen List-Endpoint liegt in drei Entscheidungen: Cursor oder Offset, welche Filterparameter erlaubt, und wie das Meta-Objekt die Konsumenten informiert.
Inhaltsverzeichnis
- 1. Die Grundentscheidung: Cursor oder Offset?
- 2. Offset-Pagination: einfach, aber mit Grenzen
- 3. Cursor-Pagination: konsistent bei veränderlichen Daten
- 4. Das Meta-Objekt: was Konsumenten wirklich brauchen
- 5. Filter-Parameter: Syntax, Typen und Grenzen
- 6. Sortierung: einfach konsistent halten
- 7. List-Endpoints vollständig in OpenAPI dokumentieren
- 8. Performance-Überlegungen für Pagination und Filter
- 9. Pagination-Typen im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Die Grundentscheidung: Cursor oder Offset?
Die erste und wichtigste Designentscheidung für jeden List-Endpoint ist die Wahl des Pagination-Mechanismus. Offset-Pagination (?page=2&per_page=20) ist intuitiv, weit verbreitet und einfach zu implementieren. Cursor-Pagination (?cursor=eyJpZCI6MTAwfQ&limit=20) ist komplexer, aber konsistenter bei veränderlichen Daten und skalierbarer bei großen Datensätzen.
Die Entscheidung hängt vom Anwendungsfall ab: Wenn Nutzer oder Clients zu beliebigen Seiten springen müssen (z. B. Seite 47 von 200), ist Offset die einzige praktikable Option. Wenn die Daten sich häufig ändern und Nutzer sequenziell durch Ergebnisse navigieren (z. B. ein Activity-Feed oder Log-Stream), ist Cursor die zuverlässigere Wahl. Das Problem mit Offset bei veränderlichen Daten: Wenn zwischen Seite 1 und Seite 2 ein neues Element vorne eingefügt wird, verschiebt sich der gesamte Offset – Elemente werden doppelt oder gar nicht gezeigt.
Eine pragmatische Strategie: Offset für Admin-Interfaces und Reports (willkürliche Seitensprünge wichtig), Cursor für User-Feeds und Timelines (Konsistenz wichtig). Beide Modelle können in derselben API koexistieren – verschiedene Endpoints mit unterschiedlichen Anforderungen können unterschiedliche Pagination-Typen haben. Das sollte in der OpenAPI-Dokumentation klar signalisiert werden.
2. Offset-Pagination: einfach, aber mit Grenzen
Offset-Pagination ist der einfachste Einstieg und für viele Use Cases vollständig ausreichend. Die Grundform: ?page=2&per_page=20 oder alternativ ?offset=40&limit=20. Beide Varianten sind verbreitet, aber innerhalb einer API sollte nur eine konsequent verwendet werden. Die page/per_page-Variante ist nutzerfreundlicher und näher an der menschlichen Intuition ("Seite 3"), die offset/limit-Variante ist präziser und direkter auf die SQL-Schicht gemappt.
Wichtige Designentscheidungen bei Offset-Pagination: Das Maximum für per_page muss begrenzt sein (typisch: 100 oder 200), um Resource-Exhaustion-Angriffe zu verhindern. Der Default sollte sinnvoll sein (20 oder 25 ist üblich). Das Total-Count im Meta-Objekt ist bei Offset nützlich, damit Clients die Gesamtzahl der Seiten berechnen können – aber auch teuer, weil ein COUNT-Query auf großen Tabellen langsam ist. Für sehr große Datensätze kann ein approximatives Total (totalApproximate) oder gar kein Total eine bessere Option sein.
# Offset pagination — OpenAPI parameter definitions
components:
parameters:
PageParam:
name: page
in: query
description: Page number (1-indexed)
schema:
type: integer
minimum: 1
default: 1
PerPageParam:
name: per_page
in: query
description: Items per page. Maximum 100.
schema:
type: integer
minimum: 1
maximum: 100
default: 20
schemas:
OffsetPaginationMeta:
type: object
required: [total, page, perPage, totalPages]
properties:
total:
type: integer
minimum: 0
description: Total number of items matching the current filters
example: 842
page:
type: integer
minimum: 1
description: Current page number
example: 3
perPage:
type: integer
minimum: 1
maximum: 100
description: Items per page as requested
example: 20
totalPages:
type: integer
minimum: 0
description: Total number of pages (ceil(total / perPage))
example: 43
links:
type: object
description: Navigation links for common page transitions
properties:
self: { type: string, format: uri }
first: { type: string, format: uri }
prev: { type: string, format: uri, nullable: true }
next: { type: string, format: uri, nullable: true }
last: { type: string, format: uri }
3. Cursor-Pagination: konsistent bei veränderlichen Daten
Cursor-Pagination verwendet einen opaken Zeiger (Cursor) auf die Position im Datensatz. Der Cursor ist typischerweise ein Base64-kodiertes JSON-Objekt, das den Wert des Sort-Feldes des letzten zurückgegebenen Elements enthält – z. B. {"id": 100, "createdAt": "2026-05-10T12:00:00Z"} als Cursor für den Datensatz sortiert nach createdAt DESC, id DESC. Der Server kann diesen Cursor sicher dekodieren und die Abfrage mit einer WHERE (createdAt, id) < (cursorCreatedAt, cursorId)-Bedingung einschränken.
Ein wichtiges API-Design-Prinzip: Cursor müssen opak für Clients sein. Clients dürfen den Cursor nicht interpretieren oder konstruieren – er wird vom Server generiert und unverändert im nächsten Request zurückgesendet. Das erlaubt es dem Server, die Cursor-Implementierung zu ändern, ohne die API-Oberfläche zu brechen. Der Cursor sollte zeitlich begrenzt sein (z. B. 24 Stunden), um zu verhindern, dass Clients sehr alte Cursor unbegrenzt verwenden.
# Cursor pagination — OpenAPI definitions
components:
parameters:
CursorParam:
name: cursor
in: query
description: |
Opaque pagination cursor from the previous response's meta.cursors.next.
Do NOT construct or interpret this value — treat as a black box.
Cursors expire after 24 hours.
schema:
type: string
example: eyJpZCI6MTAwLCJjcmVhdGVkQXQiOiIyMDI2LTA1LTEwIn0
LimitParam:
name: limit
in: query
description: Number of items to return. Maximum 100.
schema:
type: integer
minimum: 1
maximum: 100
default: 20
schemas:
CursorPaginationMeta:
type: object
required: [limit, hasMore]
properties:
limit:
type: integer
description: Items per page as requested
example: 20
hasMore:
type: boolean
description: Whether more items exist after the current page
example: true
cursors:
type: object
properties:
next:
type: string
nullable: true
description: Cursor for the next page. Null if this is the last page.
example: eyJpZCI6MTIwfQ
prev:
type: string
nullable: true
description: Cursor for the previous page. Null if this is the first page.
example: eyJpZCI6ODl9
# Example usage in a list endpoint
paths:
/orders:
get:
summary: List orders (cursor-paginated)
parameters:
- $ref: '#/components/parameters/CursorParam'
- $ref: '#/components/parameters/LimitParam'
responses:
'200':
description: Order list
content:
application/json:
schema:
type: object
required: [data, meta]
properties:
data:
type: array
items:
$ref: '#/components/schemas/Order'
meta:
$ref: '#/components/schemas/CursorPaginationMeta'
4. Das Meta-Objekt: was Konsumenten wirklich brauchen
Das Meta-Objekt in einer List-Response ist die Schnittstelle zwischen der API und der Paginierungslogik im Client. Was Konsumenten wirklich brauchen, variiert je nach Pagination-Typ und Anwendungsfall. Für Offset-Pagination: Total-Count, aktuelle Seite, Seiten insgesamt und direkte Navigationslinks. Für Cursor-Pagination: hasMore-Flag, Cursor für die nächste und vorherige Seite.
Ein häufiger Fehler: Das Meta-Objekt wird so gestaltet, dass der Client noch rechnen muss. Wenn total und perPage zurückgegeben werden, muss der Client Math.ceil(total/perPage) rechnen, um die Seitenanzahl zu kennen. Besser: totalPages direkt mitgeben. Wenn cursor zurückgegeben wird, aber kein hasMore-Flag, muss der Client testen, ob cursor null ist. Besser: hasMore: false explizit angeben. Das Meta-Objekt sollte so gestaltet sein, dass der Client-Code minimal bleibt.
5. Filter-Parameter: Syntax, Typen und Grenzen
Filter-Parameter sind einer der häufigsten Quellen von API-Design-Inkonsistenz. Drei unterschiedliche Teams bauen drei unterschiedliche Filter-Syntaxen: ?status=active, ?filter[status]=active und ?filter=status:active. Konsumenten müssen drei verschiedene Syntaxen lernen, Dokumentation wird komplexer, und generierter Client-Code muss alle drei Varianten unterstützen.
Die Empfehlung: Einfache Gleichheits-Filter als direkte Query-Parameter (?status=active&category=electronics). Mehrfach-Werte durch kommaseparierte Listen oder mehrfachen Parameter (?status=active,pending oder ?status=active&status=pending). Range-Filter durch spezifische Parameter (?price_min=10&price_max=100 oder ?created_after=2026-01-01&created_before=2026-12-31). Für komplexe Filteranforderungen (AND/OR-Kombinationen, Negation) bietet sich ein JSON-basierter Filterparameter an – aber nur, wenn die API wirklich komplexe Queries erlauben muss.
# Filter parameters — OpenAPI definitions for list endpoint
paths:
/products:
get:
summary: List products with filtering, sorting and pagination
parameters:
# Simple equality filter
- name: status
in: query
description: Filter by product status. Multiple values allowed (comma-separated).
schema:
type: string
enum: [active, inactive, draft, archived]
example: active
- name: category_id
in: query
description: Filter by category UUID. Matches exact category, not children.
schema:
type: string
format: uuid
# Range filter
- name: price_min
in: query
description: Minimum price (inclusive), in EUR cents.
schema:
type: integer
minimum: 0
- name: price_max
in: query
description: Maximum price (inclusive), in EUR cents.
schema:
type: integer
minimum: 0
# Date range filter
- name: created_after
in: query
description: Only products created after this date (ISO 8601 date).
schema:
type: string
format: date
example: "2026-01-01"
- name: created_before
in: query
schema:
type: string
format: date
example: "2026-12-31"
# Full-text search
- name: q
in: query
description: Full-text search across name and description. Min 2 chars.
schema:
type: string
minLength: 2
maxLength: 200
# Sorting
- name: sort
in: query
description: >
Sort field and direction. Prefix with - for descending.
Supported fields: name, price, createdAt, updatedAt.
schema:
type: string
enum: [name, -name, price, -price, createdAt, -createdAt, updatedAt, -updatedAt]
default: -createdAt
# Pagination
- $ref: '#/components/parameters/PageParam'
- $ref: '#/components/parameters/PerPageParam'
6. Sortierung: einfach konsistent halten
Sortierparameter sind einfacher zu designen als Filterparameter, aber die Inkonsistenzen sind trotzdem häufig. Das verbreitetste Muster: ?sort=field für aufsteigend, ?sort=-field für absteigend (das Minuszeichen-Prefix ist von vielen JSON:API-Implementierungen inspiriert). Alternativ: ?sort_by=name&sort_order=asc als separate Parameter.
Wichtige Designentscheidungen: Welche Felder sind sortierbar? Nicht alle Felder, die zurückgegeben werden, sind sinnvoll oder effizient sortierbar. Die sortierbaren Felder in der OpenAPI-Dokumentation als Enum zu definieren ist besser als einen freien String zuzulassen – es verhindert fehlerhafte Sortierparameter, die zu Server-Fehlern oder unexpected Behavior führen. Multi-Field-Sortierung (z. B. ?sort=-createdAt,name) ist optional und sollte nur hinzugefügt werden, wenn Konsumenten sie wirklich brauchen.
7. List-Endpoints vollständig in OpenAPI dokumentieren
Die vollständige OpenAPI-Dokumentation eines List-Endpoints ist aufwendiger als die eines einzelnen Resource-Endpoints, weil alle Parameter-Kombinationen und ihre Auswirkungen auf die Response dokumentiert werden müssen. Wichtig: Parameterkombinationen, die nicht unterstützt werden (z. B. Cursor-Pagination zusammen mit einem Page-Parameter), sollten explizit dokumentiert werden – entweder im description-Feld oder durch einen 400-Response mit Beschreibung.
Reusable Components reduzieren die Wiederholung erheblich: Pagination-Parameter als $ref-Referenzen, Meta-Schemas als wiederverwendbare Komponenten, gemeinsame Filter-Parameter (Datums-Range, Suche) als Parameter-Komponenten. Das macht OpenAPI-Dokumente wartbarer und reduziert die Chance, dass verschiedene List-Endpoints unterschiedliche Konventionen für dasselbe Konzept verwenden.
| Kriterium | Offset-Pagination | Cursor-Pagination | Empfehlung |
|---|---|---|---|
| Implementierungsaufwand | Niedrig | Mittel–Hoch | Offset für einfache Cases |
| Konsistenz bei Datenänderungen | Niedrig (Duplikate/Lücken) | Hoch | Cursor für veränderliche Daten |
| Willkürliche Seitensprünge | Ja (Seite 47 direkt) | Nein | Offset für Admin-UIs |
| Performance bei großen Datensätzen | Niedrig (OFFSET teuer) | Hoch (Index-Scan) | Cursor ab ~100k Datensätzen |
| Total Count verfügbar | Ja (COUNT-Query) | Nein (by design) | Offset wenn Total benötigt |
8. Performance-Überlegungen für Pagination und Filter
Pagination und Filter haben direkte Auswirkungen auf Datenbankperformance, die beim API-Design berücksichtigt werden müssen. Offset-Pagination mit großen Offsets (LIMIT 20 OFFSET 10000) ist teuer: Die Datenbank muss 10.000 Zeilen lesen und verwerfen. Cursor-Pagination vermeidet das durch eine WHERE-Bedingung auf indizierten Spalten. Bei großen Datensätzen (ab ca. 100.000 Einträgen) ist der Performance-Unterschied messbar.
Filter-Parameter, die auf nicht indizierten Feldern operieren, können Full-Table-Scans auslösen. Das API-Design sollte sich der Datenbank-Realität bewusst sein: Nur Felder, die indexiert sind oder effizient abgefragt werden können, sollten als Filter-Parameter exponiert werden. Das bedeutet nicht, dass die API alle Datenbankeinschränkungen nach außen trägt – aber es bedeutet, dass der API-Designer mit dem Datenbankteam abstimmt, welche Filter-Kombinationen unterstützt werden sollen.
10. Zusammenfassung
Pagination, Filter und Sortierung für REST APIs sauber zu entwerfen, erfordert drei klare Entscheidungen: Cursor oder Offset (je nach Konsistenz-Anforderung und Datensatzgröße), welche Filter-Parameter und -Syntaxen konsistent über alle List-Endpoints verwendet werden, und wie das Meta-Objekt Konsumenten mit allen nötigen Informationen versorgt, ohne dass sie rechnen müssen. Diese Entscheidungen früh zu treffen und konsequent durchzuhalten ist wichtiger als die konkrete Syntax-Wahl.
OpenAPI-Dokumentation für List-Endpoints zahlt sich besonders aus: Reusable Parameters und Schemas für Pagination und Filter reduzieren Wiederholung und erzwingen Konsistenz. Clients, die aus OpenAPI generiert werden, erhalten die Pagination-Logik kostenlos. Contract Tests mit Postman/Newman verifizieren, dass das Meta-Objekt korrekt befüllt ist und Cursor funktionieren. Der Aufwand für sorgfältiges List-Endpoint-Design ist einmalig – schlechtes Design verfolgt eine API ihr ganzes Leben lang.
Pagination, Filter und Sortierung — Das Wichtigste auf einen Blick
Cursor vs. Offset
Cursor für veränderliche Daten und große Datensätze. Offset wenn Seitensprünge oder Total Count benötigt werden. Beide können in derselben API koexistieren.
Meta-Objekt
So gestalten, dass der Client nicht rechnen muss: totalPages statt total+perPage, hasMore statt cursor-null-check, Navigationslinks direkt im Meta.
Filter-Konsistenz
Einfache Gleichheitsfilter als direkte Query-Parameter. Range-Filter als _min/_max-Pairs. Nur indexierte Felder als Filter exponieren.
OpenAPI-Reuse
Pagination-Parameter und Meta-Schemas als reusable components/$ref. Sortierbares Felder als Enum definieren — verhindert fehlerhafte Sortierparameter.