Пошук уроків, статей та іншого контенту
Винесете доступ до даних у репозиторії та відокремите бізнес-логіку від конкретної технології зберігання.
Repository — це об’єкт, який приховує доступ до сховища даних і надає застосунку зрозумілий набір операцій:
знайти сутність;
зберегти її;
оновити або видалити;
отримати список сутностей.
Repository відокремлює бізнес-логіку від конкретної технології зберігання. Бізнес-логіка не повинна знати:
чи використовуємо ми PostgreSQL, MongoDB або файлову систему;
як виглядає SQL-запит;
як формується шлях до файлу;
як перетворити результат драйвера бази даних на об’єкт застосунку.
Замість цього сервіс працює з абстракцією репозиторію:
const order = await orderRepository.findById(orderId);
await orderRepository.save(order);Без репозиторію бізнес-логіка часто змішується з кодом доступу до даних:
async function payOrder(orderId, database) {
const result = await database.query(
'SELECT * FROM orders WHERE id = $1',
[orderId]
);
if (result.rows.length === 0) {
throw new Error('Замовлення не знайдено');
}
const order = result.rows[0];
if (order.status === 'paid') {
throw new Error('Замовлення вже оплачене');
}
await database.query(
'UPDATE orders SET status = $1 WHERE id = $2',
['paid', orderId]
);
}У цьому прикладі функція одночасно:
виконує SQL-запит;
знає структуру таблиці;
перевіряє бізнес-правила;
змінює стан замовлення.
Якщо замінити PostgreSQL на іншу технологію, доведеться змінювати цю функцію. Repository дозволяє розділити відповідальності.
Типова структура може виглядати так:
Контролер
↓
Сервіс із бізнес-логікою
↓
Repository
↓
Конкретне сховищеПриймає HTTP-запит і передає дані сервісу. Він не повинен містити складні правила бізнесу.
Містить бізнес-логіку:
перевіряє стан сутності;
вирішує, чи дозволена операція;
змінює сутність;
викликає методи репозиторію.
Відповідає за збереження та отримання даних. Він приховує деталі конкретної технології.
У JavaScript немає обов’язкових інтерфейсів, як у TypeScript або Java. Тому контракт репозиторію зазвичай описують угодою.
Наприклад, OrderRepository може мати такі методи:
findById(id) → Order | null
save(order) → OrderСервіс знає лише про ці методи. Йому не важливо, як вони реалізовані.
Репозиторій може зберігати об’єкти в:
базі даних;
пам’яті процесу;
JSON-файлі;
зовнішньому API.
Головне, щоб різні реалізації дотримувалися одного контракту.
Розглянемо правило:
замовлення можна оплатити лише один раз;
не можна оплатити замовлення, якого не існує;
після оплати статус замовлення змінюється на paid.
Бізнес-логіка буде в OrderService, а доступ до даних — у OrderRepository.
У прикладі є дві реалізації одного репозиторію:
InMemoryOrderRepository зберігає дані в Map;
JsonFileOrderRepository зберігає дані у JSON-файлі.
Сервіс працює з обома реалізаціями однаково.
Збережіть код у файл repository-demo.js і запустіть:
node repository-demo.jsconst fs = require('node:fs/promises');
const crypto = require('node:crypto');
class InMemoryOrderRepository {
constructor() {
this.orders = new Map();
}
async findById(id) {
return this.orders.get(id) ?? null;
}
async save(order) {
this.orders.set(order.id, { ...order });
return { ...order };
}
}
class JsonFileOrderRepository {
constructor(filePath) {
this.filePath = filePath;
}
async findById(id) {
const orders = await this.readOrders();
return orders.find((order) => order.id === id) ?? null;
}
async save(order) {
const orders = await this.readOrders();
const index = orders.findIndex((item) => item.id === order.id);
if (index === -1) {
orders.push({ ...order });
} else {
orders[index] = { ...order };
}
await fs.writeFile(
this.filePath,
JSON.stringify(orders, null, 2),
'utf8'
);
return { ...order };
}
async readOrders() {
try {
const content = await fs.readFile(this.filePath, 'utf8');
return JSON.parse(content);
} catch (error) {
if (error.code === 'ENOENT') {
return [];
}
throw error;
}
}
}
class OrderService {
constructor(orderRepository) {
this.orderRepository = orderRepository;
}
async createOrder(customer, total) {
if (!customer || total <= 0) {
throw new Error('Некоректні дані замовлення');
}
const order = {
id: crypto.randomUUID(),
customer,
total,
status: 'pending'
};
return this.orderRepository.save(order);
}
async payOrder(orderId) {
const order = await this.orderRepository.findById(orderId);
if (!order) {
throw new Error('Замовлення не знайдено');
}
if (order.status === 'paid') {
throw new Error('Замовлення вже оплачене');
}
const paidOrder = {
...order,
status: 'paid'
};
return this.orderRepository.save(paidOrder);
}
}
async function run() {
const memoryRepository = new InMemoryOrderRepository();
const memoryService = new OrderService(memoryRepository);
const memoryOrder = await memoryService.createOrder('Олена', 1500);
console.log('Створено в памʼяті:', memoryOrder);
const paidMemoryOrder = await memoryService.payOrder(memoryOrder.id);
console.log('Оплачено в памʼяті:', paidMemoryOrder);
const fileRepository = new JsonFileOrderRepository('./orders.json');
const fileService = new OrderService(fileRepository);
const fileOrder = await fileService.createOrder('Андрій', 2300);
console.log('Створено у файлі:', fileOrder);
const paidFileOrder = await fileService.payOrder(fileOrder.id);
console.log('Оплачено у файлі:', paidFileOrder);
}
run().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});class InMemoryOrderRepository {
constructor() {
this.orders = new Map();
}
async findById(id) {
return this.orders.get(id) ?? null;
}
async save(order) {
this.orders.set(order.id, { ...order });
return { ...order };
}
}Ця реалізація використовує Map. Вона зручна:
для локальної розробки;
для автоматизованих тестів;
для демонстрацій;
коли постійне сховище не потрібне.
Дані зникнуть після завершення процесу Node.js.
JsonFileOrderRepository має той самий публічний контракт:
findById(id)
save(order)Але всередині він читає та записує JSON-файл. OrderService не знає про цей факт.
Саме це і є перевагою Repository: конкретний спосіб зберігання ізольований в одному класі.
class OrderService {
constructor(orderRepository) {
this.orderRepository = orderRepository;
}
}Репозиторій передається в конструктор. Такий підхід називається впровадженням залежності або dependency injection.
Сервіс не створює репозиторій самостійно:
// Невдалий підхід
class OrderService {
constructor() {
this.orderRepository = new JsonFileOrderRepository('./orders.json');
}
}Якщо сервіс сам створює залежність, його складніше:
тестувати;
використовувати з іншим сховищем;
налаштовувати для різних середовищ.
Натомість залежність передається ззовні:
const repository = new InMemoryOrderRepository();
const service = new OrderService(repository);або:
const repository = new JsonFileOrderRepository('./orders.json');
const service = new OrderService(repository);Репозиторій відповідає за збереження та отримання даних. Він не повинен вирішувати, чи можна виконати бізнес-операцію.
Наприклад, перевірка статусу замовлення належить сервісу:
if (order.status === 'paid') {
throw new Error('Замовлення вже оплачене');
}Репозиторій лише зберігає результат:
await this.orderRepository.save({
...order,
status: 'paid'
});Якщо помістити бізнес-правила в репозиторій, його буде складніше повторно використовувати. Один і той самий репозиторій може застосовуватися в різних сценаріях, а правила операцій мають залишатися в сервісному шарі.
Небажано, щоб сервіс отримував об’єкти, специфічні для драйвера бази даних:
const result = await database.query(...);
const row = result.rows[0];У такому випадку бізнес-логіка залежить від формату відповіді конкретного драйвера.
Краще, щоб репозиторій повертав звичайну модель застосунку:
{
id: 'order-id',
customer: 'Олена',
total: 1500,
status: 'pending'
}Тоді сервіс працює з даними незалежно від того, звідки вони надійшли.
Одна з головних переваг Repository — можливість тестувати бізнес-логіку без справжньої бази даних.
const repository = new InMemoryOrderRepository();
const service = new OrderService(repository);
const order = await service.createOrder('Олена', 1000);
const paidOrder = await service.payOrder(order.id);
console.log(paidOrder.status); // paidДля перевірки помилки можна використати try...catch:
try {
await service.payOrder(order.id);
} catch (error) {
console.log(error.message);
}У тестах така реалізація працює швидше і не потребує:
запуску сервера бази даних;
створення таблиць;
підготовки тестової бази;
очищення реальних даних.
При цьому тестується саме бізнес-логіка сервісу, а не робота конкретного драйвера сховища.
Для невеликого Node.js-проєкту код можна розділити так:
src/
repositories/
in-memory-order-repository.js
json-file-order-repository.js
services/
order-service.js
models/
order.js
app.jsЗазвичай:
repositories містить реалізації доступу до даних;
services містить бізнес-операції;
app.js створює потрібні залежності та з’єднує компоненти.
Наприклад, у точці запуску можна вибрати реалізацію:
const repository = process.env.NODE_ENV === 'test'
? new InMemoryOrderRepository()
: new JsonFileOrderRepository('./orders.json');
const orderService = new OrderService(repository);Сервіс при цьому не змінюється.
Патерн доречний, коли:
застосунок має нетривіалу бізнес-логіку;
доступ до даних використовується в кількох сервісах;
потрібно замінити технологію зберігання;
потрібні ізольовані тести бізнес-логіки;
код доступу до бази даних починає дублюватися;
потрібно приховати складні запити за зрозумілими методами.
Для дуже маленького скрипту з одним простим запитом окремий репозиторій може бути зайвим. Його варто вводити тоді, коли він справді зменшує зв’язність і спрощує структуру коду.
Репозиторій не повинен вирішувати, чи дозволена операція. Його завдання — отримати або зберегти дані.
Якщо сервіс безпосередньо викликає SQL-запити або методи драйвера, Repository не виконує своєї ролі.
Це ускладнює заміну реалізації та тестування. Краще передавати репозиторій через конструктор.
Якщо один репозиторій має findById, а інший — getOrder, сервіс не зможе працювати з ними взаємозамінно. Реалізації повинні дотримуватися однакових назв методів, параметрів і формату результату.
Сервіс не повинен знати про rows, курсори, документи драйвера або інші внутрішні структури. Репозиторій має перетворювати їх на модель, з якою працює застосунок.
Не потрібно створювати лише методи на кшталт find, save і delete, якщо це приховує зміст операції. Методи репозиторію мають бути зрозумілими для предметної області, наприклад:
findById(id)
findPendingOrders()
save(order)Repository інкапсулює доступ до даних.
Сервіс містить бізнес-логіку, але не знає деталей сховища.
Різні реалізації репозиторію повинні мати однаковий контракт.
Репозиторій можна реалізувати для бази даних, файлу або пам’яті.
Впровадження залежності дозволяє легко замінювати реалізацію.
In-memory-репозиторій зручний для тестування сервісів.
Бізнес-правила потрібно залишати в сервісному шарі, а технічні деталі зберігання — у репозиторії.