1. Роль репозитория в read-only API
Когда слышишь слово «репозиторий», мозг иногда автоматически дорисовывает PostgreSQL, миграции, индексы и DBA, который строго смотрит на вас через монитор. Но сегодня мы гораздо скромнее: нам нужно место, где вообще есть данные. Даже если API «только читает», ему всё равно надо откуда-то читать, иначе получится API уровня «GET /reading-list возвращает вдохновение».
Если данные хранить прямо в handler (например, сделать там List<ReadingListItem>), то очень быстро случится классическое backend-болото: маршрутизация, парсинг запроса, бизнес-логика и хранение окажутся в одной куче. И в этот момент вы уже начинаете писать “мини-фреймворк” в одном классе, хотя клялись себе этого не делать.
Репозиторий в нашей архитектуре — это слой, который отвечает за простую вещь: хранить и отдавать доменные объекты. Он не знает ничего про HTTP, не выбирает статус-коды, не сериализует JSON и не должен пытаться «быть умнее, чем надо». Он просто «кладовщик»: у него есть склад (память процесса) и картотека (ключ id), и он умеет по запросу показать, что лежит на полках.
Ограничения in-memory хранилища
In-memory хранилище — это когда данные живут в памяти процесса Java-приложения. То есть внутри вашей запущенной JVM, рядом с вашими объектами, потоками и бессонными ночами. Вы перезапустили приложение — и хранилище «обнулилось». Это не баг, а честное свойство: никакой устойчивости между перезапусками мы сегодня не обещаем.
Почему это нормально именно в этом курсе? Потому что наша цель — научиться HTTP-цепочке и слоям приложения, не уезжая в соседний курс про SQL/JDBC/ORM. Если бы мы сейчас добавили базу данных, то 80% внимания ушло бы в настройку подключения, схемы, миграции и тонкости хранения. А у нас сегодня цель проще: «пусть сервер умеет вернуть список и один элемент по id», и пусть при этом код остаётся понятным.
Полезно прямо зафиксировать отличия в маленькой таблице — не ради академизма, а чтобы мозг не обманывал себя:
| Вопрос | In-memory репозиторий (Map) | База данных |
|---|---|---|
| Данные переживают перезапуск? | Нет | Да |
| Нужны драйверы/SQL/миграции? | Нет | Да |
| Скорость доступа по id | Очень высокая (в памяти) | Зависит от индексов, сети и нагрузки |
| Можно хранить миллионы записей? | Технически — можно, но быстро станет плохо | Да, это как раз её работа |
| Цель в курсе | Понять слои и HTTP-семантику | Это отдельная дисциплина |
И ещё важный нюанс: in-memory репозиторий — это почти всегда «учебное или прототипное» решение. В реальном проекте он может использоваться как временный стаб, как кеш или как тестовая заглушка, но не как «финальная история хранения». Однако для bridge-course это идеальный компромисс: минимум инфраструктуры, максимум фокуса на API.
Контракт репозитория: говорим на языке домена, а не HTTP
Очень хочется (особенно после недели HTTP) сделать метод вида findByIdOrThrow404 или findAllAsJson — и вроде бы даже «работает». Но это ловушка. Репозиторий — не часть web-слоя, а часть внутренней логики приложения. Его язык — это домен: ReadingListItem, ReadingStatus, идентификатор long. Его результат — это доменные объекты или отсутствие доменного объекта.
Это разделение важно не из-за «красоты», а из-за контроля сложности. Как только репозиторий начнёт знать про HTTP, вы получите класс, который одновременно и «хранит», и «решает, какой статус вернуть», и «формирует JSON», и «пишет в лог». Такой класс невозможно нормально менять: любое изменение будет цеплять все слои сразу.
Ещё одна причина держать репозиторий «глухим к HTTP» — это будущая заменяемость. Сегодня у нас Map. Завтра (в следующем этапе вашей траектории) может появиться база данных. Если репозиторий изначально не смешивал storage-логику с HTTP, то заменить InMemory... на «Database...Repository» будет в разы проще: контракт останется, реализация поменяется.
2. Интерфейс ReadingListRepository
Сегодняшний уровень посвящён read-only части API, значит репозиторию достаточно минимального контракта: «дай всё» и «дай по id». Не больше. Когда новичок видит слово “Repository”, часто начинается «а давайте сразу добавим save, update, delete, findByTitle, findByAuthor, findByStatusAndTitleContainsIgnoreCaseAnd…» — и через 15 минут у вас самодельный Spring Data JPA. Без Spring Data и без JPA, зато с полной болью.
Поэтому мы фиксируем простой интерфейс. Он живёт в пакете readinglist.repository, а возвращает домен из readinglist.domain. Это важная связка: репозиторий не возвращает DTO и не возвращает JSON-строку. Он возвращает ReadingListItem, а дальше другой слой решит, что с ним делать.
Ниже пример контракта именно на сегодня:
import com.example.readlater.readinglist.domain.ReadingListItem;
import java.util.List;
public interface ReadingListRepository {
// Возвращаем все доменные объекты как есть (без DTO и без JSON).
List<ReadingListItem> findAll();
// Возвращаем доменный объект по id.
// Если объект не найден — возвращаем null (а уже handler решит, что это 404).
ReadingListItem findById(long id);
}
Обратите внимание на маленький, но принципиальный выбор: findById возвращает ReadingListItem, а если ничего не найдено — вернёт null. Можно было бы использовать Optional, и в больших проектах это часто уместно, но в учебном коде null иногда проще воспринимается, особенно когда вы параллельно учитесь различать 400 и 404 в handler-е. Главное — чтобы null был ожидаемым контрактом, а не сюрпризом.
3. InMemoryReadingListRepository на Map
Теперь переходим к самой «мясной» части лекции: как устроить хранение. Мы знаем, что ключ сценариев чтения сегодня — id из пути (/reading-list/{id}). Значит самая естественная структура — Map<Long, ReadingListItem>. Это буквально «картотека»: по номеру карточки (id) мы быстро находим конкретную запись.
Самый частый вопрос здесь — «почему не List?». Потому что List — это по сути “полка без номеров”: чтобы найти элемент по id, вам пришлось бы каждый раз пробегать все записи и сравнивать item.getId(). Для пары элементов это не страшно, но сам принцип не тот. Map сразу подсказывает мозгу: основной доступ — по ключу.
Давайте зафиксируем минимальные поля класса репозитория. Мы берём HashMap как базовую реализацию Map и отдельно держим счётчик id.
import com.example.readlater.readinglist.domain.ReadingListItem;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
public class InMemoryReadingListRepository implements ReadingListRepository {
// Внутреннее хранилище: ключ — id, значение — доменный объект.
// Важно: наружу этот Map отдавать нельзя, иначе сломаем инкапсуляцию.
private final Map<Long, ReadingListItem> storage = new HashMap<>();
// Генератор уникальных id внутри процесса (in-memory).
// AtomicLong полезен, если запросы придут параллельно.
private final AtomicLong nextId = new AtomicLong(0);
}
Здесь важно две вещи. Первая: storage — это внутренняя структура, и мы не должны отдавать её наружу. Вторая: nextId — часть ответственности слоя хранения. Не handler генерирует id, не service, а именно репозиторий (в нашем учебном упрощении) знает, как выдавать уникальные идентификаторы внутри процесса.
4. AtomicLong: генератор id
Слово Atomic иногда звучит так, будто мы сейчас влезем в квантовую физику и будем обсуждать «суперпозицию идентификаторов». На самом деле всё проще: AtomicLong — это счётчик, который умеет увеличиваться безопасно (атомарно), даже если вдруг несколько потоков захотят получить новый id одновременно.
Да, сегодня мы не делаем курс по конкурентности. Но сервер — штука потенциально многопоточная, и даже на учебном HttpServer запросы могут приходить параллельно. Если бы мы использовали обычный long и делали nextId++, в теории можно было бы получить одинаковые id в гонке. AtomicLong даёт нам простой и надёжный «нумератор билетов», как в электронной очереди.
Минимальное использование выглядит так:
// Предположим, что nextId — это поле AtomicLong (как в репозитории выше).
long id1 = nextId.incrementAndGet(); // увеличили счётчик и получили новое значение
long id2 = nextId.incrementAndGet(); // ещё раз увеличили
System.out.println(id1); // 1
System.out.println(id2); // 2
Метод incrementAndGet делает ровно то, что написано на упаковке: увеличивает значение и возвращает новое. Мы стартуем с 0, чтобы первые значения были 1, 2, 3 — людям так проще читать, чем 0, 1, 2 (хотя компьютеру всё равно).
5. Стартовые данные для GET-эндпоинтов
Если репозиторий пустой, то list-endpoint (GET /reading-list) всё равно будет корректным: он вернёт items: [] и count: 0. Но с точки зрения обучения это скучно: вы не видите разницы между «всё работает» и «просто всегда пусто». Поэтому полезно заложить в репозиторий небольшие sample-данные прямо в конструкторе.
Тут важно не перепутать: мы не делаем полноценный механизм создания элементов через API (это другой уровень). Мы просто подготавливаем «стартовый набор», чтобы сегодня можно было проверить чтение списка и получение элемента по id и сразу увидеть осмысленные JSON-ответы.
Пример конструктора, который кладёт пару книг:
public InMemoryReadingListRepository() {
// Стартовые данные нужны только для демонстрации read-only эндпоинтов.
putSample("Clean Code", "Robert C. Martin", ReadingStatus.PLANNED, "OL12345M");
putSample("Effective Java", "Joshua Bloch", ReadingStatus.IN_PROGRESS, "OL67890M");
}
А вот маленький приватный помощник putSample. Он инкапсулирует логику генерации id и укладывания объекта в Map, чтобы конструктор оставался читаемым.
private void putSample(String title, String author, ReadingStatus status, String externalId) {
// Генерируем новый уникальный id внутри процесса.
long id = nextId.incrementAndGet();
// Создаём доменный объект (репозиторий не создаёт DTO и не формирует JSON).
ReadingListItem item = new ReadingListItem(id, title, author, status, externalId, null);
// Кладём запись в in-memory хранилище.
storage.put(id, item);
}
Обратите внимание на спокойный стиль: мы создаём доменный объект ReadingListItem и кладём его в storage. Репозиторий не сериализует его, не превращает в DTO, не добавляет «HTTP-ошибки». Он просто хранит.
6. findAll и findById: реализация
Теперь два ключевых метода. На первый взгляд кажется: «ну там же две строчки, что тут объяснять». А объяснять есть что, потому что именно в таких местах новички часто случайно делают себе “дырку” в инкапсуляции и начинают ловить странные баги, которые выглядят как магия.
Начнём с findAll(). Самое плохое, что можно сделать — вернуть наружу «живой вид» внутренней коллекции и дать внешнему коду возможность случайно поломать репозиторий. Поэтому мы возвращаем новый список, собранный из значений Map. Это дешёво, читаемо и достаточно безопасно.
import java.util.ArrayList;
import java.util.List;
@Override
public List<ReadingListItem> findAll() {
// Важно: возвращаем копию списка, чтобы внешний код не мог менять состав коллекции.
return new ArrayList<>(storage.values());
}
Здесь есть тонкость: мы копируем список, но не копируем сами объекты. Если ReadingListItem — изменяемый класс (а он часто изменяемый, потому что статус книги меняется), то кто-то снаружи может изменить объект и это отразится в репозитории, потому что ссылка та же. Для учебного проекта это допустимо, но держите в голове: «копия списка» защищает от изменения состава коллекции, а не от изменения внутренностей объектов.
Теперь findById(long id). Он вообще должен быть максимально тупым: взять и вернуть из Map по ключу. Если не найдено — Map.get вернёт null. Это нормально. Решение «null → 404» будет приниматься в handler-е, потому что это именно HTTP-решение.
@Override
public ReadingListItem findById(long id) {
// Map.get вернёт null, если такого ключа нет — это и есть контракт репозитория.
return storage.get(id);
}
И вот здесь хорошо видно, почему мы не хотим мешать репозиторий с HTTP. Репозиторий не обязан знать, что null станет 404. Для него null означает простую вещь: «на складе нет такого товара». А уже внешний слой решит, является ли это «ошибка клиента», «не найдено», «конфликт» или что-то ещё.
7. Репозиторий в слоях приложения
Сейчас мы добавили новый кусок системы, и важно не потерять общую картину. Когда приложение растёт, мозг начинает путаться: «а где у меня что? почему я это делаю здесь, а не там?». Поэтому полезно один раз зафиксировать маршрут данных сверху вниз и обратно.
Представьте, что приходит запрос «дай список reading list». Обработчик (handler) принимает HTTP, сервис (service) решает прикладную задачу «получить данные», репозиторий (repository) отдаёт доменные объекты, и дальше всё поднимается обратно: сервис маппит домен в response DTO, handler выбирает статус и пишет JSON.
Вот маленькая схема, которая держит эту модель в голове:
flowchart TD
Client[HTTP client / Postman] -->|GET /api/v1/reading-list| Handler[readinglist.http Handler]
Handler --> Service[readinglist.service ReadingListService]
Service --> Repo[readinglist.repository ReadingListRepository]
Repo --> Storage["Map<Long, ReadingListItem>"]
Storage --> Repo --> Service --> Handler --> Client
Самая важная мысль тут такая: репозиторий — это не «нижняя часть handler-а». Это отдельный слой, который можно понимать, тестировать и менять отдельно. Если вы удерживаете эту границу, дальше (когда появятся новые операции) проект будет расширяться предсказуемо. Если границы стереть — проект начнёт расширяться хаотично, как папка «Новая папка (17)».
8. Типичные ошибки при работе с in-memory репозиторием
Ошибка №1: хранить в репозитории response DTO вместо доменных объектов.
Это выглядит заманчиво: «зачем хранить ReadingListItem, если всё равно отдаём JSON?». Проблема в том, что response DTO — язык внешнего контракта, он живёт по правилам клиента и может меняться из-за требований API. Репозиторий же должен жить по правилам домена. Если смешать эти миры, вы получите слой хранения, который начинает зависеть от формы HTTP-ответа, и любое изменение контракта будет ломать внутреннюю часть приложения.
Ошибка №2: писать HTTP-статусы и JSON-логику в репозитории.
Репозиторий, который возвращает «404» или формирует ErrorResponse, быстро превращается в “всё-в-одном” класс. Это ломает обучающую модель слоёв: вы перестаёте понимать, где заканчивается хранение и начинается web-логика. Статус и JSON должны рождаться в handler-е, максимум с помощью общих утилит отправки ответа.
Ошибка №3: генерировать id где попало (обычно в handler-е).
Если генерация идентификатора разбросана по коду, вы гарантированно получите «неожиданные коллизии» и странные дубли. Даже в in-memory модели лучше держать генерацию в одном месте — рядом с хранилищем. Сегодня мы используем AtomicLong, чтобы id выдавались последовательно и предсказуемо, а код не превращался в охоту за тем, кто “последний увеличивал счётчик”.
Ошибка №4: отдавать наружу внутреннюю коллекцию или storage.values() без копии.
Если вернуть наружу живую коллекцию, внешний код может случайно её изменить. Даже если вы “точно не будете”, кто-нибудь другой (или вы через неделю) будет. Скопировать значения в новый ArrayList — дешёвый способ сохранить инкапсуляцию и не превращать репозиторий в общественную кухню, где каждый может переставить кастрюли.
Ошибка №5: ожидать, что in-memory данные сохранятся после перезапуска сервера.
Это самая честная ловушка: вы всё настроили, получили список, порадовались, перезапустили приложение — и «всё пропало». Тут не надо искать баг. Это нормальная цена in-memory модели. Мы специально выбрали её, чтобы фокусироваться на HTTP и структуре приложения. Устойчивое хранение — это отдельная большая тема, и она придёт позже, когда у вас будет крепкая основа.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ