Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Які найкращі патерни інтеграції бази даних в Express.js?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Патерни інтеграції бази даних в Express.js** визначають, де живе SQL і як застосунок спілкується з базою даних. ```js class UserRepository { constructor(pool) { this.pool = pool; } async findAll() { return (await this.pool.query('SELECT * FROM users')).rows; } } ``` **Головне:** Repository Pattern - правильний вибір для продакшену. SQL ізолюється в окремих класах, pool передається через конструктор, що дозволяє тестувати код без реальної бази. Прямий запит підходить тільки для прототипів.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Патерн інтеграції бази даних** - архітектурне рішення про те, де і як твій Express.js-застосунок спілкується з базою даних. Від цього рішення залежить, наскільки легко тестувати код, міняти запити і знаходити баги через пів року. ## Теорія ### TL;DR - **Прямий запит**: SQL прямо в обробниках маршрутів. Пишеться за хвилини, ламається при масштабуванні. - **Repository Pattern**: SQL в окремих класах з ін'єкцією pool. Тестовано, перевикористовується, правильний вибір для продакшену. - **ORM (Prisma, Sequelize, TypeORM)**: без сирого SQL, schema-first підхід. Типобезпечно, але менше контролю над запитами. - **Service Layer**: шар над репозиторіями для бізнес-логіки. Додавай, коли маршрути починають робити занадто багато. - Правило вибору: Repository для більшості застосунків. Прямий запит тільки для одноразових скриптів. ORM коли схема складна і важливі зв'язки між сутностями. ### Швидкий приклад ```js // ❌ Прямий запит - SQL у маршруті, важко тестувати app.get('/users', async (req, res) => { const result = await pool.query('SELECT * FROM users'); res.json(result.rows); // Output: [{ id: 1, name: 'Alice' }] }); // ✅ Repository Pattern - SQL у класі, можна підмінити в тестах class UserRepository { constructor(pool) { this.pool = pool; } async getAll() { const { rows } = await this.pool.query('SELECT * FROM users'); return rows; // Output: [{ id: 1, name: 'Alice' }] } } app.get('/users', async (req, res) => { const users = await userRepo.getAll(); res.json(users); // той самий результат, але тепер mockable }); ``` Repository не змінює те, що іде в базу. Він змінює те, що можна зробити в тестах. ### Чому вибір патерну важливий при масштабуванні З прямим запитом кожен обробник маршруту робить дві речі: обробляє HTTP і звертається до бази. При 5 маршрутах це нормально. При 50 той самий SQL повторюється в десятках місць. Зміна назви стовпця перетворюється на пошук по всьому проєкту. Repository Pattern переносить весь SQL для однієї сутності в один клас. Зміна запиту в одному місці - і всі виклики отримують виправлення. Але головне інше: підміни реальний pool на mock у конструкторі, і юніт-тести ніколи не торкаються справжньої бази. Ось у чому справжня цінність патерну. На практиці більшість команд обирають підхід на першому тижні розробки і вже не повертаються до цього питання. Вибирай свідомо. ### Коли використовувати кожен патерн - **Прямий запит**: proof-of-concept, вивчення Express, одноразові скрипти - **Repository**: будь-який продакшн-застосунок, команда від 2 осіб, код з юніт-тестами - **ORM (Prisma)**: складна схема з багатьма зв'язками, потрібні типобезпечні запити - **ORM (Sequelize)**: legacy Node.js-проєкти, існуючі схеми зі складними асоціаціями - **Query Builder (Knex.js)**: потрібен контроль над SQL і міграції в коді - **Service + Repository**: бізнес-логіка виходить за рамки простого CRUD (перевірки, листи, зовнішні API) ### Таблиця порівняння | Патерн | Налаштування | Контроль SQL | Тестованість | Підходить для | |--------|-------------|--------------|--------------|---------------| | Прямий запит | Хвилини | Повний | Погана | Прототипи | | Repository | Години | Повний | Відмінна | Більшість застосунків | | Query Builder (Knex) | Години | Високий | Добра | Складні запити + міграції | | ORM (Prisma) | Години | Низький | Добра | Швидка розробка, типи | | ORM (Sequelize) | Дні | Низький | Добра | Legacy, складні зв'язки | | Service + Repository | Години | Повний | Найкраща | Застосунки з бізнес-логікою | ### Як працює пул з'єднань Коли викликається `pool.query()`, Node.js передає запит до драйвера бази даних (pg, mysql2). Драйвер бере вільне з'єднання з пулу, відправляє запит асинхронно і повертає результат. Обробник маршруту чекає, не блокуючи інші запити. Пул має жорсткий ліміт. При `max: 20` двадцять перший запит чекає, поки з'явиться вільне з'єднання. Якщо за `connectionTimeoutMillis` воно не з'явилось - помилка. Саме тому витік з'єднань вбиває продакшн: кожен пропущений `client.release()` зменшує доступний пул. Repository Pattern нічого тут не змінює. Він просто обгортає `pool.query()` в методи, які можна замінити під час тестування. ### Типові помилки **Помилка 1: Забутий release з'єднання** ```js // ❌ Пул зменшується з кожним запитом до цього маршруту app.get('/users', async (req, res) => { const client = await pool.connect(); const result = await client.query('SELECT * FROM users'); res.json(result.rows); // client.release() не викликається - витік з'єднання }); // ✅ finally виконується навіть якщо запит кидає помилку app.get('/users', async (req, res) => { const client = await pool.connect(); try { const result = await client.query('SELECT * FROM users'); res.json(result.rows); } finally { client.release(); } }); ``` **Помилка 2: N+1 запити в циклах** ```js // ❌ 1 запит для юзерів + 1 на кожного = 101 запит const users = await pool.query('SELECT * FROM users LIMIT 100'); for (let user of users.rows) { user.posts = await pool.query( 'SELECT * FROM posts WHERE user_id = $1', [user.id] ); } // ✅ Один JOIN замінює всі ці запити const result = await pool.query(` SELECT u.*, json_agg(p.*) as posts FROM users u LEFT JOIN posts p ON u.id = p.user_id GROUP BY u.id `); // 1 запит, повний результат ``` **Помилка 3: SQL-ін'єкція через конкатенацію рядків** ```js // ❌ Зловмисник передає name = "'; DROP TABLE users; --" const query = `SELECT * FROM users WHERE name = '${req.query.name}'`; await pool.query(query); // ✅ Параметризований запит - драйвер сам екранує вхідні дані await pool.query( 'SELECT * FROM users WHERE name = $1', [req.query.name] ); ``` **Помилка 4: Відсутність таймауту для повільних запитів** ```js // ❌ 100 паралельних запитів на повільний SQL, пул вичерпується на 20-му app.get('/report', async (req, res) => { const result = await pool.query('SELECT * FROM huge_table JOIN another_table ...'); res.json(result.rows); }); // ✅ statement_timeout не дозволяє запиту висіти нескінченно app.get('/report', async (req, res) => { const client = await pool.connect(); try { await client.query('SET statement_timeout = 5000'); // максимум 5 секунд const result = await client.query('SELECT * FROM huge_table JOIN another_table ...'); res.json(result.rows); } catch (err) { if (err.message.includes('statement timeout')) { res.status(408).json({ error: 'Запит виконувався надто довго' }); } else { res.status(500).json({ error: err.message }); } } finally { client.release(); } }); ``` **Помилка 5: Відсутність перевірки з'єднання під час старту** ```js // ❌ Застосунок стартує, перший запит падає з ECONNREFUSED якщо БД недоступна const pool = new Pool({ connectionString: process.env.DATABASE_URL }); app.listen(3000); // ✅ Fail fast - не приймай трафік якщо БД недоступна const pool = new Pool({ connectionString: process.env.DATABASE_URL }); pool.query('SELECT 1').then(() => { app.listen(3000, () => console.log('Сервер і БД підключені')); }).catch(err => { console.error('Помилка підключення до БД:', err); process.exit(1); }); ``` ### Де використовується у продакшені - **Express + pg**: прямі запити або Repository Pattern. Використовується у Stripe і Airbnb. - **Express + Prisma**: типобезпечний автогенерований клієнт, популярний у стартапах. Figma використовує внутрішньо. - **Express + Sequelize**: legacy Node.js-застосунки і корпоративні системи зі складними асоціаціями. - **Express + Knex.js**: query builder з міграціями, основа для Bookshelf.js і Objection.js. - **Express + TypeORM**: TypeScript-орієнтований ORM з декораторами. Стандарт у NestJS. ### Питання на співбесіді **Q:** Чому Repository Pattern робить тестування простішим, якщо він просто обгортає `pool.query()`? **A:** Бо pool передається через конструктор. У тестах підставляєш mock: `{ query: jest.fn().mockResolvedValue({ rows: [...] }) }`. Без репозиторію кожен тест маршруту потребує або реальної бази, або складного патчингу модуля на рівні require. **Q:** Що таке проблема N+1 і чому вона виникає з ORM? **A:** N+1 - це коли ти завантажуєш N батьківських записів і робиш 1 запит на кожен, щоб отримати дочірні. З ORM це легко пропустити: `user.posts` виглядає як звернення до масиву, але за фактом це окремий запит. Вирішення - eager loading: `include: { posts: true }` у Prisma, `include: [Post]` у Sequelize. **Q:** Коли додавати Service Layer поверх репозиторіїв? **A:** Коли маршрути починають містити рішення. Перевірка дублікату email, відправка листа після реєстрації, виклик webhook - нічому з цього не місце ні в маршруті, ні в репозиторії. Service Layer тримає багатокрокові операції. Додавай, коли одна й та сама логіка з'являється більш ніж в одному маршруті. **Q:** Як реалізувати транзакції в Repository Pattern? **A:** Передавай client (не pool) у методи репозиторію або прийми транзакцію як необов'язковий параметр. Викликач отримує клієнт, запускає BEGIN, викликає методи з цим клієнтом, потім COMMIT або ROLLBACK у finally-блоці. Деякі команди виносять обгортку для транзакцій у базовий клас репозиторію. **Q:** (Senior) Як масштабувати читання на кілька реплік бази даних? **A:** Створюй окремі пули для читання і запису. SELECT - на репліки, INSERT/UPDATE/DELETE - на primary. Складна частина - затримка реплікації (replication lag): юзер записує дані і одразу читає, але отримує застарілі дані з репліки. Варіанти: спрямовуй читання цього юзера на primary протягом короткого часу, використовуй read-after-write перевірки, або прийми eventual consistency. У node-postgres це два окремі екземпляри Pool. Prisma підтримує це через конфігурацію реплік. ## Приклади ### Базовий: Repository з dependency injection ```js // repositories/user.repository.js class UserRepository { constructor(pool) { this.pool = pool; } async findById(id) { const { rows } = await this.pool.query( 'SELECT * FROM users WHERE id = $1', [id] ); return rows[0]; // Output: { id: 1, name: 'Alice', email: 'alice@example.com' } } async create(userData) { const { rows } = await this.pool.query( 'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *', [userData.name, userData.email] ); return rows[0]; // Output: { id: 42, name: 'Bob', email: 'bob@example.com' } } } // У тестовому файлі - без реальної БД const mockPool = { query: jest.fn().mockResolvedValue({ rows: [{ id: 1, name: 'Test' }] }) }; const repo = new UserRepository(mockPool); await repo.findById(1); expect(mockPool.query).toHaveBeenCalledWith( 'SELECT * FROM users WHERE id = $1', [1] ); // тест проходить - перевіряємо точний SQL ``` Саме ін'єкція через конструктор робить цей код тестованим. Репозиторій не знає, чи отримав він реальний pool чи mock. Ця байдужість і є механізм. ### Середній: Service Layer з бізнес-логікою ```js // services/user.service.js class UserService { constructor(userRepo, emailService) { this.userRepo = userRepo; this.emailService = emailService; } async createUser(data) { const existing = await this.userRepo.findByEmail(data.email); if (existing) { const err = new Error('Email already exists'); err.status = 409; throw err; } const user = await this.userRepo.create(data); await this.emailService.sendWelcome(user.email); // side effect тут, не в маршруті return user; } } // routes/users.js app.post('/api/users', async (req, res, next) => { try { const user = await userService.createUser(req.body); res.status(201).json(user); } catch (err) { next(err); } }); ``` Маршрут викликає сервіс і відправляє відповідь. Більше нічого. Всі рішення - у сервісі. Ось і вся межа відповідальності. ### Просунутий: Транзакція для переказу коштів ```js // Обидва UPDATE мають або спрацювати разом, або обидва скасуватись async function transferMoney(fromId, toId, amount) { const client = await pool.connect(); try { await client.query('BEGIN'); await client.query( 'UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, fromId] ); await client.query( 'UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, toId] ); await client.query('COMMIT'); } catch (err) { await client.query('ROLLBACK'); throw err; // перекидаємо, щоб викликач знав про помилку } finally { client.release(); // завжди, без винятків } } ``` Якщо другий UPDATE кидає помилку, ROLLBACK скасовує перший. Баланси залишаються консистентними. Без транзакції збій між двома запитами залишив би рахунок відправника списаним, а рахунок отримувача - без змін.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.