JavaRush /Курси /Spring REST & MVC /Seed data для стартового стану API

Seed data для стартового стану API

Spring REST & MVC
Рівень 9 , Лекція 3
Відкрита

1. Порожній репозиторій: працює, але не навчає

Коли ви робите навчальний REST API, дуже легко потрапити в пастку: «контролер віддав відповідь, статус 200 є — значить, усе чудово». А потім студент — або ви самі за тиждень — відкриває проєкт, надсилає запит GET /tasks і бачить у відповіді… нічого. Формально все чесно: даних немає. Але мозку від цього не легше — перевіряти нічого, порівнювати нічого, пояснювати нічого. Це як тренувати плавання за картинками: вода наче десь є, а мокрим ви так і не стали.

Seed data — це невеликий набір заздалегідь підготовлених даних, який зʼявляється під час запуску застосунку. Він потрібен не для того, щоб зробити «красиве демо», а для того, щоб зробити поведінку API відтворюваною. Вам важливо, щоб у кожному запуску застосунок стартував в однаковому стані: ті самі завдання, ті самі ідентифікатори, той самий порядок. Тоді ви можете спокійно писати .http-запити, показувати приклади на лекціях і не грати в лотерею «а які id у мене сьогодні випали».

Якщо ви зараз думаєте: «ну я ж можу спочатку створити 5 завдань вручну через POST» — можете. Але це перетворює кожну перевірку API на окремий квест із ручними кроками. А ми будуємо проєкт так, щоб він був зручним для навчання: запустили — уже є дані, можна одразу перевіряти list/detail, можна одразу бачити різницю між різними завданнями, можна одразу обговорювати, чому структура проєкту важлива.

Seed data в нашому проєкті

Важливо домовитися про терміни, інакше далі все змішається в один великий «набір даних». Seed data в Task Tracker API — це стартовий набір записів, який живе всередині застосунку і завантажується під час запуску. Це не база даних, не міграції, не «виробничі» дані й не «прямо як у реальному проді» (у проді ви не захочете, щоб вам на кожному розгортанні хтось додавав завдання «Перевірити кнопку»). Ми робимо навчальний сервіс, тож seed data — це методичний інструмент.

Seed data також не обовʼязково має бути великим. Навпаки: що більше ви насиплете даних, то складніше їх читати й пояснювати. Хороший seed data — це кілька записів, які відрізняються один від одного за змістом. Якщо у вас 20 однакових завдань «Test task 1…20», це не дані — це шум. А ось 6–10 завдань із різними статусами, пріоритетами та строками — уже корисно: ви запускаєте проєкт і одразу бачите різні життєві ситуації.

Ще один момент: seed data не повинно ламати архітектуру. Дуже часта помилка — «ну я просто в контролері створю список завдань, щоб було що повернути». Тоді ви, по суті, переносите відповідальність за зберігання й ініціалізацію даних у вебшар. Сервіс і репозиторій стають декоративними, а контролер роздувається. Seed data має завантажуватися там, де живе зберігання, або в окремій технічній точці старту застосунку, але точно не в контролері.

2. Детермінованість і фіксовані id

Seed data цінний не тим, що він «є», а тим, що він однаковий щоразу. Це слово звучить сухо, зате означає дуже корисну річ: детермінованість. Коли seed data детермінований, ви можете один раз написати запит GET /api/v1/tasks/{taskId}, і він працюватиме сьогодні, завтра і за тиждень. Коли seed data «випадковий», ви щоразу шукаєте новий id, переписуєте запити й марнуєте час.

Найтиповіший «майже правильний» код виглядає так: ви створюєте завдання і даєте їм id через UUID.randomUUID(). На вигляд усе красиво: «справжні UUID», є чим пишатися. Але під час наступного запуску застосунку id зміняться, і будь-які приклади, .http-запити та пояснення перестають збігатися з реальністю.

Давайте зафіксуємо це в маленькій табличці — вона добре допомагає зрозуміти, чому ми прискіпуємося до стабільних id:

Підхід до id у seed data Що отримуємо на кожному запуску Плюс Мінус
UUID.randomUUID() нові id «схоже на прод» ламає повторюваність, приклади та перевірки
фіксовані рядки UUID ті самі id відтворюваність і перевірюваність потрібно один раз вручну задати значення
task-1 task-2 ті самі id простіше для ока гірше збігається з нашою домовленістю “UUID string”

У проєкті ми домовилися про формат «UUID string». Це означає, що публічний id виглядає як UUID, навіть якщо ми зберігаємо його в типі String. Тож найзручніший навчальний варіант — фіксовані UUID-рядки.

Приклад фрагмента enum зі статусами — він нам знадобиться для «різноманітних» завдань:

package com.example.tasktracker.domain.model;

// Статуси завдання: зручно використовувати і в доменній моделі, і в seed data.
public enum TaskStatus {
    TODO,
    IN_PROGRESS,
    BLOCKED,
    DONE,
    ARCHIVED
}

З таким набором seed data легко створювати завдання, які «живуть у різних станах», а не однаково лежать у TODO, як студенти в понеділок зранку.

3. Зберігання seed data

Коли в проєкті зʼявляється seed data, рука тягнеться закинути його туди, «де зручно». У результаті через кілька днів ви знаходите стартові записи в трьох місцях: частину — у конструкторі репозиторію, частину — у сервісі, частину — в якомусь дивному util/DataGenerator. А потім хтось змінює одне поле в одному місці, забуває в другому, і дані стають… «цікавими». Не в хорошому сенсі.

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

Наприклад, у пакеті infrastructure.repository.inmemory (або поряд, щоб його було легко знайти) можна тримати TaskSeedData. Він може виглядати так (показую компактно, без десятків полів, щоб було читабельно):

package com.example.tasktracker.infrastructure.repository.inmemory;

import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatus;
import java.util.List;

public final class TaskSeedData {

    public static List<Task> initialTasks() {
        // Важливо: фіксовані id = відтворюваність прикладів і .http-запитів.
        // Важливо: порядок елементів у списку теж фіксуємо навмисно.
        return List.of(
            task("11111111-1111-1111-1111-111111111111", "Зібрати каркас проєкту", TaskStatus.DONE),
            task("22222222-2222-2222-2222-222222222222", "Зробити in-memory репозиторій", TaskStatus.IN_PROGRESS)
        );
    }

    private TaskSeedData() {
        // Утилітний клас: екземпляри не потрібні.
    }
}

Важлива деталь тут — метод task(...). Він допомагає зробити список коротким і не перетворювати initialTasks() на полотно на три екрани. Простий варіант допоміжного методу може бути таким:

import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatus;

private static Task task(String id, String title, TaskStatus status) {
    // Створюємо обʼєкт максимально просто, щоб seed data читався очима.
    Task task = new Task(id, title);

    // Явно встановлюємо статус, щоб у seed data були різні «сценарії життя».
    task.setStatus(status);

    return task;
}

Так, тут ми використовуємо сеттер. В ідеальному світі ви, можливо, зробили б обʼєкт завдання незмінним, але зараз наш пріоритет — навчальна прозорість. Seed data має читатися очима. Якщо для цього зручніше створити обʼєкт і «донастроїти» його сеттерами — це нормально для поточного етапу.

4. Завантаження seed data під час запуску

Коли seed data описано, залишається головне інженерне питання: коли і де його завантажити в наш in-memory репозиторій? Ми хочемо, щоб дані зʼявлялися автоматично під час запуску застосунку, без ручних дій. Водночас ми не хочемо ламати ланцюжок залежностей: controller -> service -> repository.

Найпростіший шлях — а для навчального проєкту часто й ідеальний — завантажити seed data в конструкторі репозиторію. Репозиторій створюється Spring під час запуску лише один раз, і в цей момент можна наповнити внутрішню Map стартовими завданнями. Приклад — покажу скорочено:

package com.example.tasktracker.infrastructure.repository.inmemory;

import com.example.tasktracker.domain.model.Task;
import java.util.LinkedHashMap;
import java.util.Map;

public class InMemoryTaskRepository {
    // LinkedHashMap зберігає порядок вставки — це важливо для стабільного GET /tasks.
    private final Map<String, Task> tasks = new LinkedHashMap<>();

    public InMemoryTaskRepository() {
        // Ініціалізація даних відбувається рівно один раз під час запуску застосунку.
        TaskSeedData.initialTasks().forEach(t -> tasks.put(t.getId(), t));
    }
}

Тут LinkedHashMap важливий не тому, що так модно, а тому, що він зберігає порядок вставки. Це допомагає, коли ви робите GET /tasks і очікуєте побачити завдання в стабільному порядку. У навчальних прикладах це прямо рятує нерви: ви не сперечаєтеся з компʼютером, чому сьогодні «першим завданням» стало інше.

Якщо ініціалізація потім зачепить кілька репозиторіїв, її часто виносять в окремий ApplicationRunner у config. Але для нашого поточного базового варіанта це вже зайвий шар: важливіше, щоб одразу було видно, звідки береться стартовий набір завдань.

5. Seed data: різноманітність без перевантаження

Seed data особливо корисний, коли записи відрізняються один від одного так, що на них можна показувати різні сценарії читання. Навіть якщо прямо зараз ваш GET /tasks просто повертає список, вам уже добре мати завдання «в різних станах», із різними назвами, а не копії одного й того самого.

Наприклад, можна додати завдання, які відображають різні стадії життя завдання. Навіть якщо ви поки не реалізуєте бізнес-правила переходів статусів, ви вже можете зберігати статус усередині Task, тому що це частина доменної моделі проєкту. Тоді seed data стає «живим» і візуально зрозумілим.

Невеликий приклад того, як seed data може виглядати з різноманітністю:

return List.of(
    // Фіксовані UUID: так зручно писати детерміновані запити в .http.
    task("11111111-1111-1111-1111-111111111111", "Зібрати каркас проєкту", TaskStatus.DONE),
    task("22222222-2222-2222-2222-222222222222", "Додати поділ service/repository", TaskStatus.IN_PROGRESS),
    task("33333333-3333-3333-3333-333333333333", "Підготувати seed data", TaskStatus.TODO),
    task("44444444-4444-4444-4444-444444444444", "Розібратися з Attachment API", TaskStatus.BLOCKED)
);

Тут ми не заглиблюємося в «як правильно сортувати» або «як фільтрувати». Ми просто створюємо стартовий набір, який потім зручно використовувати в будь-якому обговоренні. Ви запускаєте застосунок — і вже в першій відповіді GET /tasks можна очима помітити: «ага, є DONE, є BLOCKED». Це сильно підвищує наочність проєкту.

6. Перевірка вручну

Ручна перевірка через .http

Коли seed data завантажено, хочеться переконатися, що він справді працює. Найпростіший спосіб — зробити один запит до вашої кінцевої точки list. Це хороший момент, щоб закріпити практику «перевіряємо API вручну» без тестів і без складної інфраструктури.

Приклад запиту — можна покласти у файл requests/tasks.http або туди, де ви домовилися зберігати подібні файли в проєкті:

### List tasks (seed data)
# Перевіряємо, що застосунок стартує вже з даними (без ручних POST перед перевіркою).
GET http://localhost:8080/api/v1/tasks
Accept: application/json

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

7. Типові помилки seed data

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

Помилка №2: випадкові id через UUID.randomUUID() у seed data.
Спочатку здається, що це «правильніше». Але вже завтра ви перепишете половину .http-запитів, тому що id змінилися, а потім почнете шукати їх у відповіді list endpointʼа, копіювати вручну й нервувати. Для навчального API та для відтворюваних прикладів потрібні фіксовані значення.

Помилка №3: занадто великий seed data, який неможливо прочитати очима.
Якщо TaskSeedData займає 300 рядків, ви програли. Seed data має бути маленьким, щоб його можна було пояснити. Краще 6–10 хороших завдань, які відрізняються за змістом, ніж 50 однотипних. Це не база даних, це стартова «вітрина» проєкту.

Помилка №4: seed data розмазаний по проєкту і дублюється.
Коли частина завдань створюється в TaskSeedData, частина — у конструкторі репозиторію, а частина — в якомусь «помічнику», ви рано чи пізно отримаєте розсинхрон. Наприклад, ви зміните заголовок завдання в одному місці, а в іншому забудете. У результаті під час запуску зʼявляться два схожі записи або дані суперечитимуть одне одному. Тримайте seed data в одному місці й завантажуйте його єдиним способом.

Помилка №5: нестабільний порядок списку, який повертається.
Навіть якщо id фіксовані, можна випадково зберігати дані в структурі, яка не гарантує порядок, а потім дивуватися, чому список «стрибає» між запусками. Для in-memory репозиторію в навчальному проєкті хороша звичка — використовувати LinkedHashMap і наповнювати його в одному й тому самому порядку. Тоді GET /tasks виглядає однаково й передбачувано.

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