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 не должно ломать архитектуру. Очень частая ошибка — «ну я просто в контроллере создам список задач, чтобы было что вернуть». Тогда вы, по сути, переносите ответственность хранения и инициализации данных в web-слой. Сервис и репозиторий становятся декоративными, а контроллер раздувается. 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() в полотно на три экрана. Простой вариант helper-метода может быть таким:
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. Но для нашего текущего baseline это уже лишний слой: важнее, чтобы было сразу видно, откуда берётся стартовый набор задач.
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 split", 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 endpoint. Это хороший момент, чтобы закрепить практику “проверяем 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 должен жить в репозитории или в конфигурации старта приложения, но не в web-слое.
Ошибка №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 выглядит одинаково и предсказуемо.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ