1. record добре підходить для DTO
Коли ви вперше робите JSON-мапінг, дуже легко потрапити в пастку: «Ну це ж Java, отже потрібні клас, поля, конструктори, гетери, equals, hashCode…» — і ось ваш DTO на чотири поля раптом займає пів екрана. Для транспортних моделей це особливо прикро: DTO майже завжди про дані, а не про поведінку. Саме тому в Java є чудовий інструмент — record. Він дає змогу описати «контейнер даних» одним виразом і заодно отримати нормальний toString, який потім рятує життя в логах і під час налагодження.
Порівняймо підходи у вигляді невеликої таблиці. Це не про «краще/гірше у вакуумі», а саме про DTO-шар:
| Питання | Звичайний DTO | record для DTO |
|---|---|---|
| Скільки коду потрібно на 3–5 полів | Часто багато шаблонного коду | Зазвичай 1–3 рядки |
| Чи є гетери | Потрібно писати або генерувати | Є автоматично (title(), author()) |
| equals/hashCode/toString | Потрібно писати або генерувати | Є автоматично |
| Змінюваність | Часто роблять змінюваним за звичкою | За замовчуванням «як DTO і має бути»: незмінний каркас |
| На що схоже в голові | «Об’єкт із власним життям» | «Посилка з даними» |
У проєкті ReadLater Starter ми саме в тій точці, де це особливо важливо: у нас є зовнішній контракт провайдера, ми хочемо швидко й безпечно перетворити JSON на типи, а не писати ще один мінішар бюрократії поверх бюрократії.
2. Синтаксис record: компоненти й аксесори
Коли ви чуєте слово record, мозок іноді очікує чогось складного або якоїсь незрозумілої магії. Але насправді record — це просто компактна форма оголошення класу, який зберігає дані. Його «поля» називаються компонентами, і ви перелічуєте їх у круглих дужках. Ось і все: саме це і буде формою вашого JSON-контракту з боку Java-коду. Усередині IDE ви відчуєте перевагу відразу: менше коду — менше місць, де можна помилитися.
Ось мінімальний DTO для книги:
// Мінімальний DTO: імена компонентів = очікувані імена полів у JSON
record BookDto(String title, String author) {}
І тепер його можна використовувати як звичайний тип:
// record створюється як звичайний обʼєкт через new
BookDto book = new BookDto("Clean Code", "Robert C. Martin");
// Аксесори в record без префікса get: title(), author()
System.out.println(book.title()); // Clean Code
System.out.println(book.author()); // Robert C. Martin
Зверніть увагу на два моменти. По-перше, в record аксесори називаються не getTitle, а просто title(). По-друге, ви не побачите сетерів: запис даних у DTO відбувається під час створення обʼєкта. Для транспортних моделей це майже завжди ідеально, тому що заповнення DTO по частинах зазвичай призводить до помилок і непередбачуваності.
Якщо дуже хочеться «підправити поле», правильна думка така: ви не лагодите старий DTO, а створюєте новий. DTO — як чек: його можна передрукувати, але не потрібно «підтирати цифри гумкою», інакше бухгалтерія (і Jackson) вас не зрозуміє.
3. Що дає record: equals, hashCode, toString
Слова equals і hashCode звучать як щось із іспиту з Java Core, який ви намагалися не згадувати. Але в реальному бекенд-житті вони спливають несподівано часто: ви порівнюєте результати, кладете DTO в колекції, налагоджуєте їх і друкуєте в логах. І ось тут record раптом перетворюється на маленького помічника, який робить нудну роботу за вас.
Подивіться, наскільки читабельний toString у record:
record BookDto(String title, String author) {}
// Два обʼєкти з однаковими компонентами будуть рівними за equals()
BookDto a = new BookDto("Clean Code", "Robert C. Martin");
BookDto b = new BookDto("Clean Code", "Robert C. Martin");
// toString() у record автоматично друкує всі компоненти
System.out.println(a); // BookDto[title=Clean Code, author=Robert C. Martin]
System.out.println(a.equals(b)); // true
Для навчального проєкту це корисно вже зараз, навіть без повноцінного логування (воно буде пізніше). Коли ви налагоджуєте JSON-мапінг, дуже часто хочеться просто вивести результат десеріалізації й очима перевірити: «Jackson взагалі прочитав те, що я очікував?». З record це виходить без ритуалів.
Запамʼятайте лише одне: equals/hashCode у record порівнюють усі компоненти. Для DTO це зазвичай нормально, тому що DTO справді «рівні», якщо в них однакові дані.
4. record і Jackson: JSON-мапінг
Тут важливо поєднати дві речі: JSON уже читається через ObjectMapper, а record дає для цього читання коротку й чесну форму DTO. Сама механіка readValue(...) нам уже знайома; корисно побачити, що record не потребує для неї жодної особливої магії.
Найменший приклад десеріалізації:
import tools.jackson.databind.ObjectMapper;
// DTO, у який мапимо JSON
record BookDto(String title, String author) {}
// Головний інструмент Jackson для читання/запису JSON
ObjectMapper mapper = new ObjectMapper();
// Найзручніше тестувати мапінг через text block
String json = """
{"title":"Clean Code","author":"Robert C. Martin"}
""";
// readValue: JSON-рядок -> Java-обʼєкт вказаного класу
BookDto book = mapper.readValue(json, BookDto.class);
// Далі працюємо вже не зі строкою, а з типізованими даними
System.out.println(book.title()); // Clean Code
Цього достатньо, щоб побачити головне: record спокійно працює на межі з JSON. Зворотне перетворення поводиться так само спокійно: той самий DTO Jackson уміє зібрати назад у JSON, якщо вам потрібні зразок відповіді, лог або тіло запиту.
Важливо: record тут не замінює Jackson. ObjectMapper залишається вашим перекладачем між JSON-рядком і Java-типами; record просто робить модель даних компактною.
Щоб зафіксувати це в голові, корисно один раз побачити схему потоку даних:
flowchart LR
A["HttpResponse<String>.body() — сирий JSON"] --> B["ObjectMapper.readValue(...)"]
B --> C["Транспортний DTO (record)"]
C --> D["Далі код працює з полями: title(), author(), …"]
Якщо ви тримаєте цю схему в голові, ви перестаєте тягнути строку далі й починаєте працювати з даними нормально — як і хотів дорослий бекенд.
5. Вкладений JSON: обгортки та списки
Реальні відповіді зовнішніх API майже ніколи не виглядають як один обʼєкт із двома полями. Зазвичай у вас є обʼєкт-обгортка, наприклад відповідь пошуку, всередині — масив документів, а всередині кожного документа — поля, списки, іноді вкладені обʼєкти. І тут новачок часто помиляється: намагається десеріалізувати JSON одразу в List<...>, ігноруючи верхній рівень. Це зазвичай закінчується тим, що Jackson каже: «очікував масив, а отримав обʼєкт» — і формально він правий.
Тому правило просте: якщо JSON зверху — обʼєкт, то і DTO зверху має бути обʼєктом (record), навіть якщо нас цікавить лише масив усередині.
Приклад під наш сценарій пошук у каталозі: відповідь містить docs (список результатів) і numFound (скільки всього знайдено). Ми описуємо це двома record — обгорткою й елементом списку:
import java.util.List;
// Один елемент списку docs (одна «книга/документ» у видачі)
record ProviderDocDto(
String key,
String title,
List<String> author_name // імʼя компонента повторює зовнішній JSON-контракт
) {}
// Відповідь верхнього рівня: обʼєкт-обгортка, усередині якого лежить масив docs
record ProviderSearchResponseDto(
List<ProviderDocDto> docs,
Integer numFound
) {}
Так, імʼя author_name виглядає не зовсім по-джавівськи. Але поки що ми чесно відображаємо зовнішній контракт: provider DTO — це дзеркало чужого API. У транспортному шарі іноді доводиться ковтати чужий стиль іменування, як гірку мікстуру. Головне — не розносити цей стиль по всьому застосунку.
Тепер десеріалізація такої відповіді виглядає цілком спокійно:
import tools.jackson.databind.ObjectMapper;
import java.util.List;
ObjectMapper mapper = new ObjectMapper();
// JSON "зверху" обʼєкт, тому читаємо в ProviderSearchResponseDto, а не в List<...>
String json = """
{"docs":[{"key":"OL1M","title":"Clean Code","author_name":["Robert C. Martin"]}],
"numFound":1}
""";
ProviderSearchResponseDto dto = mapper.readValue(json, ProviderSearchResponseDto.class);
// Дістаємо дані з обгортки через аксесори record
System.out.println(dto.docs().get(0).title()); // Clean Code
Зверніть увагу, наскільки ці record схожі на JSON. І саме ця схожість — ваша головна опора в перші тижні роботи з контрактами: ви не вгадуєте структуру, а описуєте її, а потім працюєте з нею як із нормальними даними.
6. Типи полів: примітиви, обгортки, null
У шарі DTO типи полів — це не просто «який клас мені подобається більше». Це ваші очікування до даних провайдера. А зовнішній JSON, на жаль, не завжди готовий відповідати вашим очікуванням із підручника. Тому в транспортному DTO корисно бути трохи обережнішим і вибирати типи так, щоб вони виражали реальність: поле може бути відсутнім, може бути null, може бути порожнім списком — і це три різні стани.
Найчастіша розвилка — примітиви (int, long, boolean) проти обгорток (Integer, Long, Boolean). Примітиви не вміють бути null, а отже не вміють чесно виражати «значення не було». Обгортки вміють. Тому в DTO часто розумніше вибирати саме обгортки, особливо для даних із зовнішнього світу.
Ось приклад DTO з роком публікації, який може бути невідомий:
// Integer замість int: відсутність поля в JSON буде виражено як null, а не як "0"
record ProviderDetailsDto(
String key,
String title,
Integer firstPublishYear
) {}
Чому Integer, а не int? Тому що якщо року немає, ви хочете отримати null, а не «магічний 0», який потім складно відрізнити від реального значення.
Та сама логіка працює зі списками. Якщо поле в JSON — масив, ви зазвичай описуєте його List<...>. Наприклад, список авторів:
import java.util.List;
record ProviderDocDto(
String key,
String title,
List<String> author_name // список авторів із JSON-масиву
) {}
Тут є важливий нюанс, який новачкам часто ставить підніжку: record сам по собі незмінний, але List усередині нього — змінюваний обʼєкт. Тобто record дає вам «незмінність посилання», але не «глибоку незмінність усіх вкладених обʼєктів». Для DTO це не катастрофа, але краще тримати в голові: не потрібно змінювати списки всередині DTO по дорозі, інакше ви самі собі зробите баг, схожий на «дані раптово змінилися самі».
У межах поточного етапу ми не ускладнюємо й не будуємо окремих незмінних списків. Ми просто дисциплінуємо себе: DTO прочитали — далі з ним працюємо як із фактом, не намагаючись його редагувати.
7. Межа відповідальності транспортного DTO
Коли record починає подобатися, виникає спокуса використовувати його взагалі всюди: і як DTO, і як доменну модель, і як «трохи сервіс», і як «ну тут ще метод додам». Це нормальний етап: ви знайшли молоток, і тепер раптом усі проблеми схожі на цвяхи. Але наша мета — зберегти чіткі межі.
Транспортні DTO (record) на клієнтському етапі проєкту мають залишатися простими: вони описують вхідні дані від провайдера й дають змогу їх прочитати. Складна логіка, нормалізація, перейменування, очищення даних — це окремі кроки, і ми не змішуємо їх прямо в DTO, інакше у вас вийде обʼєкт-восьминіг, який і дані зберігає, і форматує, і валідацію робить, і ще каву варить.
При цьому невеликі допоміжні методи, які не змінюють дані й допомагають із читабельністю, цілком допустимі. Наприклад, провайдер може повернути кількох авторів, але вам для виведення в консоль достатньо першого:
import java.util.List;
record ProviderDocDto(String key, String title, List<String> author_name) {
// Безпечно отримати "першого автора" для виведення, не роздуваючи код перевірками
String firstAuthorOrDash() {
// Зовнішній контракт може повернути null або порожній список — обробляємо обидва випадки
return (author_name == null || author_name.isEmpty()) ? "—" : author_name.get(0);
}
}
Цей метод нічого не нормалізує глобально і не перетворює DTO на бізнес-сутність. Він просто робить ваш прикладний код читабельнішим: замість пʼяти перевірок на null ви викликаєте один метод. Але тримайте дисципліну: щойно ви починаєте додавати в DTO шматки логіки рівня «а давайте тут же вирішимо, що таке коректний author», — це вже натяк, що час виносити окремий шар обробки.
8. Типові помилки під час роботи з record DTO
Помилка №1: перетворювати DTO на універсальний мегаклас для всіх відповідей одразу.
Новачок часто намагається зробити один великий CatalogDto, куди запихає і поля пошуку, і поля деталей, і «про всяк випадок ще 20 полів, раптом стануть у пригоді». Це швидко перетворює мапінг на лотерею: половина полів завжди null, форма обʼєкта перестає відображати конкретний контракт, і ви починаєте плутатися, які поля взагалі мають бути заповнені в якому сценарії. Для кожної відповіді провайдера краще мати свою зрозумілу форму.
Помилка №2: робити транспортний DTO змінюваним за звичкою.
Якщо ви пишете звичайний class і додаєте сетери, зʼявляється спокуса дописувати обʼєкт по частинах: спочатку title, потім author, потім ще щось. Це погано поєднується з JSON-мапінгом, тому що DTO стає станом «у процесі», а не результатом. record допомагає позбутися цієї звички: DTO створюється одразу цілком, як результат читання контракту.
Помилка №3: використовувати примітиви там, де дані можуть бути відсутніми.
Поля зовнішніх API часто необовʼязкові, і якщо ви ставите int або long, то за відсутності поля отримаєте 0. Потім починаються дивні баги: «чому в усіх книжок рік видання 0?» або «чому кількість результатів завжди 0?». У DTO краще вибирати Integer/Long/Boolean, щоб відсутність значення виражалася чесно.
Помилка №4: очікувати «глибоку незмінність» через record.
record робить незмінними посилання на компоненти, але якщо компонент — це List, сам список можна змінювати. Якщо ви або хтось із колег почнете змінювати вміст списку всередині DTO, то обʼєкт начебто immutable, а дані вже змінилися. Для DTO в навчальному проєкті достатньо просто домовитися: DTO не змінюємо, використовуємо його як факт лише для читання.
Помилка №5: змішувати транспортний DTO й прикладну логіку виведення або обробки.
Дуже хочеться написати всередині record метод toPrettyConsoleOutput і ще кілька «зручних». Один-два маленькі helper-и допустимі, але якщо DTO починає знати про консоль, про форматування, про «як правильно відображати дані користувачеві» — це перший крок до архітектурної каші. DTO має описувати дані, а не політику їх використання.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ