1. Warum man den JSON-Vertrag fixieren sollte
Wenn Menschen „JSON-API“ hören, denken sie oft: „Na ja, das ist doch einfach: JSON gesendet, JSON empfangen.“ Aber „einfach JSON“ ist wie „einfach einen Treffpunkt ausmachen“: ohne Zeit, Adresse und die Formulierung „in welchem Format Sie mich erwarten“ wird aus dem Treffen eine Serie. Der JSON-API-Vertrag ist nicht nur „welche Felder“, sondern auch „welche Header“, „was bei einem Fehler zu tun ist“ und „wie der Client das Problem erkennt, ohne den Text Wort für Wort zu parsen“.
Wenn Sie den Vertrag nicht festlegen, bekommen Sie den klassischen Bug: Das Frontend erwartet das Feld done, das Backend hat es in isDone umbenannt, es gibt keine Tests, und dem Benutzer wird „warum auch immer“ immer „nicht erledigt“ angezeigt. Und das ist noch das bessere Szenario.
Damit der Vertrag lesbar und stabil ist, legen wir heute drei Dinge fest:
- wie und wann Content-Type und Accept verwendet werden,
- wie man JSON-Body-Formate für Requests und Responses entwirft (über DTOs),
- wie man Fehler einheitlich über ein Error Envelope zurückgibt: { "error": { "code": "...", "message": "...", "fields": { ... } } }.
2. JSON-API-Header: Content-Type und Accept
HTTP-Header wirken auf viele wie etwas „Zweitrangiges“, weil „das Wichtige doch der Body ist“. In Wirklichkeit sind Header wie das Etikett auf einem Paket bei der Lieferung. Man kann natürlich ein Paket ohne Beschriftung bringen, aber dann wird daran geschüttelt, geschnuppert und geraten: „Ist das wirklich eine Tasse und nicht ein Ziegelstein?“ Header helfen dabei, nicht zu raten.
Content-Type: was das ist und warum man es braucht
Content-Type ist der Header darüber, was Sie im Body senden. Wenn Sie JSON in einem Request senden (zum Beispiel beim Erstellen einer Aufgabe), muss Ihr Request ehrlich sagen: „Innen ist JSON.“
Typisches Beispiel:
POST /api/v1/tasks HTTP/1.1
Content-Type: application/json
{"title":"buy milk"}
Wenn Content-Type fehlt oder etwas anderes dort steht, hat der Server jedes moralische Recht zu sagen: „Ich weiß nicht, wie ich das lesen soll“ — und einen Fehler zurückzugeben. In HTTP nutzt man dafür meist den Status 415 Unsupported Media Type, aber im Moment ist nicht die Numerologie wichtig, sondern die Idee des Vertrags selbst.
Wichtiger Hinweis: Wenn ein Request keinen Body hat (zum Beispiel viele GETs), dann braucht man normalerweise auch kein Content-Type. Senden Sie nicht einfach „vorsichtshalber“ Content-Type: application/json: Das ist, als würde man einen leeren Umschlag mit „da ist ein Kuchen drin“ beschriften.
Accept: was das ist und warum man es braucht
Accept ist der Header darüber, was der Client erhalten möchte. In einer einfachen JSON-API sagt der Client meist: „Ich erwarte application/json.“
Beispiel:
GET /api/v1/tasks HTTP/1.1
Accept: application/json
Wenn der Client Accept nicht angibt, kann der Server trotzdem JSON zurückgeben (vor allem, wenn die API strikt JSON-basiert ist), aber ein sauberer Vertrag liebt Eindeutigkeit.
Wichtiger psychologischer Punkt: Content-Type ist „was ich Ihnen gebracht habe“, Accept ist „was ich von Ihnen möchte“. Das sind zwei verschiedene Rollen, wie „Dokumente übergeben“ und „eine Quittung erhalten“.
Mini-Tabelle fürs Gedächtnis
Damit das Gehirn nicht versucht, das als „Header-Magie“ zu speichern, hilft eine kurze Tabelle:
| Header | Wer setzt ihn | Worum geht es | Wann gebraucht |
|---|---|---|---|
|
Absender des Bodys | Body-Format (zum Beispiel JSON) | wenn ein Body vorhanden ist |
|
Client | Format der erwarteten Antwort | fast immer, wenn dem Client das Format wichtig ist |
3. Request-/Response-Formate: warum DTOs
Wenn Sie eine API entwerfen, ist die Versuchung groß, einfach Ihre Domain-Struktur Task zu nehmen und ihr Tags json:"..." hinzuzufügen, damit „alles von selbst funktioniert“. Das wirkt wie eine Zeitersparnis, ist aber meist ein Kredit zu 300% Jahreszins: kleine Freude jetzt, große Reparatur später.
Der Grund ist einfach: Das Domänenmodell ist für die Bedeutung und die Regeln der Fachdomäne zuständig, während der JSON-Vertrag für das externe Austauschformat mit dem Client steht. Das sind verschiedene Verantwortlichkeiten. Wenn man sie zusammenklebt, beginnt man, sich vor Änderungen an der Domäne zu fürchten, weil man „die API zerstöre“, und vor Änderungen an der API, weil man „die Domäne zerstöre“. Heraus kommt ein architektonisches Sandwich aus Angst.
Deshalb führen wir DTOs (Data Transfer Object) ein: Strukturen für JSON „außen“. Und die Domain-Typen leben „innen“ und müssen nicht wissen, was JSON ist.
DTOs für Aufgaben
Stellen wir uns unsere Lernanwendung „Task-Tracker“ vor (Aufgabenliste). Auf API-Ebene möchten wir:
- eine Aufgabe über einen Titel erstellen,
- eine Aufgabe abrufen,
- eine Liste von Aufgaben abrufen.
Minimal sinnvolle Felder für JSON:
- id — Zahl,
- title — String,
- done — boolesch.
Erstellen wir ein DTO:
package main
type TaskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
Und für den Request zum Erstellen einer Aufgabe (normalerweise überlässt der Client id nicht — sie wird vom Server vergeben):
package main
type CreateTaskRequest struct {
Title string `json:"title"`
}
Beachten Sie: Wir setzen absichtlich nicht title,omitempty — denn ein „leerer title“ ist bei einer Aufgabe meist ein Fehler, und wir wollen, dass Abwesenheit/Leere in der Validierung klar sichtbar werden und nicht einfach „verschwinden“.
Wie JSON ohne Server aussieht
Wir starten den HTTP-Server noch nicht (das wird eine eigene Geschichte), aber das JSON-Format können wir schon jetzt „im Vakuum“ debuggen.
package main
import (
"encoding/json"
"fmt"
)
func main() {
req := CreateTaskRequest{Title: "buy milk"}
b, _ := json.MarshalIndent(req, "", " ")
fmt.Println(string(b))
// {
// "title": "buy milk"
// }
}
Das ist ein einfacher, aber sehr nützlicher Ansatz: Sie sehen buchstäblich, was Sie dem Client versprechen.
4. Ein einheitliches Error Envelope: der Fehlervertrag
Fehler sind der Ort, an dem eine API am häufigsten zu einem „Zoo“ wird. Ein Endpoint liefert {"error":"bad"}, der nächste — {"message":"oops"}, der dritte gibt sogar nur Text wie „panic: runtime error“ zurück. Die Client-Seite beginnt dann zu schreiben: „Wenn es das Feld error gibt, ist es ein Fehler, wenn es das Feld message gibt, auch ein Fehler, wenn HTML zurückkommt — na ja ... wohl auch ein Fehler.“
Damit das nicht passiert, legen wir ein einheitliches Fehlerformat fest, das für die gesamte API gilt. Das ist das Error Envelope.
Die Idee passt gut zum Go-Ansatz „Fehler sind Werte“: Fehler kann man konstruieren, einpacken, klassifizieren und stabil darstellen.
Kanonisches Format
Der Vertrag unseres Moduls:
{
"error": {
"code": "...",
"message": "...",
"fields": { "...": "..." }
}
}
Regeln:
- error.code — maschineller Code (für die Client-Logik),
- error.message — kurzer sicherer Text (für Menschen),
- error.fields — erscheint nur bei Validierungsfehlern, als „Feld → Problem“.
Das ist wichtig: fields darf nicht zum Muelleimer für „alles mögliche“ werden. Der Client erwartet, dass fields „Fehler zu konkreten Eingabefeldern“ bedeutet.
Strukturen für das Envelope in Go
Wir beschreiben das Envelope so, dass das JSON stabil dem Vertrag entspricht.
package main
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
}
type ErrorEnvelope struct {
Error APIError `json:"error"`
}
Warum Fields mit omitempty? Weil fields in den meisten Fehlern nicht gebraucht wird, und wir nicht immer "fields": {} senden wollen. Leere soll leer bleiben.
Wozu code und message gebraucht werden
Manchmal möchte man nur message behalten, weil „ein Mensch liest das ja“. Aber auch der Computer liest mit (Client-Anwendung, Skripte, Integrationen). Wenn der Client not_found von validation unterscheiden will, soll er nicht den String parsen und dort nach „not found“ suchen wie in einer Quest.
Und manchmal möchte man nur code behalten und message entfernen, weil „das Frontend kann die Texte ja selbst schreiben“. Auch das ist schlecht: Sie verlieren ein einheitliches UX, und verschiedene Clients beginnen, unterschiedliche Meldungen anzuzeigen. Für eine kleine Lern-API ist es nützlicher, wenn der Server sowohl einen maschinellen Code als auch eine kurze Nachricht zurückgibt.
Hier lohnt es sich, an eine Go-Praxis zu erinnern: Sobald Sie etwas erkennbar und stabil machen, wird es Teil des API-Vertrags, und spätere Änderungen tun weh. Deshalb fixieren wir das Error Envelope als „Vertrag“.
5. Fehlerbeispiele: validation, not_found, internal
Fehler sind nützlich, wenn man sie „mit den Augen sieht“ und nicht nur in Worten beschrieben bekommt. Wir machen das wieder über json.MarshalIndent, damit sich die JSON-Form ohne Serverstart prüfen lässt.
Validierungsfehler mit fields
Typisches Szenario: Der Client hat einen leeren title gesendet.
package main
import (
"encoding/json"
"fmt"
)
func main() {
env := ErrorEnvelope{
Error: APIError{
Code: "validation",
Message: "invalid request",
Fields: map[string]string{"title": "must not be empty"},
},
}
b, _ := json.MarshalIndent(env, "", " ")
fmt.Println(string(b))
// {
// "error": {
// "code": "validation",
// "message": "invalid request",
// "fields": {
// "title": "must not be empty"
// }
// }
// }
}
Beachten Sie zwei Dinge.
Erstens ist fields ein Dictionary und kein String-Array. Das ist für den Client praktisch: Man kann das konkrete Formularfeld hervorheben.
Zweitens ist message hier allgemein („invalid request“), und die Details stehen in fields. Das hilft dabei, message nicht in einen Roman mit 12 Bänden zu verwandeln.
Not found: ohne fields
Wenn eine Ressource nicht gefunden wird (zum Beispiel gibt es keine Aufgabe mit dieser id), braucht man fields normalerweise nicht: Das ist kein „Fehler eines konkreten Eingabefelds“, sondern eine nicht vorhandene Entität.
package main
import (
"encoding/json"
"fmt"
)
func main() {
env := ErrorEnvelope{
Error: APIError{
Code: "not_found",
Message: "resource not found",
},
}
b, _ := json.MarshalIndent(env, "", " ")
fmt.Println(string(b))
// {
// "error": {
// "code": "not_found",
// "message": "resource not found"
// }
// }
}
Hier zeigt sich omitempty besonders deutlich: fields landet nicht im JSON, und der Client denkt nicht: „Oh, fields ist da, also ist das validation.“
Internal: sichere Nachricht
Der häufigste Fehler von API-Einsteigern ist, „einfach err.Error() nach außen zu geben“. Das wirkt manchmal sogar bequem („da steht ja wirklich, was passiert ist“), aber in der realen Welt bedeutet das oft:
- interne Details werden offengelegt (Dateipfade, SQL, Konfigurationen),
- der Vertrag wird zufällig vom Fehlertext abhängig gemacht,
- und manchmal werden sogar Geheimnisse sichtbar.
Wir verwenden eine stabile Nachricht, zum Beispiel "internal error". Details gehören in die Logs, nicht in den Vertrag. Genau das ist die Grenze: Was ist Teil der öffentlichen API und was ist interne Implementierung.
6. Wie man Error Envelope und Go-Fehler verbindet
Jetzt ein wichtiger Punkt: Das Error Envelope ist die JSON-Form an der Systemgrenze, aber innerhalb haben Sie weiterhin normale Go-Fehler (error), die Sie aus Funktionen zurückgeben, einpacken und über errors.Is/errors.As prüfen (das kennen Sie schon aus den vorigen Tagen).
Die Idee ist einfach: Innerhalb der Anwendung halten Sie einen reichen Fehler mit Kontext, und nach außen (im HTTP) wandeln Sie ihn in eine der öffentlichen Klassen um:
- validation → 400 + Envelope mit fields,
- not_found → 404 + Envelope ohne fields,
- internal → 500 + Envelope ohne interne Details.
Warum so? Weil „Fehler als Wert“ es ermöglicht, reichen Kontext für das Programm zu speichern, während der öffentliche Vertrag stabil und sicher bleiben muss.
Mini-Wörterbuch der Meldungen nach Code
Damit man Texte nicht an verschiedenen Stellen „nach Gefühl“ zusammenklebt, ist es praktisch, ein kleines Wörterbuch zu pflegen. Das ist keine „ideale Architektur“, sondern einfach Disziplin.
package main
func messageFor(code string) string {
switch code {
case "validation":
return "invalid request"
case "not_found":
return "resource not found"
default:
return "internal error"
}
}
Ja, das sieht ein bisschen wie „Mini-i18n“ aus, nur ohne Lokalisierungen. Das Wichtigste ist Stabilität.
Envelope-Konstruktor
Damit man nicht jedes Mal ErrorEnvelope{Error: APIError{...}} schreiben muss, machen wir eine kleine Funktion:
package main
func newErrorEnvelope(code string, fields map[string]string) ErrorEnvelope {
return ErrorEnvelope{
Error: APIError{
Code: code,
Message: messageFor(code),
Fields: fields,
},
}
}
Hier geht es nicht um „Schönheit“, sondern darum, dass der Fehlervertrag zentralisiert wird. Wenn Fehler an einer Stelle erzeugt werden, ist die Wahrscheinlichkeit deutlich geringer, „versehentlich das Format zu ändern“.
7. Erfolgsantwort und Fehler im gleichen Stil
Auch ohne Server kann man zeigen, wie eine typische erfolgreiche Antwort und ein typischer Fehler aussehen werden. Das hilft, sich zu merken, dass eine API zwei gleichwertige Zweige hat: Erfolg und Fehler. Es gibt keine API „ohne Fehler“, es gibt API „mit unerwarteten Fehlern“.
Erfolg: eine Aufgabe
package main
import (
"encoding/json"
"fmt"
)
func main() {
task := TaskDTO{ID: 1, Title: "buy milk", Done: false}
b, _ := json.MarshalIndent(task, "", " ")
fmt.Println(string(b))
// {
// "id": 1,
// "title": "buy milk",
// "done": false
// }
}
Fehler: ungültiger Request
package main
import (
"encoding/json"
"fmt"
)
func main() {
env := newErrorEnvelope("validation", map[string]string{
"title": "must not be empty",
})
b, _ := json.MarshalIndent(env, "", " ")
fmt.Println(string(b))
// {
// "error": {
// "code": "validation",
// "message": "invalid request",
// "fields": {
// "title": "must not be empty"
// }
// }
// }
}
Die Form der Fehler hängt nicht vom Endpoint ab. Genau das ist der Hauptwert: Der Client kann einen einzigen Fehler-Handler schreiben und ihn überall wiederverwenden.
Wo der JSON-API-Vertrag lebt: Schema
Manchmal ist es nützlich, ein Bild zu sehen, damit das Gehirn aufhört, das alles in Abstraktionen zu zermahlen.
flowchart LR
C[Client] -->|HTTP request + Accept + JSON body| S[Server]
S -->|2xx + JSON DTO| C
S -->|4xx/5xx + JSON ErrorEnvelope| C
Die Essenz des Schemas: Der Client erwartet immer JSON (über Accept), der Server antwortet immer entweder mit DTO-Daten oder mit einem Error Envelope. „Immer die gleiche Fehlerform“ ist keine Pedanterie, sondern etwas, das bei Integrationen Wochen Lebenszeit spart.
8. Typische Fehler
Fehler Nr. 1: Content-Type und Accept verwechseln (oder beide „vorsichtshalber“ setzen).
Wenn im Request ein JSON-Body vorhanden ist, ist Content-Type: application/json obligatorisch, sonst muss der Server das Format raten. Accept ist die Bitte des Clients um das Antwortformat. Wenn man die Header mechanisch setzt, bekommt man leicht eine Situation, in der der Client JSON „anfordert“, aber den Body als „irgendwas Unbekanntes“ sendet und sich dann wundert, warum der Server die Daten nicht liest.
Fehler Nr. 2: unterschiedliche Fehlerformate auf verschiedenen Endpoints zurückgeben.
Oft beginnt das harmlos: „Hier gebe ich einen String zurück, dort ein Objekt.“ Nach einem Monat hat der Client schon drei Fehler-Handler und einen nervösen Tic. Ein einheitliches Error Envelope ist Disziplin, die die API vorhersagbar macht. Sobald Sie es festgelegt haben, behandeln Sie es wie einen öffentlichen Vertrag und nicht wie einen „Entwurf, den man verbessern kann“.
Fehler Nr. 3: message in eine Müllhalde für Details verwandeln (oder umgekehrt nur code verwenden).
Wenn message interne Details enthält ("sql: no rows", Dateipfade, Ausschnitte aus dem Stack Trace), geben Sie entweder zu viel preis oder machen den Fehlertext zufällig zum Teil des Vertrags. Wenn Sie nur code lassen, bekommen Sie uneinheitliche Client-Meldungen und verlieren ein einheitliches UX. Das normale Paar ist: code für die Logik und message für den Menschen.
Fehler Nr. 4: fields zweckentfremden.
fields soll „Validierungsfehler zu konkreten Eingabefeldern“ bedeuten. Wenn Sie dort anfangen, alles Mögliche hineinzustopfen (zum Beispiel "storage": "down"), vertraut der Client dem Feld nicht mehr und muss Workarounds schreiben. Am Ende verlieren Sie den größten Vorteil: die Möglichkeit, dem Benutzer konkrete Eingabeprobleme hervorzuheben.
Fehler Nr. 5: interne Fehler nach außen leaken über err.Error().
Interne Fehler sollte man innerhalb des Servers gut einpacken, loggen und analysieren — in Go ist das natürlich, weil Fehler Werte sind und strukturiert werden können. Nach außen, in den HTTP-Vertrag, muss man aber eine stabile und sichere Nachricht liefern. Sonst „backen“ Sie die Implementierung in die öffentliche API ein und schaffen Verpflichtungen, die Sie nie geplant hatten.
GO TO FULL VERSION