1. Wewnętrzny model domenowy przed API
Kiedy mówimy „lokalne API dla reading list”, łatwo zacząć myśleć tylko o POST, GET i ładnych odpowiedziach JSON. Ale backend najpierw musi zrozumieć rzecz znacznie bardziej przyziemną: co dokładnie przechowuje i jak to żyje wewnątrz aplikacji. Bez tego bardzo łatwo zbudować API wokół przypadkowego zestawu pól, a potem długo i boleśnie naprawiać konsekwencje.
Wewnętrzny model domenowy to nie „DTO, tylko bez record”. To obiekty, które oddają sens dziedziny: „na mojej liście lektur są książki, każda ma status, autora, tytuł, czasem zewnętrzne id i komentarz”. Model domenowy nie musi być ładny dla JSON-a, za to musi być uczciwy wobec aplikacji: wygodny do zmian, odporny na błędy i czytelny w kodzie serwisów.
Bardzo z grubsza można narysować taki obrazek – nie o HTTP, a o sensie.
flowchart LR
A["Użytkownik chce prowadzić listę lektur"] --> B["Aplikacja przechowuje stan"]
B --> C["ReadingListItem"]
C --> D["ReadingStatus"]
Teraz potrzebujemy właśnie ReadingListItem i ReadingStatus. Najpierw ustalamy wewnętrzny „szkielet” danych, a dopiero na nim pojawią się DTO i error-kontrakt.
2. Gdzie żyje domena w strukturze ReadLater Starter
Po refaktoringu struktury projektu pojawiła się u nas ważna zasada: kod leży tam, gdzie logicznie należy. Encje domenowe to nie utilsy ani część warstwy transportowej, dlatego nie chowamy ich w common i nie wrzucamy do app, „bo tak szybciej”. Reading list ma swój feature, więc domena powinna żyć wewnątrz niego.
W ReadLater Starter wygląda to mniej więcej tak – mocno uproszczone, ale konkretnie.
com.example.readlater/
└── readinglist/
└── domain/
├── ReadingListItem.java
└── ReadingStatus.java
Dlaczego to ważne? Bo kiedy obok pojawią się readinglist.http i readinglist.dto, będziesz sobie dziękować za to, że domain leży osobno. Wtedy łatwiej trzymać w głowie „wewnętrzny stan aplikacji” i „to, co pokazujemy na zewnątrz”. I tak, właśnie tu wielu początkujących popełnia klasyczny błąd: „A zróbmy model domenowy od razu jak JSON”. Potem okazuje się, że JSON chce się zmienić, a domena jest już przyklejona klejem cyjanoakrylowym do kontraktu.
Żeby jeszcze lepiej poczuć granice, oto przydatny schemat „warstwowy”, bez popadania w architektoniczną religię.
flowchart TD
H["HTTP handler / warstwa web"] --> S[Service]
S --> R[Repository]
S --> D["Domain: ReadingListItem"]
R --> D
Domena jest tu jak „wewnętrzny język” feature’a. handler i DTO to tłumacze na zewnątrz, repository to magazynier, service to menedżer procesu. A ReadingListItem to to, dla czego w ogóle cała ta impreza.
3. ReadingStatus: enum zamiast „magicznych stringów”
Status to świetny przykład tego, jak jedna mała „oszczędność czasu” zamienia się w miesiąc smutku. Jeśli przechowywać status stringiem, to w kodzie bardzo szybko pojawiają się wartości w stylu "planned", "PLANNED", "Planed" (tak, literówka), "in_progress", "INPROGRESS" – i wszystko to jest „prawie takie samo”, ale dla programu to różne stringi, czyli różne stany. I oto już łapiesz bugi w stylu „dlaczego filtr po statusie nie działa” i pół dnia gapisz się w ekran jak na wroga ludu.
Dlatego w modelu domenowym status to enum. Ustala on skończony zbiór dopuszczalnych stanów, czyni kod samodokumentującym i pozwala kompilatorowi być twoim małym marudnym przyjacielem: „nie, tak nie można, taki status nie istnieje”.
Minimalna wersja ReadingStatus dla naszego projektu wygląda tak.
package com.example.readlater.readinglist.domain;
// Możliwe statusy czytania: ustalamy skończony zbiór, żeby nie było "magicznych stringów"
public enum ReadingStatus {
PLANNED, // planuję przeczytać
IN_PROGRESS, // czytam teraz
FINISHED // już przeczytane
}
Zwróć uwagę na styl: wartości pisane wielkimi literami – to standard dla enum. I jeszcze niuans: na razie nie dodajemy ABANDONED, choć teoretycznie można. W projekcie edukacyjnym lepiej mieć mniej statusów, ale uczciwie przeprowadzić je przez cały kod, niż dodać dziesięć wariantów i potem zapomnieć obsłużyć połowę.
Jeśli chcesz zobaczyć, co konkretnie zyskujemy, wyobraź sobie taki kod – nie rób tak, po prostu poczuj ból.
String status = "FINISED"; // literówka: kompilator milczy, a bug już się urodził
if (status.equals("FINISHED")) {
System.out.println("Książka przeczytana");
}
Kompilator tu milczy, a bug już się urodził. enum takie rzeczy łapie na etapie kompilacji – jeszcze przed uruchomieniem. Magia? Nie, po prostu typowanie, które czyni twoje życie odrobinę mniej dramatycznym.
4. ReadingListItem: pola i sens
Kiedy mówimy „element listy lektur”, to nie jest „JSON, który przyszedł od klienta” ani „obiekt, który ładnie oddajemy na zewnątrz”. To wewnętrzna encja, która przechowuje stan: tytuł, autora, status, opcjonalne pola i lokalne id. Czyli ten obiekt, który realnie będzie leżał w pamięci, a później – w Map, ale tam dzisiaj nie wchodzimy.
Z punktu widzenia projektu w ReadingListItem minimalnie niezbędne są pola: id, title, author, status, a także externalId i comment jako opcjonalne. Pola externalId i comment „opcjonalne” nie znaczy „można przechowywać jak popadnie”. To znaczy: musimy się umówić, jak reprezentujemy brak wartości. Pomysł „niech będzie pusty string, a tam się okaże” zwykle kończy się tym, że okazywać musi się wszędzie.
Szkielet klasy może wyglądać tak.
package com.example.readlater.readinglist.domain;
public class ReadingListItem {
private final long id; // lokalny identyfikator, ustawiany przez system i nie powinien się zmieniać
private String title; // niezmiennik: nie blank
private String author; // niezmiennik: nie blank
private ReadingStatus status; // niezmiennik: nie null
private String externalId; // optional: null, jeśli nie ustawiony
private String comment; // optional: null, jeśli nie ustawiony
// Gettery i metody domeny są tu pominięte: teraz ważne są same pola i ich role
}
Dlaczego class, a nie record? Bo element reading list się zmienia: status może przechodzić z PLANNED w IN_PROGRESS, komentarz można dopisać, autora poprawić, jeśli się pomyliłeś. record jest dobry dla danych, które się nie zmieniają, a u nas obiekt jest – żywy.
I jeszcze ważna myśl o id: z reguły id tworzy system, a nie klient. Dlatego id prawie zawsze jest final i nie można go zmieniać. Jeśli zmienisz id, to już nie „zaktualizowaliśmy książkę”, tylko „podmieniliśmy tożsamość”. W życiu nazywa się to oszustwem, w kodzie – bardzo nieprzyjemnym bugiem.
5. Niezmienniki domeny: jak nie dopuścić do „półżywego” obiektu
Niezmienniki to takie zasady, które muszą być prawdziwe zawsze, jeśli obiekt istnieje. Na przykład „element listy lektur musi mieć niepusty tytuł” – to nie „pożądane”, lecz „inaczej sens obiektu znika”. Jeśli dopuszczasz title = "", to za tydzień będziesz pisać podpórki w stylu „jeśli title jest pusty, to pokażemy «(bez tytułu)»” i cicho nienawidzić siebie z przeszłości. Sprawdzone przez pokolenia.
W modelu domenowym niezmienniki najlepiej chronić od razu przy tworzeniu obiektu i przy jego zmianie. Najprostszy sposób na naszym poziomie kursu – sprawdzenia w konstruktorze i w metodach aktualizacji. Nie budujemy skomplikowanego systemu walidacji, po prostu uczciwie mówimy: „obiekt nie powinien być utworzony w niepoprawnym stanie”.
Konstruktor z podstawowymi sprawdzeniami i pełną inicjalizacją pól może być taki.
import java.util.Objects;
// Konstruktor wewnątrz ReadingListItem: od razu chronimy niezmienniki na wejściu
public ReadingListItem(long id, String title, String author, ReadingStatus status,
String externalId, String comment) {
// id musi być "prawdziwy", a nie 0/-1
if (id <= 0) throw new IllegalArgumentException("id must be positive");
this.id = id;
// title/author nie mogą być puste (włącznie z " ")
this.title = requireNotBlank(title, "title");
this.author = requireNotBlank(author, "author");
// status jest obowiązkowy — null tu oznacza "obiekt półżywy"
this.status = Objects.requireNonNull(status, "status must not be null");
// pola opcjonalne też od razu sprowadzamy do jednej formy
this.externalId = normalizeOptional(externalId);
this.comment = normalizeOptional(comment);
}
Tu ważne są dwie rzeczy. Po pierwsze, jawnie używamy IllegalArgumentException, bo błąd w parametrach konstruktora to dokładnie „niewłaściwy argument”. Po drugie, nie rozmazujemy sprawdzeń po projekcie: część logiki żyje wprost obok miejsca, w którym obiekt otrzymuje swój stan.
A tu mały helper, żeby sprawdzenia nie wyglądały jak „kopiuj-wklej na 40 linii”.
// Mikro-helper dla niezmienników: "string musi być podany i nie być blank"
private static String requireNotBlank(String value, String fieldName) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(fieldName + " must not be blank");
}
return value;
}
Tak, to nieco więcej kodu niż „po prostu przypisać pola”. Ale to jak pas bezpieczeństwa: dopóki nie jest potrzebny – wydaje się zbędny, kiedy jest potrzebny – już za późno googlować „jak go założyć”.
I jeszcze subtelność: w prawdziwym API klient nie powinien otrzymywać surowego IllegalArgumentException jako tekstu błędu. Zewnętrzna warstwa później zamieni takie wewnętrzne problemy w normalną odpowiedź JSON ze zrozumiałym kodem i komunikatem.
6. Zmiana stanu: metody domeny zamiast „wszystkie pola public”
W kodzie backendowym bardzo łatwo skusić się na „szybkość”: otworzyć pola, narzucić setterów i zmieniać obiekt jak dusza zapragnie. Ale wtedy obiekt można przypadkowo zepsuć z dowolnego miejsca programu. A potem szukasz, kto ustawił status = null, i odkrywasz, że zrobił to „jakiś kod w innym pliku, którego nie ruszałeś”. On, swoją drogą, też tak myślał.
Dlatego dobry nawyk: zmiany stanu robić przez zrozumiałe metody, które zachowują niezmienniki. Najbardziej oczywisty przykład – zmiana statusu. Nie chcemy, żeby ktoś zrobił setStatus(null). Chcemy, żeby domena powiedziała: „status musi być normalny”.
Przykład metody zmiany statusu.
public void changeStatus(ReadingStatus newStatus) {
// Chronimy niezmiennik: status nie powinien stać się null
if (newStatus == null) {
throw new IllegalArgumentException("newStatus must not be null");
}
this.status = newStatus;
}
Czy można zrobić po prostu setStatus? Technicznie tak. Praktycznie – sam pozbawiasz się możliwości wyrażenia sensu: changeStatus czyta się jak akcja biznesowa, a setStatus – jak „przesuwam bajty w pamięci”. Uczymy się myślenia backendowego, więc i kod powinien mówić językiem akcji.
Pełna aktualizacja pól może wyglądać tak.
public void update(String title, String author, ReadingStatus status, String externalId, String comment) {
// Pełną aktualizację przeprowadzamy przez te same niezmienniki, co w konstruktorze
this.title = requireNotBlank(title, "title");
this.author = requireNotBlank(author, "author");
this.status = Objects.requireNonNull(status, "status must not be null");
// Pola opcjonalne znów sprowadzamy do jednej formy
this.externalId = normalizeOptional(externalId);
this.comment = normalizeOptional(comment);
}
Tu wygodnie rozdzielić dwa scenariusze. changeStatus – wąska akcja domenowa, kiedy zmienia się tylko status. update – pełna podmiana pól jednym wywołaniem. Tak domena od razu ma zrozumiałe operacje, a nie zbiór „pół-setterów” na każdy oddzielny przypadek.
A żeby zupełnie sprowadzić to na zrozumiały poziom, oto mały fragment użycia obiektu domenowego – po prostu demonstracja, bez serwera i bez repozytorium.
// Utworzyliśmy element ze statusem "planuję"
ReadingListItem item = new ReadingListItem(
1L, "Clean Code", "Robert C. Martin", ReadingStatus.PLANNED,
"OL12345M", null
);
// Zmieniliśmy status "po ludzku", przez metodę domenową
item.changeStatus(ReadingStatus.IN_PROGRESS);
System.out.println(item.getStatus()); // IN_PROGRESS
Tak, to prawie zabawka. Ale właśnie takie „zabawki” pomagają mózgowi powiązać abstrakcję „model domenowy” z realnym życiem obiektu w pamięci.
7. externalId i comment: pola opcjonalne i przyjaźń z null
W konstruktorze i w update już użyliśmy normalizeOptional, i to nie przez przypadek. Pola opcjonalne to nie zło. Zło to pola opcjonalne bez umowy. Jeśli w jednym miejscu projektu „brak wartości” oznacza null, w drugim – pusty string, a w trzecim – string ze spacji, to zaczynasz programować nie domenę, a archeologię: „co autor miał na myśli?”.
Dla naszej reading list logiczne jest uznać externalId i comment za opcjonalne. Jeśli użytkownik nie powiązał książki z zewnętrznym katalogiem – externalId nie istnieje. Jeśli nie chciał pisać komentarza – comment nie istnieje. Najprostszy model braku dla Javy – null. Nie dlatego, że null jest piękny, a dlatego, że jest zrozumiały i tani w kodzie na tym poziomie kursu.
Przydatna technika: normalizować puste i „białoznakowe” stringi do null, żebyśmy nie mieli trzech różnych „pustek” zamiast jednej.
// Normalizacja pola opcjonalnego: null/blank -> null, w przeciwnym razie zwracamy już oczyszczoną wartość
private static String normalizeOptional(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isBlank() ? null : trimmed;
}
Teraz możemy spokojnie robić normalizeOptional(comment) i być pewnym: albo tam jest normalny tekst, albo null. I to od razu upraszcza przyszły kontrakt JSON: w odpowiedzi można zwrócić comment: null albo w ogóle nie zwracać pola, ale wewnątrz domeny mamy jeden zrozumiały stan.
Jeszcze ważny moment o externalId: w projekcie jest zasada unikalności – dwa elementy listy lektur nie powinny mieć tego samego externalId, jeśli jest podany. Ale ta zasada nie dotyczy jednego obiektu, tylko systemu jako całości: porównywać trzeba z innymi elementami. Czyli sprawdzenie unikalności będzie żyć nie w ReadingListItem, lecz w serwisie albo repozytorium. Obiekt domenowy mimo to powinien zachowywać się przyzwoicie: jeśli externalId jest podane, nie powinno być „pustym stringiem ze spacji”.
8. record a encja domenowa
record w Javie to świetna rzecz. Oszczędza tonę szablonowego kodu i idealnie pasuje do modeli „kontraktowych”: DTO żądań, DTO odpowiedzi, małych struktur danych. I właśnie dlatego ręka początkującego czasem ciągnie się, żeby zrobić record w ogóle do wszystkiego: „skoro jest krótki, to znaczy najlepszy”. Ale encja domenowa to nie zawsze „po prostu dane”.
Nasz ReadingListItem żyje wewnątrz aplikacji i się zmienia. Tak, można robić model immutable, tworząc nowy obiekt przy każdej zmianie, ale dla projektu edukacyjnego doda to zbędnej złożoności: zaczniesz tłumaczyć kopiowanie, i ktoś na pewno zrobi „oj, zapomniałem przypisać z powrotem” – i cześć, bug. Dlatego tu uczciwa, modyfikowalna class jest prostsza i zrozumialsza.
Mała tabelka, żeby utrwalić to nie emocjami, a praktyką.
| Pytanie | |
|
|---|---|---|
| Wygodny dla JSON DTO | Tak, niemal idealny | Można, ale więcej szablonowego kodu |
| Wygodny dla zmiennego stanu | Zwykle nie | Tak |
| Czy można zagwarantować niezmienniki | Można, ale aktualizacje są trudniejsze | Można, a aktualizacje prostsze |
| Dobry dla „wewnętrznej encji” | Czasem, jeśli styl immutable | Często najlepszy wybór w prostym CRUD |
| Pasuje do naszego ReadingListItem | Można, ale będzie bardziej bolało | Tak, i łatwiej tłumaczyć |
Tu ważna jest nie „poprawność według książki”, lecz uczciwość metodyczna: budujemy domenę, którą łatwo będzie używać w serwisie, przechowywać w pamięci i zmieniać bez tańców. A DTO, które odzwierciedlają kontrakt JSON lokalnego API, naprawdę wygodnie robić jako record: tam nie ma zmiennego stanu encji domenowej.
9. Typowe błędy w projektowaniu
Błędy w modelu domenowym często wyglądają nieszkodliwie, bo projekt jest jeszcze mały i „wszystko działa”. Ale domena jest jak fundament: dopóki budujesz pierwsze piętro, nie widać, że jest krzywy. A kiedy dobudowujesz drugie, nagle okazuje się, że drzwi się nie zamykają. Niżej – najczęstsze grabie konkretnie dla naszego tematu.
Błąd nr 1: trzymać status jako string, bo „tak prościej”.
Z początku rzeczywiście wydaje się prościej: przyszedł JSON ze statusem – położyliśmy stringa. Ale bardzo szybko string zamienia się w źródło chaosu: różne wielkości liter, literówki, niespójne warianty. enum rozwiązuje to bez heroizmu i czyni niemożliwymi całe klasy błędów już na etapie kompilacji.
Błąd nr 2: pozwolić na utworzenie obiektu z pustym title albo author.
Jeśli encja domenowa może istnieć w bezsensownym stanie, to ten stan kiedyś na pewno się pojawi. Przy czym nie w momencie, gdy będziesz gotów go złapać. Minimalne niezmienniki trzeba chronić w konstruktorze i metodach aktualizacji, nawet jeśli wydaje się, że „walidować będzie warstwa HTTP”.
Błąd nr 3: zrobić wszystkie pola public albo wygenerować settery na wszystko.
Wtedy dowolny kawałek kodu w dowolnym miejscu projektu może ustawić obiekt w niepoprawnym stanie. W małym projekcie prowadzi to do „dlaczego to się zepsuło?”, w dużym – do „kto to zrobił i dlaczego on jeszcze pracuje w firmie?”. Lepiej dawać domenie metody akcji (changeStatus, update) i trzymać kontrolę nad zmianami.
Błąd nr 4: mylić „opcjonalnie” z „pusty string, no i dobra”.
Jeśli externalId i comment mogą nie występować, umówcie się, jak wygląda brak. Najprostsza opcja – null, plus normalizacja blank -> null. Inaczej będziesz mieć trzy rodzaje pustki, a każdy endpoint zacznie traktować je po swojemu.
Błąd nr 5: próbować zrobić domenę od razu „jak JSON”, wrzucając tam wszystko po kolei.
Do domeny nie powinny przenikać szczegóły transportowe: nazwy pól JSON, specyfika serializacji, statusy HTTP i tym bardziej ObjectMapper. Domena to o sensie. JSON i HTTP – to o granicy. Jeśli wymieszasz, dostaniesz obiekt-mutanta, który jest niewygodny zarówno w środku, jak i na zewnątrz.
GO TO FULL VERSION