CodeGym /Kursy /Java Server /ReadingListItem i ReadingStatus

ReadingListItem i ReadingStatus

Java Server
Poziom 19, Lekcja 0
Dostępny

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
record
class
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.

1
Zadanie
Java Server, poziom 19, lekcja 0
Niedostępne
Minimalny model domeny wpisu listy czytelniczej
Minimalny model domeny wpisu listy czytelniczej
1
Zadanie
Java Server, poziom 19, lekcja 0
Niedostępne
Pełna aktualizacja bytu domenowego
Pełna aktualizacja bytu domenowego
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION