JavaRush /Курси /Java Server /ReadingListItem і <...

ReadingListItem і ReadingStatus

Java Server
Рівень 19 , Лекція 0
Відкрита

1. Внутрішня доменна модель до API

Коли ми говоримо «локальний API для reading list», легко почати думати лише про POST, GET і красиві JSON-відповіді. Але спершу бекенд має зрозуміти значно приземленішу річ: що саме він зберігає і як це живе всередині застосунку. Без цього дуже легко побудувати API навколо випадкового набору полів, а потім довго й болісно виправляти наслідки.

Внутрішня доменна модель — це не «DTO, тільки без record». Це об’єкти, які відображають сенс предметної області: «у моєму списку читання є книжки, у кожної є статус, автор, назва, інколи зовнішній id і коментар». Доменна модель не зобов’язана бути зручною для JSON, зате вона зобов’язана бути чесною для застосунку: придатною для змін, стійкою до помилок і зрозумілою в коді сервісів.

Дуже грубо можна намалювати таку картинку — не про HTTP, а про сенс.

flowchart LR
    A["Користувач хоче вести список читання"] --> B["Застосунок зберігає стан"]
    B --> C["ReadingListItem"]
    C --> D["ReadingStatus"]

Зараз нам потрібні саме ReadingListItem і ReadingStatus. Спочатку фіксуємо внутрішній «скелет» даних, а вже поверх нього з’являться DTO та error-контракт.

2. Де живе домен у структурі ReadLater Starter

Після рефакторингу структури проєкту у нас з’явилося важливе правило: код лежить там, де йому логічно місце. Доменні сутності — це не утиліти й не частина транспортного шару, тому ми не ховаємо їх у common і не складаємо в app, «бо так швидше». У reading list є своя фіча, отже домен має жити всередині неї.

У ReadLater Starter це виглядає приблизно так — сильно спрощено, але по суті.

com.example.readlater/
└── readinglist/
    └── domain/
        ├── ReadingListItem.java
        └── ReadingStatus.java

Чому це важливо? Бо коли поруч з’являться readinglist.http і readinglist.dto, ви будете вдячні собі за те, що domain лежить окремо. Тоді буде простіше тримати в голові «внутрішній стан застосунку» й «те, що ми показуємо назовні». І так, саме тут багато новачків роблять класичну помилку: «А давайте доменну модель одразу зробимо як JSON». Потім виявляється, що JSON хочеться змінити, а домен уже приклеєний суперклеєм до контракту.

Щоб ще краще відчути межі, ось корисна «шарувата» схемка, без спроби піти в архітектурну релігію.

flowchart TD
    H["HTTP handler / вебшар"] --> S[Service]
    S --> R[Repository]
    S --> D["Домен: ReadingListItem"]
    R --> D

Домен тут — як «внутрішня мова» фічі. handler і DTO — це перекладачі ззовні, repository — комірник, service — менеджер процесу. А ReadingListItem — це те, заради чого взагалі вся вечірка.

3. ReadingStatus: enum замість «магічних рядків»

Статус — це чудовий приклад того, як одна маленька «економія часу» перетворюється на місяць смутку. Якщо зберігати статус рядком, то в коді дуже швидко з’являються значення на кшталт "planned", "PLANNED", "Planed" (так, описка), "in_progress", "INPROGRESS" — і все це «майже однакове», але для програми це різні рядки, а отже й різні стани. І ось ви вже ловите баги рівня «чому фільтр за статусом не працює» та пів дня дивитеся на екран як на ворога народу.

Тому в доменній моделі статус — це enum. Він фіксує кінцевий набір допустимих станів, робить код самодокументованим і дозволяє компілятору бути вашим маленьким занудним другом: «ні, так не можна, такого статусу не існує».

Мінімальна версія ReadingStatus для нашого проєкту виглядає так.

package com.example.readlater.readinglist.domain;

// Можливі стани читання: фіксуємо кінцевий набір, щоб не було "магічних рядків"
public enum ReadingStatus {
    PLANNED,     // планую прочитати
    IN_PROGRESS, // читаю зараз
    FINISHED     // вже прочитав
}

Зверніть увагу на стиль: значення у верхньому регістрі — це стандарт для enum. І ще нюанс: ми поки не додаємо ABANDONED, хоча в теорії можна. У навчальному проєкті краще мати менше статусів, але провести їх чесно через увесь код, ніж додати десять варіантів і потім забути обробити половину.

Якщо хочеться побачити, що саме ми виграємо, уявіть такий код — не робіть так, просто відчуйте біль.

String status = "FINISED"; // описка: компілятор промовчить, а баг уже народився

if (status.equals("FINISHED")) {
    System.out.println("Книжку прочитано");
}

Компілятор тут мовчить, а баг уже народився. enum такі речі ловить на етапі компіляції — ще до запуску. Магія? Ні, просто типізація, яка робить ваше життя трохи менш драматичним.

4. ReadingListItem: поля та сенс

Коли ми говоримо «елемент списку читання», це не «JSON, який прийшов від клієнта» і не «об’єкт, який красиво віддаємо назовні». Це внутрішня сутність, яка зберігає стан: назву, автора, статус, опційні поля й локальний id. Тобто це той об’єкт, який реально лежатиме в пам’яті, а згодом — у Map, але туди ми сьогодні не ліземо.

З погляду проєкту у ReadingListItem мінімально необхідні поля: id, title, author, status, а також externalId і comment як необов’язкові. Поля externalId і comment «необов’язкові» не означає «можна зберігати як завгодно». Це означає, що ми маємо домовитися, як представляємо відсутність значення. Ідея «нехай буде порожній рядок, а там розберемося» зазвичай закінчується тим, що розбиратися доводиться всюди.

Каркас класу може виглядати так.

package com.example.readlater.readinglist.domain;

public class ReadingListItem {
    private final long id; // локальний ідентифікатор, задається системою і не має змінюватися

    private String title; // інваріант: не blank
    private String author; // інваріант: не blank
    private ReadingStatus status; // інваріант: не null

    private String externalId; // optional: null, якщо не задано
    private String comment;    // optional: null, якщо не задано

    // Гетери й методи домену тут опущені: зараз важливі самі поля та їх ролі
}

Чому class, а не record? Бо елемент reading list змінюється: статус може переходити з PLANNED у IN_PROGRESS, коментар можна дописати, автора — виправити, якщо помилилися. record добрий для даних, які не змінюються, а в нас об’єкт живий.

І ще важлива думка про id: як правило, id створює система, а не клієнт. Тому id майже завжди final, і змінювати його не можна. Якщо змінити id, це вже не «оновили книжку», а «підмінили особу». У житті таке називають шахрайством, у коді — дуже неприємним багом.

5. Інваріанти домену: як не допустити «напівживий» об’єкт

Інваріанти — це такі правила, які мають бути істинними завжди, якщо об’єкт існує. Наприклад, «у елемента списку читання має бути непорожня назва» — це не «бажано», а «інакше сенс об’єкта зникає». Якщо ви допускаєте title = "", то за тиждень будете писати костилі на кшталт «якщо title порожній, то покажемо “(без назви)”» і тихо ненавидіти себе з минулого. Перевірено поколіннями.

У доменній моделі інваріанти найкраще захищати одразу під час створення об’єкта і під час його зміни. Найпростіший спосіб на нашому рівні курсу — перевірки в конструкторі та в методах оновлення. Ми не будуємо складну систему валідації, а просто чесно кажемо: «об’єкт не має бути створений у некоректному стані».

Конструктор із базовими перевірками та повною ініціалізацією полів може бути таким.

import java.util.Objects;

// Конструктор усередині ReadingListItem: одразу захищаємо інваріанти на вході
public ReadingListItem(long id, String title, String author, ReadingStatus status,
                       String externalId, String comment) {
    // id має бути "справжнім", а не 0/-1
    if (id <= 0) throw new IllegalArgumentException("id має бути додатним");

    this.id = id;

    // title/author не мають бути порожніми (включно з "   ")
    this.title = requireNotBlank(title, "title");
    this.author = requireNotBlank(author, "author");

    // статус обов’язковий — null тут означає "об’єкт напівживий"
    this.status = Objects.requireNonNull(status, "status не має бути null");

    // optional-поля теж одразу приводимо до однієї форми
    this.externalId = normalizeOptional(externalId);
    this.comment = normalizeOptional(comment);
}

Тут важливі дві речі. По-перше, ми явно використовуємо IllegalArgumentException, бо помилка в параметрах конструктора — це і є «неправильний аргумент». По-друге, ми не розмазуємо перевірки по проєкту: частина логіки живе прямо поруч із місцем, де об’єкт отримує свій стан.

А ось маленький хелпер, щоб перевірки не виглядали як «копіпаста на 40 рядків».

// Мікрохелпер для інваріантів: "рядок має бути заданим і не містити лише пробілів"
private static String requireNotBlank(String value, String fieldName) {
    if (value == null || value.isBlank()) {
        throw new IllegalArgumentException(fieldName + " не має бути blank");
    }
    return value;
}

Так, це трохи більше коду, ніж «просто присвоїти поля». Але це як ремінь безпеки: поки не потрібен — здається зайвим, а коли потрібен — уже пізно шукати в інтернеті «як його поставити».

І ще тонкість: у реальному API клієнт не має отримувати сирий IllegalArgumentException як текст помилки. Зовнішній шар потім перетворить такі внутрішні проблеми в нормальну JSON-відповідь із зрозумілим кодом і повідомленням.

6. Зміна стану: методи домену замість «всі поля public»

У бекенд-коді дуже легко спокуситися «швидкістю»: відкрити поля, накидати сетери й змінювати об’єкт як душа забажає. Але тоді об’єкт можна випадково зламати з будь-якого місця програми. А потім ви шукаєте, хто поставив status = null, і виявляєте, що це зробив «якийсь код в іншому файлі, який ви не чіпали». Він, до речі, теж так думав.

Тому добра звичка — змінювати стан через зрозумілі методи, які зберігають інваріанти. Найочевидніший приклад — зміна статусу. Ми не хочемо, щоб хтось зробив setStatus(null). Ми хочемо, щоб домен сказав: «статус має бути нормальним».

Приклад методу зміни статусу.

public void changeStatus(ReadingStatus newStatus) {
    // Захищаємо інваріант: статус не має ставати null
    if (newStatus == null) {
        throw new IllegalArgumentException("newStatus не має бути null");
    }
    this.status = newStatus;
}

Чи можна зробити просто setStatus? Технічно так. Практично — ви самі позбавляєте себе можливості висловити сенс: changeStatus читається як бізнес-дія, а setStatus — як «я рухаю байти в пам’яті». Ми вчимося backend-мисленню, отже й код має говорити мовою дій.

Повне оновлення полів може виглядати так.

public void update(String title, String author, ReadingStatus status, String externalId, String comment) {
    // Повне оновлення проводимо через ті самі інваріанти, що й у конструкторі
    this.title = requireNotBlank(title, "title");
    this.author = requireNotBlank(author, "author");
    this.status = Objects.requireNonNull(status, "status не має бути null");

    // Optional-поля знову приводимо до єдиної форми
    this.externalId = normalizeOptional(externalId);
    this.comment = normalizeOptional(comment);
}

Тут зручно розвести два сценарії. changeStatus — вузька доменна дія, коли змінюється лише статус. update — повна заміна полів одним викликом. Так у домену одразу є зрозумілі операції, а не розсип «напівсетерів» під кожен окремий випадок.

І, щоб зовсім приземлити це на зрозумілий рівень, ось маленький фрагмент використання доменного об’єкта — просто демонстрація, без сервера і без репозиторію.

// Створили елемент у статусі "планую"
ReadingListItem item = new ReadingListItem(
        1L, "Clean Code", "Robert C. Martin", ReadingStatus.PLANNED,
        "OL12345M", null
);

// Змінили статус по-людськи, через доменний метод
item.changeStatus(ReadingStatus.IN_PROGRESS);

System.out.println(item.getStatus()); // IN_PROGRESS

Так, це майже іграшка. Але саме такі «іграшки» допомагають мозку пов’язати абстракцію «доменна модель» з реальним життям об’єкта в пам’яті.

7. externalId і comment: опційні поля та дружба з null

У конструкторі та в update ми вже використали normalizeOptional, і це не випадково. Опційні поля — це не зло. Зло — це опційні поля без домовленості. Якщо в одному місці проєкту «відсутнє значення» означає null, в іншому — порожній рядок, а в третьому — рядок із пробілів, то ви починаєте програмувати не домен, а археологію: «що автор мав на увазі?».

Для нашого reading list логічно вважати externalId і comment необов’язковими. Якщо користувач не зв’язав книжку із зовнішнім каталогом — externalId відсутній. Якщо не захотів писати коментар — comment відсутній. Найпростіша модель відсутності для Java — null. Не тому, що null прекрасний, а тому, що він зрозумілий і дешевий за кодом на цьому рівні курсу.

Корисний прийом — нормалізувати порожні й «пробільні» рядки в null, щоб у нас не було трьох різних «порожнеч» замість однієї.

// Нормалізація опційного поля: null/blank -> null, інакше повертаємо вже підчищене значення
private static String normalizeOptional(String value) {
    if (value == null) {
        return null;
    }
    String trimmed = value.trim();
    return trimmed.isBlank() ? null : trimmed;
}

Тепер ми можемо спокійно робити normalizeOptional(comment) і бути впевненими: або там нормальний текст, або null. І це одразу спрощує майбутній JSON-контракт: у відповіді можна повернути comment: null або взагалі не повертати поле, але всередині домену в нас один зрозумілий стан.

Ще важливий момент про externalId: у проєкті є правило унікальності — два елементи списку читання не мають мати один і той самий externalId, якщо він заданий. Але це правило не про один об’єкт, а про систему загалом: порівнювати потрібно з іншими елементами. Отже, перевірка унікальності житиме не в ReadingListItem, а в сервісі або репозиторії. Доменний об’єкт при цьому все одно має поводитися пристойно: якщо externalId заданий, він не має бути «порожнім рядком із пробілів».

8. record і доменна сутність

record у Java — класна річ. Він економить тонну шаблонного коду й ідеально підходить для «контрактних» моделей: DTO запитів, DTO відповідей, маленьких структур даних. І саме тому рука новачка інколи тягнеться зробити record взагалі для всього: «раз він короткий, значить він найкращий». Але доменна сутність — це не завжди «просто дані».

Наш ReadingListItem живе всередині застосунку й змінюється. Так, можна робити незмінювану модель, створюючи новий об’єкт на кожну зміну, але для навчального проєкту це додасть зайвої складності: ви почнете пояснювати копіювання, і хтось обов’язково зробить «ой, я забув присвоїти назад» — і привіт, баг. Тому тут чесний змінюваний class простіший і зрозуміліший.

Невелика таблиця, щоб закріпити це не емоціями, а практикою.

Питання
record
class
Зручний для JSON DTO Так, майже ідеальний Можна, але більше шаблонного коду
Зручний для змінюваного стану Зазвичай ні Так
Чи можна гарантувати інваріанти Можна, але оновлення складніші Можна, і оновлення простіші
Добрий для «внутрішньої сутності» Інколи, якщо immutable-стиль Часто найкращий вибір у простому CRUD
Підходить для нашого ReadingListItem Можна, але буде болючіше Так, і пояснювати простіше

Тут важлива не «правильність за книжкою», а методична чесність: ми будуємо домен, який легко буде використовувати в сервісі, зберігати в пам’яті й змінювати без танців. А от DTO, які відображають JSON-контракт локального API, справді зручно робити record: там немає змінюваного стану доменної сутності.

9. Типові помилки під час проєктування

Помилки в доменній моделі часто виглядають безневинно, бо проєкт ще маленький і «все працює». Але домен — як фундамент: поки будуєте перший поверх, не видно, що він кривий. А коли добудовуєте другий, раптом виявляється, що двері не зачиняються. Нижче — найчастіші граблі саме для нашої теми.

Помилка №1: зберігати статус рядком, бо «так простіше».
Спочатку справді здається простіше: прийшов JSON зі статусом — поклали рядок. Але дуже швидко рядок перетворюється на джерело хаосу: різні регістри, описки, невідповідні варіанти. enum вирішує це без героїзму й робить неможливими цілі класи помилок ще на етапі компіляції.

Помилка №2: дозволити створення об’єкта з порожнім title або author.
Якщо доменна сутність може існувати в беззмістовному стані, то цей стан колись обов’язково з’явиться. Причому не в той момент, коли ви готові його ловити. Мінімальні інваріанти потрібно захищати в конструкторі та методах оновлення, навіть якщо здається, що «валідувати буде HTTP-шар».

Помилка №3: зробити всі поля public або нагенерувати сетерів на все.
Тоді будь-який шматок коду в будь-якому місці проєкту може поставити об’єкт у некоректний стан. У маленькому проєкті це приводить до «чому воно зламалося?», у великому — до «хто це зробив і чому він ще працює в компанії?». Краще давати домену методи дій (changeStatus, update) і тримати контроль над змінами.

Помилка №4: плутати «опційно» з «порожній рядок, ну й добре».
Якщо externalId і comment можуть бути відсутніми, домовтеся, як виглядає відсутність. Найпростіший варіант — null, плюс нормалізація blank -> null. Інакше у вас буде три види порожнечі, і кожен endpoint почне трактувати їх по-своєму.

Помилка №5: намагатися зробити домен одразу «як JSON», додаючи туди все підряд.
У домен не мають просочуватися транспортні деталі: JSON-імена полів, особливості серіалізації, HTTP-статуси й тим паче ObjectMapper. Домен — це про сенс. JSON і HTTP — це про межу. Якщо змішати, ви отримаєте об’єкт-мутант, який незручний і всередині, і зовні.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ