Skip to main content

Які найкращі патерни інтеграції бази даних в Express.js?

Патерн інтеграції бази даних - архітектурне рішення про те, де і як твій 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 скасовує перший. Баланси залишаються консистентними. Без транзакції збій між двома запитами залишив би рахунок відправника списаним, а рахунок отримувача - без змін.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?