JavaRush /Курси /Spring REST & MVC /In-memory репозиторії та відповідальність

In-memory репозиторії та відповідальність

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

1. Контролер і список: швидкий шлях до проблем

Коли проєкт маленький, рука сама тягнеться зробити щось на кшталт: «А чому б мені не покласти List<Task> прямо в контролер?» І, якщо чесно, перші 10 хвилин це навіть виглядає геніально: один клас, усе під рукою, нічого не треба пояснювати. Проблема в тому, що такий код ламає межі шарів уже з самого початку, і потім ви розплачуватиметеся за це на кожному наступному кроці — навіть тоді, коли додасте другу кінцеву точку або інший ресурс.

Поганий приклад (так не робимо, але дивитися можна — як на фотографію з «дитинства», де ви їли пісок):

import java.util.ArrayList;
import java.util.List;

import org.springframework.web.bind.annotation.RestController;

@RestController
public class TaskController {
    // ПОГАНО: контролер починає керувати даними.
    // Це змішує веб-шар і зберігання та призводить до "fat controller".
    private final List<Task> tasks = new ArrayList<>();
}

Ззовні це виглядає невинно, але всередині ховаються три проблеми. По-перше, контролер перетворюється на «і веб, і зберігання даних, і бізнес-логіка» — а це прямий шлях до fat controller. По-друге, вам буде складно повторно використати зберігання в іншому місці, наприклад у сервісі коментарів, тому що воно сховане в контролері. По-третє, ви перестаєте розуміти, де в проєкті живуть дані і хто за них відповідає.

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

2. Роль репозиторію та межі відповідальності

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

Дуже корисно тримати в голові просту таблицю, яка розділяє ролі:

Шар Що він «знає» Чого він «не знає»
Контролер HTTP, @PathVariable, @RequestBody, статуси відповіді Як і де зберігаємо задачі
Сервіс Прикладні операції («створити задачу», «знайти задачу») HTTP, ResponseEntity, заголовки
Репозиторій Колекції, ключі, «зберегти/прочитати» HTTP-статуси, URI, @RequestParam

Якщо репозиторій починає вирішувати, що повертати клієнту, ви отримаєте «репозиторій, який знає про 404», і потім раптом у вас почнуть зʼявлятися «репозиторії, які вміють робити JSON». Звучить як жарт, але зазвичай усе саме так і починається.

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

3. Вибір структури: Map для id

Коли ми зберігаємо сутності за ідентифікатором, нам майже завжди потрібні дві речі: швидко знаходити за id і водночас уміти віддати список усіх елементів. Якщо взяти List<Task>, то «віддати все» легко, а «знайти за id» перетворюється на перебір. На маленьких даних перебір не страшний, але він робить код менш прозорим: ви в кожному місці змушені писати цикли, порівняння, if, а це розмиває відповідальність у проєкті.

Ось як зазвичай виглядає пошук за id у списку (код робочий, але він починає розмножуватися, як кролі):

public Task findById(List<Task> tasks, String taskId) {
    // Лінійний перебір: чим більше задач, тим довший пошук.
    for (Task task : tasks) {
        // Перевіряємо збіг id.
        if (task.getId().equals(taskId)) {
            return task; // Знайшли — виходимо одразу.
        }
    }
    // Не знайшли — повертаємо null (але це рішення потім треба обробляти вище за рівнем).
    return null;
}

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

Map<String, Task> розвʼязує задачу природно: id — це ключ, задача — це значення.

Мінітаблиця для вибору колекції — просто щоб у мозку була опора:

Колекція Пошук за id Порядок видачі Коментар
ArrayList повільніше (перебір) порядок вставки зручно як контейнер, але не як індекс
HashMap швидко порядок «як вийде» для наших відповідей це може бути незручно
LinkedHashMap швидко стабільний порядок вставки чудовий навчальний варіант

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

4. Контракт і InMemoryTaskRepository

Зараз ми зберемо репозиторій так, щоб він був максимально нудним і корисним. Почнемо з інтерфейсу. Важливо, щоб інтерфейс був частиною «домену» — тобто контракту проєкту, — а реалізація була в інфраструктурі. Тоді сервіс залежить від контракту, а не від конкретного класу.

Приклад інтерфейсу (пакет можна вибрати відповідно до вашої структури; часто зручно тримати його в domain, щоб infrastructure залежала від domain, а не навпаки):

package com.example.tasktracker.domain.repository;

import java.util.List;

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

// Контракт репозиторію: лише зберігання та отримання, без HTTP і без бізнес-правил.
public interface TaskRepository {
    // Повернути всі задачі (зазвичай — копією або незмінною колекцією; це вирішує реалізація).
    List<Task> findAll();

    // Знайти за id. У in-memory реалізації може повернутися null, якщо задачі немає.
    Task findById(String taskId);

    // Зберегти задачу за id (створення/оновлення вирішується на рівні вище).
    Task save(Task task);
}

Тепер реалізуємо in-memory репозиторій в інфраструктурному пакеті. Тут важливі два моменти: по-перше, репозиторій — це Spring bean, тому позначаємо його @Repository. По-друге, він зберігає колекцію всередині себе і не віддає її назовні як є.

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

import java.util.LinkedHashMap;
import java.util.Map;

import org.springframework.stereotype.Repository;

import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.repository.TaskRepository;

@Repository
public class InMemoryTaskRepository implements TaskRepository {
    // LinkedHashMap дає стабільний порядок вставки (зручно для передбачуваних відповідей і тестів).
    private final Map<String, Task> tasks = new LinkedHashMap<>();
}

Далі — методи. Почнемо з findAll. Важлива звичка: повертайте копію, а не «живу» view-колекцію, щоб зовнішній код не міг випадково змінити внутрішній стан репозиторію.

import java.util.List;

@Override
public List<Task> findAll() {
    // Не віддаємо назовні "живу" колекцію з Map — робимо незмінну копію.
    return List.copyOf(tasks.values());
}

findById в in-memory-варіанті — це просто get за ключем. Так, він поверне null, якщо задачі немає. І це нормально для поточного шару: репозиторій не зобовʼязаний вирішувати, що таке not found для API.

@Override
public Task findById(String taskId) {
    // get поверне null, якщо ключа немає — це нормально для рівня Repository.
    return tasks.get(taskId);
}

save — це put. Тут є тонкий момент: put і збереже, і перезапише, якщо ключ уже був. І це добре: репозиторій не вирішує, «це create чи update». Він просто зберігає за id.

import java.util.Objects;

@Override
public Task save(Task task) {
    // Мінімальний захист від дивних викликів: краще впасти тут, ніж ловити NPE "десь потім".
    Objects.requireNonNull(task, "task не має бути null");

    // put зберігає або перезаписує за ключем id — репозиторій не розрізняє create/update.
    tasks.put(task.getId(), task);

    return task;
}

Зверніть увагу: ми додали requireNonNull — це не «валідація API», а мінімальний захист від зовсім уже дивних викликів усередині застосунку. Якщо сервіс передав null, краще впасти одразу й чесно, ніж отримати загадковий NullPointerException десь усередині Map.

5. Захисні копії та небезпека tasks.values()

Новачки часто роблять репозиторій, який «повертає список задач». І повертають tasks.values() або навіть сам tasks. Це майже завжди помилка не через «поганих людей навколо», а через звичайну неуважність: зовнішній код може випадково почати змінювати колекцію, і репозиторій втратить контроль над своїм станом. А репозиторій без контролю над станом — це вже не репозиторій, а «кімната без дверей».

Ось приклад того, як можна випадково вистрілити собі в ногу, якщо повернути «живу» колекцію:

import java.util.Collection;

@Override
public Collection<Task> findAll() {
    // ПОГАНО: values() — це view над Map, операції з нею можуть змінювати саму Map.
    return tasks.values();
}

Чому це погано? Тому що tasks.values() — це не «новий список», а представлення (view) над Map. У деяких випадках операції над цим view можуть змінити саму Map. Наприклад, якщо хтось зробить findAll().clear(), він може очистити репозиторій цілком. Звісно, сервіс так робити не повинен… але сервіс пишете теж ви, у пʼятницю ввечері, після двох годин дебагу. І ваше майбутнє «ви» може бути трохи небезпечнішим за теперішнє.

Правильний варіант — повертати копію:

import java.util.ArrayList;
import java.util.List;

@Override
public List<Task> findAll() {
    // Копія: зовнішній код може змінювати список, але внутрішня Map залишиться в безпеці.
    return new ArrayList<>(tasks.values());
}

А ще краще — незмінну копію, щоб навіть випадковий list.add(...) не пройшов:

import java.util.List;

@Override
public List<Task> findAll() {
    // Незмінна копія: не можна ні add, ні clear — добра базова гігієна.
    return List.copyOf(tasks.values());
}

Тут є чесне обмеження: навіть якщо список незмінний, елементи (Task) можуть бути мутабельними. Тобто хтось може зробити task.setTitle("..."). Але це вже інша тема — мутабельність моделі. Список ми захистили; внутрішній контейнер репозиторію — теж.

6. In-memory: перезапуск, singleton, конкурентність

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

По-перше, InMemoryTaskRepository — це bean. За замовчуванням Spring створює його як singleton. Це означає, що в процесі роботи застосунку існує один екземпляр репозиторію і одна Map всередині нього. Тому різні HTTP-запити бачать один і той самий стан. Це саме те, що нам потрібно для навчального API.

По-друге, під час перезапуску застосунку дані зникають. Це не помилка. Це буквально те, що означає «in-memory». Якщо ви очікуєте, що після перезапуску задачі залишаться, значить ви подумки підʼєднали базу даних, якої в нас немає. Іноді це викликає легкий дискомфорт, але методично це корисно: ви не плутаєте шар API зі шаром зберігання.

По-третє, є конкурентність. Spring MVC обробляє запити паралельно, а наш LinkedHashMap не є потокобезпечним. У production ви б думали про синхронізацію, ConcurrentHashMap, транзакції тощо. У навчальному проєкті ми цього не робимо, тому що це швидко відведе нас в окремий курс із багатопотоковості та узгодженості даних. Але корисно хоча б знати, що це не вічний рецепт, а свідоме навчальне спрощення.

Репозиторій у сервісі й контролері

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

Нехай сервіс працює через інтерфейс репозиторію. Приклад звичайної сервісної операції, де сервіс просто делегує:

import java.util.List;

import org.springframework.stereotype.Service;

@Service
public class DefaultTaskService implements TaskService {
    private final TaskRepository taskRepository;

    public DefaultTaskService(TaskRepository taskRepository) {
        // Впроваджуємо саме інтерфейс (контракт), а не конкретну реалізацію.
        this.taskRepository = taskRepository;
    }

    @Override
    public List<Task> getAll() {
        // Сервіс вирішує, яку операцію виконати, репозиторій — як дістати дані.
        return taskRepository.findAll();
    }
}

І контролер — просто викликає сервіс:

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TaskController {
    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        // Контролер залежить від сервісу, а не від репозиторію.
        this.taskService = taskService;
    }

    @GetMapping("/api/v1/tasks")
    public List<Task> getAll() {
        // Контролер займається обробленням HTTP-запитів, а не тим, де і як зберігаються задачі.
        return taskService.getAll();
    }
}

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

7. Типові помилки під час написання in-memory репозиторію

Помилка № 1: зберігати колекцію в контролері або в сервісі «тому що простіше».
Це здається простішим лише перші пів години. Потім контролер починає розростатися логікою пошуку, оновлення, видалення та «оброблення not found». Навіть якщо ви не пишете обробку помилок просто зараз, ви все одно почнете писати if і for у контролері, а отже — змішувати веб-шар і зберігання. Репозиторій потрібен, щоб контролер і сервіс не тягнули в собі контейнери та алгоритми доступу до них.

Помилка № 2: повертати назовні Map або tasks.values() і випадково втратити інкапсуляцію.
Найпідступніша версія цієї помилки — коли все «працює», доки хтось не зробить у сервісі «невинний» clear() або не почне модифікувати колекцію. Репозиторій перетворюється на спільний глобальний обʼєкт, який можна змінювати звідки завгодно. Повертайте копії та тримайте контейнер приватним — це не параноя, а елементарна гігієна.

Помилка № 3: використовувати HashMap і дивуватися, чому список задач то такий, то сякий.
HashMap не зобовʼязана повертати елементи у стабільному порядку. Тому ви можете отримати ситуацію: додали дві задачі, а список повертається то «A, B», то «B, A». Для клієнта API це незручно, а вам заважає тестувати й порівнювати відповіді на око. Для навчального проєкту LinkedHashMap — чудовий вибір, тому що порядок вставки стає передбачуваним.

Помилка № 4: намагатися зробити репозиторій «розумним», додаючи туди правила предметної області.
Наприклад, перевіряти всередині save, що «не можна зберігати задачу у статусі ARCHIVED» або що title не порожній. Це виглядає логічно, але це бізнес-правила, і їм місце в сервісі. Репозиторій має залишатися про зберігання. Інакше ви отримаєте проєкт, де правила розповзаються: половина в сервісі, половина в репозиторії, і ніхто не знає, де правда.

Помилка № 5: не розуміти, що in-memory зберігання живе рівно до перезапуску застосунку.
Це не помилка в коді, але це часта помилка в очікуваннях. Якщо ви перевіряєте API, перезапускаєте застосунок і бачите порожній список — це нормально. In-memory репозиторій не зобовʼязаний «памʼятати» дані. Він зобовʼязаний давати нам стійку архітектуру веб/API-шару без бази даних, а не замінювати зберігання.

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