1. Externer Vertrag: nicht ins Projekt ziehen
Wenn Sie nur eine Abfrage und eine Datei haben, scheint es, dass man „schnell“ JSON direkt in main() einlesen und sofort Felder ausgeben kann. Aber das externe API hat seinen eigenen Charakter: heute heißt das Feld so, morgen anders; heute kommen die Autoren als Array, morgen als Objekt; heute null, morgen fehlt das Feld ganz. Wenn diese Details über das Projekt verteilt sind, erhalten Sie den Effekt „Riss in einer Wand – und Risse ziehen sich durch das ganze Haus“.
Der unangenehmste Teil dabei ist nicht, dass etwas kaputt geht (es wird definitiv kaputt gehen), sondern wo genau es kaputt geht. Wenn der externe Vertrag in verschiedene Klassen „durchgesickert“ ist, werden Sie bei einer Änderung des Anbieters sowohl die Ausgabe als auch die Geschäftslogik, die Analyse der Startargumente und noch ein paar Stellen reparieren müssen, an deren Existenz Sie bereits vergessen haben. In diesem Moment beginnen Sie zu ahnen, dass der Code ein Eigenleben führt und sich von Ihrer Energie ernährt.
„Durchgesickerter Vertrag“ in der Praxis
Normalerweise sieht das harmlos aus: einmal haben Sie das provider DTO direkt am Einstiegspunkt geholt, dann ein zweites Mal bei der Ausgabe der Ergebnisse, dann ein drittes Mal – „naja, hier musste es auch sein“. Und plötzlich gibt es in Ihrem Projekt viele Stellen, die alle wissen, dass der Anbieter ein Feld author_name hat, und alle entscheiden auf ihre Weise, was zu tun ist, wenn es null ist.
import java.util.List;
public class BadIdeaExample {
// Provider DTO (externe Modell) landete innerhalb des „normalen“ Codes – das ist ein Vertragsleck
record ProviderBookDoc(String key, String title, List<String> authorNames) {}
void print(ProviderBookDoc doc) {
// UI/Ausgabe hängt jetzt direkt von einer fremden Datenstruktur ab
System.out.println(doc.key() + " " + doc.authorNames()); // [Robert C. Martin]
// Problem: Wenn der Anbieter das Format von authorNames ändert, wird nicht der Client kaputtgehen, sondern die Ausgabe
}
}
Es funktioniert scheinbar. Aber Sie haben soeben die Ausgabe (und damit den Benutzer) von einer fremden Struktur abhängig gemacht. Das ist, als würden Sie in Ihrer Wohnung nicht nur Wasser nutzen, sondern auch ein Stück der städtischen Wasserleitung im Wohnzimmer aufbewahren, „damit es näher ist“.
Behandlungsplan im ReadLater Starter
Die Idee ist einfach: Die Außenwelt soll außen bleiben. Wir erstellen eine interne API des Katalog-Clients – ein paar Methoden wie search(query) und details(externalId), die normalized DTOs zurückgeben, die genau für unsere Anwendung verständlich sind. Alles, was mit HTTP, provider JSON und Mapping zusammenhängt, wird innerhalb der Client-Schicht versteckt.
Ein kleines Diagramm (ohne architektonische Philosophie, nur damit das Gehirn die Grenze sieht):
flowchart TD
A[ReadLaterApplication] --> B["CatalogClient
interne API"]
B --> C[RealCatalogClient]
B --> D[MockCatalogClient]
C --> E[HTTP-Aufruf + provider DTO + mapping]
D --> F[sample JSON + provider DTO + mapping]
E --> G["(externe API des Anbieters)"]
Der Hauptgedanke: Nach außen aus dem CatalogClient kommt nur das normalisierte Modell. Der Rest ist „interne Küche“. Der Benutzer bekommt das Gericht, nicht das Rezept, die Liste der Töpfe und die Herdtemperatur.
Sobald der Hauptweg gewählt ist, geht es nicht mehr um die Jackson-Syntax. Es muss eine Grenze gezogen werden: wo endet das fremde JSON und wo beginnt die Sprache unserer Anwendung. Daraus entstehen dann normalized DTOs, CatalogClient und ein separater Mapper.
2. Normalisierte DTOs: die „Sprache“ unserer Anwendung
Normalisierte DTOs sind nicht „eine weitere Kopie der Daten“, sondern eine Möglichkeit, das Projekt stabiler und lesbarer zu machen. Der externe Anbieter mag zehn Autoren, drei Titelvarianten, fünf Identifikatoren und ein Feld edition_count speichern wollen. Unserer Anwendung reicht in der aktuellen Phase normalerweise weniger: externe ID, Titel und ein Autor „als Zeichenkette“. Und das ist in Ordnung: Wir sind nicht verpflichtet, das gesamte Internet in unseren Code zu schleppen.
Wichtiger Punkt: Normalisierte DTOs sollten für den Verbraucher im Projekt bequem sein. In unserem Fall sind die Verbraucher die Befehle catalog search und catalog details, die das Ergebnis dem Benutzer anzeigen. Das Modell muss also ausreichend vollständig für die Ausgabe und gleichzeitig einfach genug sein, um leicht aus dem provider DTO zusammengebaut werden zu können.
Minimalbeispiel eines normalisierten DTO für den Katalog
Wir erstellen zwei DTOs: eines für das Suchergebnis (kurz) und eines für Details (etwas umfangreicher). In einem echten Projekt werden das separate Dateien sein, aber als Idee sieht es so aus:
import java.util.Objects;
public record CatalogBookSearchItem(String externalId, String title, String author) {
public CatalogBookSearchItem {
// Normalisiertes Modell: Innerhalb der Anwendung wollen wir vorhersagbare (nicht-null) Felder
Objects.requireNonNull(externalId);
Objects.requireNonNull(title);
Objects.requireNonNull(author);
}
}
Hier legen wir sofort drei wichtige Eigenschaften fest. Erstens: externalId – das, was später an den Befehl details übergeben werden kann. Zweitens: title – das, was der Mensch mit den Augen liest. Drittens: author – bereits eine „normalisierte“ Zeichenkette, selbst wenn der Anbieter viele oder seltsam dargestellte Autoren hatte.
Wenn Sie später weitere Felder zu CatalogBookDetails hinzufügen möchten (z. B. das Jahr der Erstausgabe), ist das unsere Wahl und keine Verpflichtung gegenüber dem Anbieter. Normalisierung bedeutet, sagen zu können: „Danke, das Überflüssige brauchen wir nicht.“
3. CatalogClient: Interne API der Anwendung
Die interne API ist Ihr kleiner Vertrag innerhalb des Projekts: welche Funktionen des „Katalogs“ sind für den restlichen Code verfügbar. Ihre Aufgabe ist es, sicherzustellen, dass ReadLaterApplication und andere Klassen nichts wissen über HttpClient, ObjectMapper, Endpunkte des Anbieters und die Namen seiner JSON-Felder. Sie sollen nur das wissen, was sie wirklich brauchen: „Finde Bücher“ und „Gib Buchdetails“.
Das ist wie eine Fernbedienung. Die Fernbedienung kann „einschalten“, „Lautstärke“, „Kanal“. Sie zeigt Ihnen nicht den Elektronikplan und fordert Sie nicht auf, die Spannung für die Matrix zu wählen. Und das ist wunderbar: Sie wollen eine Serie schauen, nicht eine Dissertation über Videosignale verteidigen.
Schnittstelle CatalogClient: zwei Methoden
Wir erstellen eine Schnittstelle (oder einfach einen Vertrag), die nur normalized DTOs zurückgibt:
import java.util.List;
public interface CatalogClient {
// Interne API: nur das, was die Anwendung braucht (ohne HTTP/JSON-Details)
List<CatalogBookSearchItem> search(String query);
// Details zu einem Buch anhand der externen ID (die von search zurückgegeben wurde)
CatalogBookDetails details(String externalId);
}
Beachten Sie, was hier nicht enthalten ist. Es gibt kein HttpResponse<String>, kein provider DTO, kein JsonNode, kein ObjectMapper. Die interne API ist absichtlich „einfältig“ und kurz – so soll es sein. Je weniger Details Sie nach außen geben, desto weniger Details müssen später an zehn Stellen unterstützt werden.
Interface: praktischer Nutzen
Für einen Anfänger sieht ein Interface manchmal aus wie „eine weitere Datei um einer weiteren Datei willen“. Aber hier löst es eine sehr praktische Aufgabe: Dieselbe interne API soll sowohl im Real-Modus als auch im Mock-Modus funktionieren. Wenn Sie das Interface CatalogClient haben, muss Ihr Code, der das Ergebnis ausgibt, nicht wissen, woher die Daten kommen. Das bedeutet, Sie können zwischen den Modi wechseln, ohne die Hälfte des Projekts umschreiben zu müssen.
Und ja: Hier spüren Sie den Geschmack des Wortes „Implementierungsaustausch“ ohne jegliche Frameworks. Einfach Java, einfach gesunder Menschenverstand.
4. Provider DTO: neben dem Client
Provider DTOs sind Ihre „Handschuhe“ für die Arbeit mit fremdem JSON. Sie können seltsam sein, mit Unterstrichen, optionalen Feldern, unerwarteten Typen. Und das ist keine Tragödie: Sie spiegeln die Realität der Außenwelt wider. Aber in die Anwendung hinein (in unseren normalen Code) sollten diese Seltsamkeiten nicht gelangen.
Eine gute Gewohnheit: Sobald Sie bemerken, dass Sie irgendwo außerhalb der Katalogschicht ...dto.provider... importieren, ist das ein Signal, dass der externe Vertrag bereits zu lecken beginnt. Nicht das Ende der Welt, aber ein Grund, innezuhalten und die Grenze wiederherzustellen.
Kleines Beispiel eines provider DTO mit „unbequemen“ Namen
Der Anbieter könnte das Feld author_name nennen, wir wollen bei uns authorNames. Das ist eine klassische Geschichte. Auf der Ebene des provider DTO wird das lokal und einmal gelöst:
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public record ProviderBookDoc(
String key,
String title,
// Bindet den „krummen“ Feldnamen aus JSON an den normalen Namen in Java
@JsonProperty("author_name") List<String> authorNames
) {
// Wichtig: Dieses DTO existiert nur „neben dem Client“, nicht in der gesamten Anwendung
}
Jetzt ist der ganze „Dreck“ des Namens author_name an einem Ort eingesperrt. Idealerweise genau an einem Ort für das gesamte Projekt. Ihr CatalogClient und Ihre normalized DTOs sollten überhaupt nicht wissen, dass irgendjemand da draußen Unterstriche liebt.
5. Mapping provider → normalized: ein Übersetzer
Mapping ist der „Übersetzer“, der das fremde Datenformat in unseres umwandelt. Manchmal möchte man direkt „dort, wo es nötig ist“ mappen, weil es schneller ist. Aber dann stellt man plötzlich fest, dass dieselbe Übersetzung „in drei Varianten“ an drei Stellen lebt und alle ein bisschen unterschiedlich sind. In diesem Moment bekommen Sie die typische Bug-Story: „im Suchergebnis ist der Autor Unknown, in den Details ist der Autor ein leerer String, und an der dritten Stelle ist der Autor null“. Herzlichen Glückwunsch, Sie haben drei Standards erfunden.
Besser eine kleine Mapper-Klasse oder zumindest private Mapping-Methoden innerhalb des Clients anlegen. Hauptsache, die Umwandlung ist zentralisiert und befindet sich neben dem provider DTO, nicht neben der UI-Ausgabe.
Minimaler Mapper für das Suchelement
Hier ist ein kleines Beispiel für das Mapping von ProviderBookDoc zu CatalogBookSearchItem. Es ist absichtlich kurz und macht nur das Nötigste:
import java.util.List;
public class CatalogMapper {
CatalogBookSearchItem toSearchItem(ProviderBookDoc doc) {
// Das externe API ist instabil: die Autorenliste kann null oder leer sein
List<String> authors = doc.authorNames();
// Die Entscheidung über den Default wird hier zentral getroffen
String author = (authors == null || authors.isEmpty()) ? "Unknown" : authors.get(0);
// Ausgabe ist streng normalisiertes Modell, bequem für die restliche Anwendung
return new CatalogBookSearchItem(doc.key(), doc.title(), author);
}
}
Hier steckt ein sehr wichtiger praktischer Punkt. Beim Anbieter können Autoren null oder leer sein – und das ist kein „seltener Fall“, sondern das normale Leben einer API. Wir treffen eine Entscheidung: Wenn es keinen Autor gibt, geben wir "Unknown" aus. Diese Entscheidung lebt jetzt an einem Ort, nicht über das Projekt verstreut.
Wenn Sie später entscheiden, dass der erste Autor und dann mit Komma der zweite angezeigt werden soll, ändern Sie eine Funktion, anstatt zehn identische doc.authorNames().get(0) im Projekt zu suchen.
6. RealCatalogClient: Implementierung über HTTP
Jetzt setzen wir alles in einer echten Implementierung von CatalogClient zusammen, die das externe API aufruft. Wichtig ist, das Ziel im Kopf zu behalten: Nach außen versprechen wir search(query) und details(externalId) mit normalized DTOs. Innen machen wir alles Nötige, um dieses Versprechen zu erfüllen: URI bauen, HTTP aufrufen, JSON holen, provider DTO lesen, dann in normalized DTO mappen.
Ja, das sind mehrere Schritte. Aber es sind ehrliche Schritte. Und sie werden an einem Ort liegen, nicht über das Projekt verstreut. Das ist, als ob Sie eine „Wasseraufbereitungsanlage“ am Hauseingang installieren, anstatt das Wasser in jeder Tasse einzeln mit einem Mulltuch zu filtern.
Grundgerüst von RealCatalogClient: Abhängigkeiten und Konstruktor
Wir kennen bereits zwei Schlüsselobjekte: HttpClient und ObjectMapper. Fügen wir CatalogMapper hinzu. Außerdem brauchen wir baseUrl (vorerst als Zeichenkette/Konstante speicherbar).
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.http.HttpClient;
public class RealCatalogClient implements CatalogClient {
// Wichtig: Abhängigkeiten werden von außen übergeben – so ist das Testen und Austauschen der Implementierung einfacher
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
private final CatalogMapper mapper;
public RealCatalogClient(HttpClient httpClient, ObjectMapper objectMapper, CatalogMapper mapper) {
this.httpClient = httpClient;
this.objectMapper = objectMapper;
this.mapper = mapper;
}
// Weiter unten kommen die Methoden search/details und private Hilfsmethoden für HTTP und Parsing
}
Der Sinn des Konstruktors hier ist sehr bodenständig. Wir erstellen HttpClient und ObjectMapper nicht innerhalb der Klasse, um kein „Objekt zu bekommen, das sich alles selbst erstellt“. Solcher Code ist schwer zu kontrollieren, schwer auf Mock umzustellen und verhält sich im Allgemeinen wie eine Katze: tut, was er will, und schaut Sie vorwurfsvoll an.
Methode search(query): Nach außen Liste von normalized DTOs, innen – provider DTO
Innerhalb von search lesen wir die provider-Antwort, dann mappen wir jedes Dokument in ein normalisiertes Element.
import java.util.List;
public List<CatalogBookSearchItem> search(String query) {
// 1) Innerhalb des Clients führen wir HTTP durch und erhalten den rohen Antworttext (JSON)
String json = callSearchApi(query); // innen: HTTP GET und body als String
// 2) JSON in provider DTO umwandeln (das externe Modell bleibt innerhalb dieser Schicht)
ProviderSearchResponse response = readSearchResponse(json);
// 3) provider DTO in normalisierte DTOs mappen, die die Anwendung „versteht“
return response.docs().stream().map(mapper::toSearchItem).toList();
}
Beachten Sie den Trick: Wir haben die „umfangreichen“ Schritte absichtlich hinter kleine Methoden callSearchApi(query) und readSearchResponse(json) versteckt. Das nicht aus „Schönheit“, sondern für die Lesbarkeit. Wenn Sie search lesen, sehen Sie den Geschäftssinn: JSON holen → parsen → in Ergebnis umwandeln. Die Details von HTTP und Jackson bleiben innerhalb der Klasse.
Und ja, ProviderSearchResponse existiert hier, aber es existiert innerhalb von RealCatalogClient. Es wird nicht nach außen zurückgegeben und nicht im Interface erwähnt.
Methode details(externalId): Verhalten bei „not found“ vereinbaren
Für Details ist es normalerweise praktisch, ein klares Verhalten zu haben: entweder die Karte zurückgeben oder explizit sagen, dass das Buch nicht existiert. Auf der Ebene der internen API kann man das mit einer Exception umsetzen (nicht, weil wir Exceptions lieben, sondern weil wir einen Befehl haben, der entweder die Karte anzeigen oder eine verständliche Meldung zeigen will).
public CatalogBookDetails details(String externalId) {
// 1) JSON der Details zur externen ID holen
String json = callDetailsApi(externalId);
// 2) provider DTO parsen (das ist noch nicht das, was die Anwendung sehen will)
ProviderDetailsResponse provider = readDetailsResponse(json);
// 3) In normalisierte Buchkarte übersetzen
return mapper.toDetails(provider);
}
Wir gehen hier nicht auf die ideale Behandlung von Antwortcodes und Exceptions ein – das ist ein separates großes Thema. Wichtiger ist etwas anderes: Selbst in details geht nach außen nur CatalogBookDetails, die provider-Struktur bleibt intern.
7. MockCatalogClient: Dieselbe interne API, andere Datenquelle
Der Mock-Modus ist nicht dazu da, „den Benutzer zu täuschen“, sondern um das Projekt reproduzierbar zu machen. Die externe API kann nicht verfügbar sein, den Vertrag ändern, Sie wegen zu aktivem Lernenthusiasmus blockieren. Und wenn Ihr Projekt nur bei glücklicher Sternenkonstellation funktioniert, ist das kein Lernprojekt, sondern eine Quest.
Aber der Mock-Modus macht nur dann Sinn, wenn er die interne API nicht bricht. Das heißt, MockCatalogClient muss genau dieselben normalized DTOs zurückgeben wie der echte Client. Der Modus ändert die Datenquelle, nicht das Ergebnisformat.
Beispiel: Mock search liest sample JSON und gibt normalisiertes Ergebnis zurück
Im Mock-Client nehmen wir JSON aus den Ressourcen, lesen es mit demselben ObjectMapper, erhalten ein provider DTO und mappen mit demselben Mapper. Das ist wichtig: Der Mock sollte der Realität in der Datenform so ähnlich wie möglich sein.
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
public List<CatalogBookSearchItem> search(String query) {
// Festgelegte Provider-Antwort aus den Ressourcen lesen (reproduzierbares Verhalten)
try (InputStream is = getClass().getResourceAsStream("/mock/search.json")) {
if (is == null) {
throw new IllegalStateException("Mock resource not found: /mock/search.json");
}
// Mit demselben ObjectMapper und in dieselben provider DTOs parsen wie im echten Client
ProviderSearchResponse response = objectMapper.readValue(is, ProviderSearchResponse.class);
// Nach außen nur das normalisierte Modell zurückgeben (wie von CatalogClient versprochen)
return response.docs().stream().map(mapper::toSearchItem).toList();
} catch (IOException e) {
throw new IllegalStateException("Cannot read mock search response", e);
}
}
Beachten Sie: Die Signatur von search ist hier dieselbe wie bei CatalogClient. Für die restliche Anwendung müssen real und mock identisch aussehen: checked Exceptions fließen nicht nach außen, und ein Problem mit der Ressource oder dem Lesen des Mock-JSON wird zu einem expliziten runtime-Fehler.
8. Ein einziger Punkt für die Modusauswahl: Außen immer CatalogClient
Wenn ein Projekt zwei Implementierungen hat, taucht auch die typische Falle auf: man beginnt, if (useMock) über den gesamten Code zu verteilen. Das endet fast garantiert damit, dass Sie an einer Stelle den Zweig vergessen, an einer anderen verwechselt und an einer dritten „schnell gemacht haben“. Ergebnis: Es gibt scheinbar zwei Modi, aber fünf Verhaltensweisen.
Besser ist es, die Implementierung an einer Stelle auszuwählen (üblicherweise im Startpunkt der Anwendung) und dann überall über CatalogClient zu arbeiten. Dann lebt der gesamte Rest der Anwendung in glücklicher Unwissenheit und wird nicht zur „if-else“-Farm.
Mini-Factory: Implementierung auswählen und eine Schnittstelle zurückgeben
Vorerst ohne Konfigurationen und komplexe Systeme – einfach eine Methode, die CatalogClient zurückgibt.
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.http.HttpClient;
public class CatalogClients {
public static CatalogClient create(boolean useMock, HttpClient httpClient, ObjectMapper objectMapper) {
// Gemeinsamer Mapper: gleiche Normalisierungsregeln für real und mock
CatalogMapper mapper = new CatalogMapper();
// Genau eine Stelle für die Modusauswahl: die Anwendung arbeitet danach nur über CatalogClient
return useMock
? new MockCatalogClient(objectMapper, mapper)
: new RealCatalogClient(httpClient, objectMapper, mapper);
}
}
Beachten Sie: Nach außen wird CatalogClient zurückgegeben, nicht RealCatalogClient. Das ist eine kleine, aber sehr wichtige Anstrengung. Es ist wie ein Fitnessstudio-Abo: Es macht Sie nicht von allein stärker, aber es erhöht drastisch die Wahrscheinlichkeit, dass Sie nicht zum „if-else“-Menschen werden.
9. Typische Fehler bei der Arbeit mit der internen API
Fehler Nr. 1: Ein provider DTO nach außen geben, „weil es schneller geht“.
Auf kurze Sicht spart das tatsächlich Zeit – kein Mapping nötig, man kann die fertige Struktur sofort nutzen. Aber Sie bezahlen dafür, dass Ihr ReadLaterApplication anfängt, das fremde JSON und sein Format zu kennen. Jede Änderung beim Anbieter wird zu einer Kaskade von Änderungen im gesamten Projekt. Ein guter Indikator für das Problem ist, wenn Sie Importe aus dto.provider außerhalb der Katalogschicht sehen – die Grenze ist bereits undicht und muss wiederhergestellt werden.
Fehler Nr. 2: Das Mapping direkt an der Ausgabestelle durchführen, „weil es nur drei Zeilen sind“.
So beginnt fast immer die Duplizierung. Erst drei Zeilen, dann ein weiteres Szenario, dann noch eines – und am Ende haben Sie mehrere Kopien von fast identischem Code, die null, leere Listen und Formatierung unterschiedlich behandeln. Das Mapping sollte an einem Ort leben: ein separater CatalogMapper oder zumindest private Methoden innerhalb des Clients, aber nicht über den Code „verschmiert“.
Fehler Nr. 3: useMock über das gesamte Projekt verteilen und zwei verschiedene Logiken erhalten.
Wenn der Modus mock/real nicht nur die Datenquelle, sondern auch das Verhalten des Codes beeinflusst, erschaffen Sie faktisch zwei Versionen der Anwendung. Am Ende entsteht das klassische Problem: „Im Mock funktioniert alles, im Real stürzt es ab.“ Die Ursache ist fast immer, dass es sich nicht mehr um einen einzigen Logikfluss handelt. Der Modus sollte nur die Datenquelle umschalten, das Format und die Verarbeitung des Ergebnisses müssen einheitlich bleiben.
Fehler Nr. 4: Nicht berücksichtigen, dass die externe API unerwartete Daten zurückgeben kann.
Felder können fehlen, Listen können leer sein, Werte können null sein. Wenn das Mapping „blind“ geschrieben ist, verwandelt sich die Anwendung schnell in einen Generator von NullPointerException. Die Lösung ist einfach: An einer Stelle (im Mapper) verständliche Defaults festlegen – "Unknown", leere Sammlungen, „keine Daten“ – und die Prüfungen nicht über das Projekt verteilen.
Fehler Nr. 5: Null-Schutz und Defaults an verschiedenen Stellen des Codes halten.
Manchmal berücksichtigen Entwickler scheinbar die Instabilität der externen API, aber sie tun es punktuell: mal eine Null-Prüfung, mal ein if (empty), mal ein Default-String. Das Ergebnis ist ein unvorhersehbares Verhalten. Alle derartigen Entscheidungen sollten zentralisiert sein – im Mapper oder Adapter. Dann haben Sie eine einzige Wahrheitsquelle, keine Ansammlung zufälliger Vereinbarungen im Code.
GO TO FULL VERSION