REST-APIs sind schon seit mehr als einem Jahrzehnt allgegenwärtig. Doch nicht jeder weiß, dass das, was heute meist als „REST“ bezeichnet wird, gar nicht die ursprüngliche Idee war. REST wurde von Roy Fielding in seiner Dissertation als „Representational State Transfer“ entwickelt und eine wichtige Idee darin war HATEOAS (Hypertext as the Engine of Application State), was im Grunde bedeutet, dass jede API-Antwort Navigationslinks zu anderen parametrisierten REST-Endpunkten enthält, zum Beispiel Detail-Links bei einem Listen-Endpunkt. Man kann in der API also ähnlich navigieren, wie das auf einer HTML-Seite möglich wäre.
Das hat sich aber in der Praxis kaum durchgesetzt. Wenn wir im Folgenden von „REST in der Praxis“ sprechen, meinen wir daher etwas anderes, nämlich REST im Maturity Level 2 mit JSON über HTTP, ohne HATEOAS, dafür mit OpenAPI oder einer ähnlichen Dokumentation. Das ist zwar eine sehr enge Definition, die wenig mit dem Original zu tun hat, spiegelt jedoch den Industrie-Standard sehr gut wieder, wie wir finden. Und da Software in erster Linie dazu da ist, um benutzt zu werden, lohnt es sich in der Regel, Industrie-Standards auch in Erwägung zu ziehen.

Diese Industrie-Standard-Form von REST wird aber immer wieder kritisiert. Dabei handle es sich lediglich um eine Form von CRUD, was ja sowieso ein Anti-Pattern sei. Diese Aussage meint aber nicht, wie man vielleicht denken könnte, dass CRUD-Datenbanken problematisch sind. Im verlinkten Video wird lediglich problematisiert, dass die CRUD-Datenbanklogik auf die Backend-Ebene gehoben wird. Und, das ist für einige vielleicht überraschend, finden wir in der Regel auch nicht gut!
Wie hängt REST mit CRUD zusammen?
Die HTTP-Methoden GET
, POST
, PUT
, PATCH
und DELETE
, die bei allen Formen von REST zum Einsatz kommen, sind tatsächlich sehr CRUD-nah. Aber: Niemand sagt, dass dieses CRUD auf derselben Abstraktionsebene wie die Datenbank sein muss. Ein POST-Endpoint, der einen Kunden erfasst, könnte im Hintergrund zehn verschiedene Datenbanktabellen befüllen und einen Domain-Event auslösen und das alles sauber abstrahieren.
Wer REST nur als CRUD-Interface in die Datenbank sieht, der lässt REST potenziell hinter seinen Möglichkeiten zurück und kreiert darüber hinaus eine API, wo Datenfilterung und -verbindung, eigentlich eine Stärke von Datenbanken und Backends, im Frontend erledigt werden muss. Das ist übrigens keine Wertung; genauso haben wir es in einem Projekt mit umfangreichen Offline-Fähigkeiten schon gehandhabt. Der Regelfall ist es aber nicht.
Die Tendenz, einer Datenbank einfach eine API überzustülpen, gibt es tatsächlich, und diese ist nur in einem Fall eine gute Idee: Die Datenbank soll als eine Art DBaaS (Database as a Service) dienen. So war es auch in unserem Projekt. In allen anderen Fällen ist das einfach nur eine Fehlentscheidung, die man zwar kompensieren kann, aber in der Regel nur auf Kosten von hoher Komplexität und schlechter Performance.
Fachliches REST
Wir gehen daher mit Herrn Roden aus dem Video insofern überein, dass eine API in der Regel fachliche Operationen anbieten sollte. Auch die Vorbereitung der Operationen auf CQRS, also eine Trennung von Schreib- und Leseoperationen, sehen wir als sinnvoll an. So muss man bei einer Skalierung, die eines Tages notwendig werden könnte, die externe API nicht umstellen.
Nun gibt es aber eine wichtige Nuance, die oft zu kurz kommt: Die meisten fachlichen Operationen einer API lassen sich sehr gut ins CRUD-Schema von GET, POST, PUT, PATCH und DELETE einreihen. Einige jedoch überhaupt nicht. Was ist die pragmatische Lösung? Wir finden, alles, was ins Schema passt, sollte dort eingereiht werden, was nicht passt, sollte zu einem POST werden. Der Name der Operation wandert in die OpenAPI-Operation-ID. Dadurch ergeben sich etwa folgende Endpunkte:
POST /api/user
, Operation-ID:registerUser
PATCH /api/user/1
(oderPATCH /api/user/1/profile
), Operation-ID:editProfile
DELETE /api/user/1
, Operation-ID:deleteAccount
(hier ist sogar eine CRUD-Style-Operation-ID die passendste, denn der Endbenutzer möchte sein Benutzerkonto löschen)POST /api/user/1/ban
, Operation-ID:banUser
(ein fachlicher Vorgang, der nicht ins CRUD-Schema passt)GET /api/user/1/ban
, Operation-ID:getUserBanStatus
GET /api/user/1/profile
, Operation-ID:getProfile
So lassen sich auch State-Machine-Logiken und ähnliches recht gut ausdrücken. Die API ist fachlich sprechend und dennoch REST-üblich strukturiert. Dadurch haben die Endpunkt-URLs und Methoden ebenso eine hohe Aussagekraft. Bei dieser Lösung sehen wir übrigens auch fachlich orientierte Statuscodes als gerechtfertigt. Wird zum Beispiel ein gebannter User nochmals gebannt, gehört für uns ein 422 Unprocessable Content
gesendet, bei Zugriff auf einen gelöschten Account ein 410 Gone
, und so weiter. Eine Trennung zwischen fachlicher und technischer Ebene sehen wir hier als nahezu unmöglich.
Wo bleiben komplexe Rückmeldungen und CQRS?
Lediglich beim Rückmelden von teilweise geglückten oder fehlgeschlagenen Operationen werden die Einschränkungen der Status-Codes doch deutlich. Hier kommt eine Technik zum Einsatz, auf der auch eine HATEOAS-Erweiterung aufbauen könnte: Die modifizierten Objekte werden direkt vom Endpunkt zurückgegeben. Alles, was erfolgreich durchgelaufen ist, hat einen Eintrag (oder HATEOAS-Link) im Response-Content. Alles andere nicht. Beim Statuscode plädieren wir bei teilweise fehlgeschlagenen Operationen auf einen Fehler 500. Zumindest, wenn sich die fehlschlagende Komponente im oder hinter dem Backend befindet, um die korrekte HTTP-Semantik zu wahren.
CQRS wird bei diesem System durch zwei Maßnahmen unterstützt:
- Alle nicht-GET-Operationen geben einen Response-Body mit dem geänderten Objekt oder den geänderten Objekten zurück (auch ein Grundstein für HATEOAS)
- Alle GET-Operationen sind unabhängig von den nicht-GET-Operationen implementiert.
Diese pragmatische und für viele Entwickler vertraute Option hat sich für uns bewährt und eignet sich sowohl für interne Anwendungen als auch für öffentliche APIs.
Praxis-REST als Kompatibilitäts-Garant
REST im Maturity Level 2 reiht sich in die Reihen von ungemein praktischen, aber nicht immer glamourösen Standards ein, derer da unter anderem wären, die C-Linkage, das CSV-Datenformat oder auch neuere Entwicklungen wie Web-Components.
Natürlich gibt es auch die Option, eine Client-Bibliothek aus der OpenAPI-Definition generieren zu lassen. Das ist aber im Gegensatz zu GraphQL oder gRPC optional. Es ist zwar etwas mühsamer, ohne Client-Bibliothek zu arbeiten, ans Ziel kommt man aber allemal. Da homoikonische Sprachen, wo man zur Laufzeit YAML in Code transformieren kann, leider Exoten sind, muss man sich hier in der Praxis für ein Übel entscheiden: Codegenerierung mit vorprogrammierten Konflikten und Versionsproblemen, oder repetitiven Code selbst schreiben.
Wer Microservices, Event-Busse, CQRS und so weiter nutzt, muss in der Regel trotzdem früher oder später eine REST-API implementieren, denn der Rest der Welt spricht REST (schlechtes Wortspiel, ich weiß) und möchte JSON-Endpunkte, API-Keys und/oder OAuth.
Eine klare Absage an den Purismus
Der vorgestellte API-Design-Ansatz zeigt, dass pragmatische Lösungen oft besser sind als dogmatische Reinheit. Die Kombination von CRUD-Konventionen für Standardfälle mit explizit fachlichen Operationen dort, wo sie wirklich notwendig sind, schafft APIs, die sowohl verständlich als auch domänengerecht sind.
Die Alternative – das strikte Vermeiden von CRUD-Patterns um jeden Preis – würde zu unnötiger Komplexität führen, ohne echten fachlichen Mehrwert zu bieten. Stattdessen nutzen wir die Stärken etablierter REST-Konventionen und ergänzen sie gezielt um fachliche Semantik durch Operation-IDs und sprechende Ressourcen-URLs.
Diese Lösung ist gut, aber nicht ideal – unsere Branche ist auch noch jung, und wir glauben, dass das Ende der Fahnenstange noch nicht erreicht ist – weder im Bereich Eleganz noch im Bereich Einfachheit. Einfachheit für Eleganz zu opfern halten wir aber nie für eine gute Idee.