Які найкращі патерни інтеграції бази даних в Express.js?
Патерн інтеграції бази даних - архітектурне рішення про те, де і як твій Express.js-застосунок спілкується з базою даних. Від цього рішення залежить, наскільки легко тестувати код, міняти запити і знаходити баги через пів року.
Теорія
TL;DR
- Прямий запит: SQL прямо в обробниках маршрутів. Пишеться за хвилини, ламається при масштабуванні.
- Repository Pattern: SQL в окремих класах з ін'єкцією pool. Тестовано, перевикористовується, правильний вибір для продакшену.
- ORM (Prisma, Sequelize, TypeORM): без сирого SQL, schema-first підхід. Типобезпечно, але менше контролю над запитами.
- Service Layer: шар над репозиторіями для бізнес-логіки. Додавай, коли маршрути починають робити занадто багато.
- Правило вибору: Repository для більшості застосунків. Прямий запит тільки для одноразових скриптів. ORM коли схема складна і важливі зв'язки між сутностями.
Швидкий приклад
// ❌ Прямий запит - 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 з'єднання
// ❌ Пул зменшується з кожним запитом до цього маршруту
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 запити в циклах
// ❌ 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-ін'єкція через конкатенацію рядків
// ❌ Зловмисник передає 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: Відсутність таймауту для повільних запитів
// ❌ 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: Відсутність перевірки з'єднання під час старту
// ❌ Застосунок стартує, перший запит падає з 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
// 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 з бізнес-логікою
// 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);
}
});Маршрут викликає сервіс і відправляє відповідь. Більше нічого. Всі рішення - у сервісі. Ось і вся межа відповідальності.
Просунутий: Транзакція для переказу коштів
// Обидва 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 скасовує перший. Баланси залишаються консистентними. Без транзакції збій між двома запитами залишив би рахунок відправника списаним, а рахунок отримувача - без змін.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.