1. Роль репозиторію в API лише для читання
Коли ви чуєте слово «репозиторій», мозок іноді автоматично домальовує PostgreSQL, міграції, індекси й DBA, який суворо дивиться на вас через монітор. Але сьогодні ми значно скромніші: нам потрібне місце, де взагалі є дані. Навіть якщо API «лише читає», йому все одно треба звідкись читати, інакше вийде API рівня «GET /reading-list повертає натхнення».
Якщо зберігати дані прямо в обробнику (handler), наприклад у вигляді List<ReadingListItem>, дуже швидко виникне класичне бекенд-болото: маршрутизація, парсинг запиту, бізнес-логіка і зберігання опиняться в одній купі. У цей момент ви вже починаєте писати «міні-фреймворк» в одному класі, хоча клялися собі цього не робити.
Репозиторій у нашій архітектурі — це шар, який відповідає за просту річ: зберігати й віддавати доменні об’єкти. Він не знає нічого про HTTP, не обирає статус-коди, не серіалізує JSON і не повинен намагатися «бути розумнішим, ніж треба». Він просто «комірник»: у нього є склад (памʼять процесу) і картотека (ключ id), а на запит він уміє показати, що лежить на полицях.
Обмеження in-memory сховища
In-memory сховище — це коли дані живуть у памʼяті процесу Java-застосунку. Тобто всередині вашої запущеної JVM, поруч із вашими об’єктами, потоками та безсонними ночами. Ви перезапустили застосунок — і сховище обнулилося. Це не баг, а чесна властивість: ніякої стійкості між перезапусками ми сьогодні не обіцяємо.
Чому це нормально саме в цьому курсі? Тому що наша мета — навчитися HTTP-ланцюжку та шарам застосунку, не заходячи в сусідній курс про SQL/JDBC/ORM. Якби ми зараз додали базу даних, то 80% уваги пішло б у налаштування підключення, схеми, міграції та тонкощі зберігання. А в нас сьогодні мета простіша: нехай сервер уміє повертати список і один елемент за id, а код при цьому залишається зрозумілим.
Корисно прямо зафіксувати відмінності в маленькій таблиці — не заради академізму, а щоб мозок не обманював сам себе:
| Питання | In-memory репозиторій (Map) | База даних |
|---|---|---|
| Дані переживають перезапуск? | Ні | Так |
| Потрібні драйвери/SQL/міграції? | Ні | Так |
| Швидкість доступу за id | Дуже висока (у памʼяті) | Залежить від індексів, мережі та навантаження |
| Можна зберігати мільйони записів? | Технічно — можна, але швидко стане погано | Так, це якраз її робота |
| Мета в курсі | Зрозуміти шари та HTTP-семантику | Це окрема дисципліна |
І ще важливий нюанс: in-memory репозиторій — це майже завжди «навчальне або прототипне» рішення. У реальному проєкті він може використовуватися як тимчасовий стаб, як кеш або як тестова заглушка, але не як «фінальна історія зберігання». Однак для перехідного курсу це ідеальний компроміс: мінімум інфраструктури, максимум фокусу на API.
Контракт репозиторію: говоримо мовою домену, а не HTTP
Дуже хочеться, особливо після тижня HTTP, зробити метод на кшталт findByIdOrThrow404 або findAllAsJson — і начебто навіть «працює». Але це пастка. Репозиторій — не частина вебшару, а частина внутрішньої логіки застосунку. Його мова — це домен: ReadingListItem, ReadingStatus, ідентифікатор long. Його результат — це доменні об’єкти або відсутність доменного об’єкта.
Це розділення важливе не через «красу», а через контроль складності. Щойно репозиторій почне знати про HTTP, ви отримаєте клас, який одночасно і зберігає, і вирішує, який статус повернути, і формує JSON, і пише в лог. Такий клас неможливо нормально змінювати: будь-яка зміна зачіпатиме всі шари одразу.
Ще одна причина тримати репозиторій «глухим до HTTP» — майбутня замінюваність. Сьогодні в нас Map. Завтра, на наступному етапі вашої траєкторії, може з’явитися база даних. Якщо репозиторій від самого початку не змішував логіку зберігання з HTTP, то замінити InMemory... на «Database...Repository» буде в рази простіше: контракт залишиться, реалізація зміниться.
2. Інтерфейс ReadingListRepository
Сьогоднішній рівень присвячений частині 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 в обробнику. Головне — щоб 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 — частина відповідальності шару зберігання. Не обробник генерує id, не сервіс, а саме репозиторій у нашому навчальному спрощенні знає, як видавати унікальні ідентифікатори всередині процесу.
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-ендпоінтів
Якщо репозиторій порожній, то ендпоінт списку (GET /reading-list) усе одно буде коректним: він поверне items: [] і count: 0. Але з погляду навчання це нудно: ви не бачите різниці між «усе працює» і «просто завжди порожньо». Тому корисно закласти в репозиторій невеликі початкові дані прямо в конструкторі.
Тут важливо не переплутати: ми не робимо повноцінний механізм створення елементів через API (це інший рівень). Ми просто готуємо «стартовий набір», щоб сьогодні можна було перевірити читання списку та отримання елемента за id і одразу побачити осмислені JSON-відповіді.
Приклад конструктора, який додає пару книжок:
public InMemoryReadingListRepository() {
// Початкові дані потрібні лише для демонстрації ендпоінтів лише для читання.
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» ухвалюватиме обробник, бо це саме 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, обробник обирає статус і пише JSON.
Ось маленька схема, яка тримає цю модель у голові:
flowchart TD
Client[Клієнт HTTP / Postman] -->|GET /api/v1/reading-list| Handler[обробник readinglist.http]
Handler --> Service[сервіс readinglist.service ReadingListService]
Service --> Repo[репозиторій readinglist.repository ReadingListRepository]
Repo --> Storage["Map<Long, ReadingListItem>"]
Storage --> Repo --> Service --> Handler --> Client
Найважливіша думка тут така: репозиторій — це не «нижня частина обробника». Це окремий шар, який можна розуміти, тестувати й змінювати окремо. Якщо ви утримуєте цю межу, далі, коли з’являться нові операції, проєкт буде розширюватися передбачувано. Якщо межі стерти — проєкт почне розширюватися хаотично, як папка «Нова папка (17)».
8. Типові помилки під час роботи з in-memory репозиторієм
Помилка № 1: зберігати в репозиторії response DTO замість доменних об’єктів.
Це виглядає спокусливо: «навіщо зберігати ReadingListItem, якщо все одно віддаємо JSON?». Проблема в тому, що response DTO — мова зовнішнього контракту, вона живе за правилами клієнта й може змінюватися через вимоги API. Репозиторій же повинен жити за правилами домену. Якщо змішати ці світи, ви отримаєте шар зберігання, який починає залежати від форми HTTP-відповіді, і будь-яка зміна контракту буде ламати внутрішню частину застосунку.
Помилка № 2: писати HTTP-статуси й JSON-логіку в репозиторії.
Репозиторій, який повертає «404» або формує ErrorResponse, швидко перетворюється на клас «все-в-одному». Це ламає навчальну модель шарів: ви перестаєте розуміти, де закінчується зберігання і починається веблогіка. Статус і JSON мають народжуватися в обробнику, максимум за допомогою спільних утиліт для відправлення відповіді.
Помилка № 3: генерувати id де завгодно (зазвичай в обробнику).
Якщо генерація ідентифікатора розкидана по коду, ви гарантовано отримаєте «неочікувані колізії» та дивні дублікати. Навіть у in-memory моделі краще тримати генерацію в одному місці — поруч зі сховищем. Сьогодні ми використовуємо AtomicLong, щоб id видавалися послідовно й передбачувано, а код не перетворювався на полювання за тим, хто «останній збільшував лічильник».
Помилка № 4: віддавати назовні внутрішню колекцію або storage.values() без копії.
Якщо повернути назовні живу колекцію, зовнішній код може випадково її змінити. Навіть якщо ви «точно не будете», хтось інший, або ви через тиждень, буде. Скопіювати значення в новий ArrayList — дешевий спосіб зберегти інкапсуляцію й не перетворювати репозиторій на громадську кухню, де кожен може переставити каструлі.
Помилка № 5: очікувати, що in-memory дані збережуться після перезапуску сервера.
Це найчесніша пастка: ви все налаштували, отримали список, пораділи, перезапустили застосунок — і «все зникло». Тут не треба шукати баг. Це нормальна ціна in-memory моделі. Ми спеціально обрали її, щоб зосередитися на HTTP і структурі застосунку. Стійке зберігання — це окрема велика тема, і вона прийде пізніше, коли у вас буде міцна основа.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ