JavaRush /Курси /Java Server /Provider DTO і normalized DTO

Provider DTO і normalized DTO

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

1. Provider DTO — не модель застосунку

Коли ви пишете клієнт до зовнішнього API, дуже легко потрапити в психологічну пастку: якщо ми вже мапимо JSON у красиві Java-об’єкти, значить це й є «наші дані». Але зовнішній провайдер не підписував із вами пакт про вічну дружбу та стабільні назви полів. Він живе своїм життям, а його JSON — це його мова, звички та дивацтва.

Уявіть, що ви приїхали в іншу країну й вивчили рівно одну фразу: «Де бібліотека?». Ви можете користуватися цією фразою, але якщо спробуєте побудувати на ній усе своє життя — роботу, стосунки, іпотеку — найменша зміна контексту вас зламає. Provider DTO — це «вивчена фраза», а нам потрібен внутрішній словник проєкту.

У ReadLater Starter це особливо важливо, бо в нас є чіткий навчальний контур: команда catalog search і команда catalog details. Обидві мають працювати з результатом у термінах нашого проєкту, а не в термінах «як це назвав зовнішній API сьогодні вранці».

2. Provider DTO: дзеркало зовнішнього контракту

Provider DTO — це модель, яка максимально близько відображає JSON провайдера: його назви полів, структуру, обгортки та вкладеність. Provider DTO не обов’язково мають бути красивими. Ба більше, якщо ви починаєте «перейменовувати все під себе» просто на цьому рівні, ви втрачаєте важливу річ: можливість легко звіряти DTO із зразком JSON і документацією провайдера.

Давайте візьмемо типовий приклад відповіді пошуку: у провайдера є масив docs, а всередині кожного елемента поля можуть називатися так, ніби їх вибирали голосуванням у чаті з трьох людей. (Жарт. Зазвичай це вирішують одна людина й дедлайн.)

Приклад provider DTO для елемента пошуку:

package com.example.readlater.catalog.dto.provider;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;

/**
 * Provider DTO — модель, максимально наближена до полів провайдера.
 * Тут ми свідомо лишаємося в термінах зовнішнього контракту.
 *
 * key — зовнішній ідентифікатор книги в каталозі провайдера
 * title — заголовок, як прислав провайдер
 * authorNames — список авторів із поля author_name (так називається в JSON)
 */
public record OpenLibraryDocDto(
    String key,
    String title,
    @JsonProperty("author_name") List<String> authorNames
) {}

Тут є кілька важливих думок, хоча код короткий.

По-перше, ми не вдаємо, що author_name — це «неправильна назва». Це назва провайдера, і ми її приймаємо. По-друге, ми все одно хочемо зручну назву в Java (authorNames), тому використовуємо @JsonProperty, і Jackson розуміє, що це одне й те саме поле.

Тепер обгортка відповіді:

package com.example.readlater.catalog.dto.provider;

import java.util.List;

/**
 * Provider DTO — обгортка відповіді пошуку від провайдера.
 * Поля та назви — як у контракті (docs, numFound), без "наших" перейменувань.
 */
public record OpenLibrarySearchResponseDto(
    List<OpenLibraryDocDto> docs,
    int numFound
) {}

Так, numFound нам може знадобитися або не знадобитися — але поки ми його чесно описали, тому що він реально є в контракті. І головне: на цьому рівні ми все ще живемо у світі провайдера. Назви docs, key, author_name — усе це зовнішнє.

3. Normalized DTO: внутрішня мова проєкту

Normalized DTO — це модель, яка відображає наші сценарії, а не чужу структуру JSON. Тут ми обираємо зручні назви, зручні типи й зручну форму даних. Normalized DTO — це те, чим мають оперувати команди застосунку (catalog search, catalog details) і будь-яка прикладна логіка клієнтської частини.

Наприклад, нам у catalog search точно потрібні «зовнішній ідентифікатор книги», «заголовок» і «один автор для охайного виведення» (навіть якщо провайдер повертає список авторів). Внутрішню модель пошуку можна зробити простою:

package com.example.readlater.catalog.dto.normalized;

/**
 * Normalized DTO — внутрішня модель результату пошуку для нашого застосунку.
 * Це вже "наша мова", не залежна від назв полів провайдера.
 */
public record CatalogBookSearchItem(
    String externalId,
    String title,
    String author
) {}

Зверніть увагу на два перейменування.

Поле key провайдера стало externalId. У нашому проєкті це зрозуміліше: це ID у зовнішньому каталозі. Поле author_name (список) перетворилося на author (рядок). Це вже рішення нормалізації: для виведення в консоль ми беремо одного автора.

І це нормально. Normalized DTO якраз і існують, щоб приймати рішення, яку форму даних ми хочемо всередині проєкту.

4. Крок нормалізації: нормалізація окремим етапом

Коли ви вперше дізнаєтеся про provider DTO та normalized DTO, з’являється бажання зробити мапінг «по дорозі»: тут в одному методі if, там substring, тут stream(), і начебто все само склеїлося. Але це швидко перетворюється на кашу. Набагато простіше для голови — і для підтримки — якщо між цими моделями буде явний крок нормалізації: окремий метод або клас, який відповідає за перетворення.

Схема дуже проста — і її корисно тримати просто перед очима:

flowchart TD
    A["сирий JSON-рядок"] --> B["Provider DTO OpenLibrarySearchResponseDto"]
    B --> C["Нормалізатор / Mapper"]
    C --> D["Normalized DTO CatalogBookSearchItem"]
    D --> E["Інший код застосунку catalog search / details"]

Ця схема важлива тим, що «бруд зовнішнього світу» (нестабільні поля, дивні назви, зайві дані) зупиняється на етапі B→C. Далі, у точці D, код починає жити з нормальними назвами й передбачуваною формою.

Приклад нормалізації одного елемента:

package com.example.readlater.catalog.service;

import com.example.readlater.catalog.dto.normalized.CatalogBookSearchItem;
import com.example.readlater.catalog.dto.provider.OpenLibraryDocDto;

public class CatalogNormalizer {

    public static CatalogBookSearchItem toSearchItem(OpenLibraryDocDto doc) {
        // Зовнішній світ може надіслати null або порожній список — це нормально для JSON.
        // Наше завдання: привести дані до стабільного вигляду для іншого коду застосунку.
        String author = (doc.authorNames() == null || doc.authorNames().isEmpty())
            ? "Невідомий автор" // Значення за замовчуванням, щоб UI/CLI не падав на порожніх даних
            : doc.authorNames().get(0); // Для search беремо першого автора (рішення нормалізації)

        // key провайдера перетворюємо на зовнішній ID в термінах нашого проєкту.
        return new CatalogBookSearchItem(doc.key(), doc.title(), author);
    }
}

Так, тут є маленьке «бізнес-рішення»: якщо авторів немає, ми пишемо Невідомий автор. Можна обрати й іншу поведінку, головне — тепер це сконцентровано в одному місці. І якщо ви вирішите замість "Невідомий автор" ставити "—", ви змінюєте один рядок, а не шукаєте по проєкту 17 місць, де хтось «по дорозі» підставляв значення за замовчуванням.

А тепер повний шлях: JSON → provider response → список normalized items.

import com.example.readlater.catalog.dto.normalized.CatalogBookSearchItem;
import com.example.readlater.catalog.dto.provider.OpenLibrarySearchResponseDto;
import com.example.readlater.catalog.service.CatalogNormalizer;
import tools.jackson.databind.ObjectMapper;
import java.util.List;

public class CatalogSearchParser {
    // ObjectMapper — точка, де ми перетворюємо "сирий" JSON у provider DTO.
    private final ObjectMapper mapper = new ObjectMapper();

    public List<CatalogBookSearchItem> parse(String json) throws Exception {
        // 1) Десеріалізуємо строго в provider DTO (у термінах провайдера).
        OpenLibrarySearchResponseDto dto = mapper.readValue(json, OpenLibrarySearchResponseDto.class);

        // 2) Нормалізуємо у внутрішні DTO, які безпечно віддавати в інший код.
        return dto.docs().stream().map(CatalogNormalizer::toSearchItem).toList();
    }
}

Зверніть увагу, що тут ми не показуємо мережевий виклик — він у нас уже є в transport-шарі. Тут лише шматок «після response.body()». І це логічно: шар мапінгу JSON стоїть між транспортом та рештою застосунку.

5. Захист від витоку provider DTO

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

Тут допомагає залізне правило: зовнішній шар (клієнт до провайдера) має повертати тільки normalized DTO. Тобто сигнатури методів — це ваш «прикордонник». Якщо на межі ви віддаєте ProviderBookDto, ви самі відчинили ворота.

Порівняймо дві сигнатури. Перша — невдала, тому що назовні протікає зовнішній контракт:

import com.example.readlater.catalog.dto.provider.OpenLibraryDocDto;
import java.util.List;

public interface CatalogClientBadIdea {
    // Невдала ідея: назовні витікає provider DTO, і інший код почне жити за контрактом провайдера.
    List<OpenLibraryDocDto> search(String query);
}

Друга — вдала, тому що назовні виходить внутрішня мова проєкту:

import com.example.readlater.catalog.dto.normalized.CatalogBookSearchItem;
import java.util.List;

public interface CatalogClient {
    // Вдала ідея: назовні віддаємо тільки normalized DTO (внутрішня мова проєкту).
    List<CatalogBookSearchItem> search(String query);
}

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

Щоб закріпити думку, давайте оформимо порівняння в таблицю. Тут не теорія заради теорії, а буквально «що ви побачите в коді через тиждень».

Ознака Provider DTO Normalized DTO
Кому належить модель Провайдеру Нашому проєкту
Назви полів author_name, docs, key externalId, items, author
Стабільність Може змінюватися без попередження Стабільна, доки ми самі не вирішимо її змінити
Де живе Усередині catalog client У прикладній частині catalog search/details
Що робити при зміні провайдера Змінюємо DTO та нормалізатор В ідеалі — не чіпаємо інший код

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

6. Нормалізація: місце для маленьких рішень

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

Наприклад, провайдер може дати список авторів, а нам для catalog search потрібен один автор, щоб охайно вивести результат у консоль. Це рішення нормалізації: беремо першого автора або з’єднуємо рядок через кому. Провайдер може дати порожній список, а нам потрібно щось показати користувачу — ми ставимо значення за замовчуванням. Провайдер може повернути title із зайвими пробілами — ми робимо trim().

Такі рішення краще тримати в одному місці — у нормалізаторі. І дуже бажано, щоб вони були максимально простими та зрозумілими. Нормалізатор — не місце для «запуску міні-ШІ, щоб вгадати, хто головний автор». Не треба перетворювати його на філософію.

Приклад трохи акуратнішого вибору автора без ускладнення:

import java.util.List;

public class AuthorPicker {

    public static String pickSingleAuthor(List<String> authors) {
        // Якщо авторів немає — повертаємо значення за замовчуванням, щоб інший код не робив null-check усюди.
        if (authors == null || authors.isEmpty()) {
            return "Невідомий автор";
        }

        // Беремо першого автора й підчищаємо пробіли: це типова маленька нормалізація.
        return authors.get(0).trim();
    }
}

Цей код примітивний, але в навчальному проєкті це плюс. Його легко читати, легко змінювати й важко зламати.

7. Сценарій: провайдер змінив контракт

Уявіть типовий день із життя зовнішнього API: вони «трохи покращили контракт» (читай: перейменували поле), і тепер замість author_name прийшло authors. Якщо provider DTO та normalized DTO злити в одне, ви змінюєте DTO — і далі каскадом ламаються всі місця, де ви зверталися до authorNames().

Якщо ж у вас є чітка межа, то зона змін стає маленькою: provider DTO (і, можливо, одна анотація @JsonProperty) плюс нормалізатор. Нормалізовані моделі та інший код не знають і не повинні знати, що там відбувається у провайдера.

Наприклад, ви змінюєте лише provider DTO:

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;

/**
 * Provider DTO після зміни контракту провайдера.
 * Ми адаптуємося на межі, щоб внутрішній код проєкту не змінювався.
 */
public record OpenLibraryDocDto(
    String key,
    String title,
    @JsonProperty("authors") List<String> authorNames // Java-імʼя лишили тим самим, змінюємо лише привʼязку до JSON
) {}

А нормалізатор, який працює з authorNames(), може навіть не змінитися. Так, це маленький приклад, але він показує головне: ми локалізуємо хаос зовнішнього світу.

У промисловій розробці це іноді називають антикорупційним шаром (anti-corruption layer): шаром, який не дозволяє зовнішній системі «забруднити» внутрішню модель. Назва грізна, але сенс простий: «не тягніть чужі дивацтва до свого дому».

8. Типові помилки під час роботи з DTO

Помилка №1: «Навіщо два DTO? Зроблю одне, але назву його красиво».
Таке рішення зазвичай виглядає як record BookDto(String key, @JsonProperty("author_name") List<String> author, ...), а потім цей DTO використовують і як provider, і як внутрішній. Проблема проявляється не одразу, а коли вам потрібно перейменувати key на externalId у логіці застосунку або коли один і той самий DTO починає обслуговувати і search, і details, хоча форма відповідей різна. У підсумку DTO роздувається, стає «на все», і ви втрачаєте ясність меж.

Помилка №2: нормалізація розмазана по проєкту «по дорозі».
Сьогодні ви десь написали doc.authorNames().get(0), завтра в іншому місці String.join(", ", doc.authorNames()), а післязавтра ще десь поставили "Невідомий автор". Ззовні все працює, але поведінка стає непередбачуваною: один екран показує одного автора, інший — усіх, третій — значення за замовчуванням. Нормалізатор потрібен саме для того, щоб поведінка була єдиною.

Помилка №3: normalized DTO перетворюється на копію provider DTO «про всяк випадок».
Іноді студент думає: «Зроблю normalized DTO, але покладу туди всі поля провайдера, раптом знадобляться». Це саме той момент, коли normalized DTO перестає бути внутрішньою мовою проєкту й знову стає дзеркалом зовнішнього світу, тільки з іншою назвою. Нормалізована модель має містити рівно те, що потрібно вашим сценаріям зараз, інакше ви втрачаєте сенс розділення.

Помилка №4: назви полів провайдера просочуються в normalized DTO.
Якщо у вашій проєктній моделі зʼявляються поля на кшталт doc, numFound, key, author_name, то ви фактично визнали провайдера «головним власником сенсу». Внутрішні моделі мають говорити вашою мовою: externalId, items, count, author. Це не естетика, а спосіб тримати код читабельним і незалежним.

Помилка №5: відсутність обробки null/порожніх списків у нормалізаторі.
У реальному JSON зовнішнього світу «іноді немає автора», «іноді список порожній», «іноді поле відсутнє». Якщо нормалізатор без перевірки робить get(0), ви отримаєте красивий IndexOutOfBoundsException у найневдаліший момент — зазвичай коли демонструєте проєкт комусь іншому. Нормалізатор — це місце, де ви повинні приземлити дані до стабільної форми.

1
Задача
Java Server, 16 рівень, 4 лекція
Недоступна
Один provider DTO і один нормалізований DTO
Один provider DTO і один нормалізований DTO
1
Задача
Java Server, 16 рівень, 4 лекція
Недоступна
Список normalized DTO із provider search response
Список normalized DTO із provider search response
1
Опитування
JSON-мапінг, рівень 16, лекція 4
Недоступний
JSON-мапінг
Робота з JSON DTO
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ