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 простіший і зрозуміліший.
Невелика таблиця, щоб закріпити це не емоціями, а практикою.
| Питання | |
|
|---|---|---|
| Зручний для 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 — це про межу. Якщо змішати, ви отримаєте об’єкт-мутант, який незручний і всередині, і зовні.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ