Як структурувати велику програму на Express.js?
Структура Express.js додатку - розподіл коду по шарах папок, де кожен відповідає за одну задачу: маршрутизація, бізнес-логіка, доступ до даних, конфігурація.
Теорія
Коротко
- Аналогія з рестораном: routes приймають замовлення біля входу, controllers виконують базову роботу, services займаються складною логікою, models дістають дані зі сховища, middleware перевіряє пропуски на вході.
- Один
app.jsз усією логікою нормально для 5 маршрутів. На 50 ти витрачаєш більше часу на пошук коду, ніж на написання. - Основний поділ: routes тримають тільки URL-параметри, controllers відповідають за req/res, services містять бізнес-логіку, models спілкуються з БД.
- Більше 10 маршрутів або 5 моделей - вводь шари одразу.
Структура папок
src/
├── server.js # Точка входу: тільки app.listen()
├── app.js # Налаштування Express, middleware, кореневий router
├── config/
│ ├── index.js # Змінні середовища з валідацією
│ └── database.js # Підключення до БД
├── routes/
│ ├── index.js # Монтує всі sub-routers
│ ├── users.routes.js
│ └── products.routes.js
├── controllers/
│ ├── users.controller.js
│ └── products.controller.js
├── services/
│ ├── users.service.js # Бізнес-логіка
│ └── email.service.js
├── models/
│ ├── User.model.js
│ └── Product.model.js
├── middleware/
│ ├── auth.middleware.js
│ └── rateLimiter.middleware.js
├── validators/
│ └── users.validator.js
└── utils/
├── AppError.js
└── asyncHandler.jsapp.js і server.js: навіщо два файли
Це плутає людей першого разу. app.js налаштовує Express, реєструє middleware, монтує router-и та експортує об'єкт додатку. Він ніколи не викликає app.listen(). Це завдання server.js.
Причина - тестування. Імпортуєш app.js у supertest - можеш надсилати HTTP-запити без того, щоб займати реальний порт. Одна відповідальність на файл, жодних конфліктів.
// src/app.js
const express = require('express');
const routes = require('./routes');
const { notFound, errorHandler } = require('./middleware/error.middleware');
const app = express();
app.use(express.json({ limit: '10kb' }));
app.use('/api/v1', routes);
app.use(notFound);
app.use(errorHandler); // завжди останній
module.exports = app;
// src/server.js
const app = require('./app');
const { connectDB } = require('./config/database');
async function start() {
await connectDB();
app.listen(process.env.PORT || 3000, () =>
console.log(`Сервер на порту ${process.env.PORT || 3000}`)
);
}
start().catch(console.error);GET /api/v1/users резолвиться без проблем. Тести імпортують app.js і ніколи не чіпають server.js.
Як запит проходить через шари
Express зіставляє шляхи за допомогою trie (radix tree), тому GET /api/v1/users/123 резолвиться за O(k) де k - кількість сегментів шляху. Далі запит іде передбачуваним маршрутом: router вибирає обробник, controller читає req.params і викликає сервіс, сервіс виконує бізнес-логіку і звертається до моделі, відповідь іде назад.
Помилки рухаються у зворотному напрямку. Вони піднімаються через middleware-стек у зворотному порядку. Тому error-handling middleware стоїть останнім у app.js.
| Шар | Відповідальність |
|---|---|
| Routes | Зіставлення URL з обробниками, per-router middleware |
| Controllers | Обробка req/res, делегування до сервісів |
| Services | Бізнес-логіка, виклики зовнішніх API |
| Models | Схеми БД та запити (Mongoose, Prisma) |
| Middleware | Auth, rate limiting, обробка помилок |
| Config | Змінні середовища з валідацією при запуску |
Controllers займаються HTTP-специфічними речами: статус-коди, JSON-серіалізація, читання заголовків. Services нічого про HTTP не знають. Саме це робить userService.findById() придатним для виклику з REST-ендпоінту, WebSocket-обробника і фонового job-а без жодних змін.
Коли додавати шари
До 10 маршрутів, один розробник: один app.js цілком підходить. Структура додає складність навігації раніше, ніж є що навігувати.
Від 10 до 50 маршрутів, невелика команда: додай routes, controllers і models. Services можна пропустити, якщо логіка не виходить за 30 рядків на функцію в контролері.
Понад 50 маршрутів, команда зростає: повний стек. Services, validators і routes/index.js який монтує все разом. Інакше app.js перетворюється на 500-рядковий список імпортів.
Поділ на мікросервіси пізніше: папки за bounded context (billing-domain/routes, user-domain/services), спільні утиліти в core/. MedusaJS використовує саме таку структуру для своєї e-commerce платформи на тисячах production-магазинів.
Типові помилки
1. Бізнес-логіка в routes:
// неправильно: логіка email потрапляє в route
router.post('/users', async (req, res) => {
const user = await User.create(req.body);
await sendEmail(req.body.email); // тут не місце
res.status(201).json(user);
});
// правильно: route - один рядок
router.post('/users', validateCreateUser, createUser);Routes швидко розпухають до 100+ рядків. Їх неможливо юніт-тестувати без мокування повного HTTP-запиту.
2. Глобальний auth middleware:
// неправильно: блокує /login, /health і всі публічні маршрути
app.use(authenticate);
// правильно: захищай тільки потрібні маршрути
usersRouter.use(authenticate);Це найпоширеніша причина скарг типу "отримую 401 на health check" в Express-форумах. Завжди ця помилка.
3. Відсутній index-файл у routes:
// неправильно: app.js імпортує 20 файлів напряму
app.use('/users', require('./routes/users.routes'));
// ... ще 18 рядків
// правильно: routes/index.js монтує все
app.use('/api/v1', require('./routes'));4. Прямі виклики моделей у controllers:
// неправильно: модель у контролері, немає перевикористання
exports.getUser = asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user);
});
// правильно: контролер викликає сервіс, сервіс викликає модель
const user = await usersService.findById(req.params.id);Коли cron job потребує того самого запиту, ти копіюєш User.findById у скрипт. Тепер маєш два місця для оновлення при зміні схеми.
5. Конфіг прямо в app.js:
// неправильно: падає без попередження при відсутній змінній
app.listen(process.env.PORT || 3000);
// правильно: валідуй при запуску, падай голосно до деплою
const config = require('./config'); // кидає помилку якщо PORT відсутній у prod
app.listen(config.port);Де застосовується
- NestJS: той самий підхід через декоратори
@Controllerі@Injectable, під капотом - Express. Більше 100k зірок на GitHub. - MedusaJS:
src/api/routes,src/services,src/models, тисячі production-магазинів. - LoopBack (IBM):
controllersіrepositoriesяк концепти першого класу у фреймворку. - FeathersJS: services - центральний примітив, REST і WebSocket обробники генеруються з них автоматично.
Питання на співбесіді
Q: Навіщо відокремлювати services від controllers якщо логіка проста?
A: Controllers мають HTTP-специфічні знання: статус-коди, форматування відповіді, читання заголовків. Services цього не знають. Коли додаєш Kafka-consumer або CLI-скрипт пізніше, сервіс вже готовий до перевикористання без жодних змін.
Q: Як уникати циклічних залежностей між сервісами?
A: Передавай сервіси як аргументи фабричних функцій замість того, щоб require-ити їх на верхньому рівні файлу. Команда madge --circular src/ перед merge-ом знаходить цикли до того, як вони спричинять undefined у рантаймі.
Q: Як версіонувати маршрути у великому додатку?
A: Монтуй паралельні роутери: app.use('/api/v1', v1Routes) і app.use('/api/v2', v2Routes). Клієнти мігрують у своєму темпі. Feature flags дозволяють A/B-тестувати поведінку v2 до повного переходу.
Q: Яка стратегія тестування по шарах?
A: Юніт-тести мокають сервіс всередині controller-тестів. Інтеграційні тести використовують supertest з реальною тестовою БД. Contract-тести через Pact перевіряють що клієнт і сервер погоджуються на форму API між деплоями.
Q (Senior): У монорепо на 100 сервісів як domain boundaries впливають на структуру папок?
A: Кожен bounded context отримує власну підпапку: billing-domain/routes, user-domain/services. Спільний код живе в core/. Межі між доменами контролюються через правила eslint-plugin-import, які забороняють прямі cross-domain імпорти. Це запобігає "великій кулі бруду" (big ball of mud), яку утворюють великі монолітні додатки коли команди пропускають цей крок.
Приклади
Базовий: тонкий маршрут, контролер, сервіс
// routes/users.routes.js
const router = require('express').Router();
const { getUser } = require('../controllers/users.controller');
const { authenticate } = require('../middleware/auth.middleware');
router.get('/:id', authenticate, getUser); // тільки зіставлення URL
module.exports = router;
// controllers/users.controller.js
const asyncHandler = require('../utils/asyncHandler');
const usersService = require('../services/users.service');
const AppError = require('../utils/AppError');
exports.getUser = asyncHandler(async (req, res) => {
const user = await usersService.findById(req.params.id);
if (!user) throw new AppError('User not found', 404);
res.json({ success: true, data: user });
});
// services/users.service.js
const User = require('../models/User.model');
exports.findById = async (id) => User.findById(id);GET /api/v1/users/123 повертає { success: true, data: { name: 'Alice' } }. Сервіс нічого не знає про HTTP. Виклик usersService.findById доступний з будь-якого місця кодової бази.
Середній: створення продукту з валідацією і сповіщеннями
// routes/products.routes.js
router.post('/', authenticate, validateCreateProduct, createProduct);
// controllers/products.controller.js
const asyncHandler = require('../utils/asyncHandler');
const productsService = require('../services/products.service');
const emailService = require('../services/email.service');
exports.createProduct = asyncHandler(async (req, res) => {
const product = await productsService.create({
...req.body,
tenantId: req.user.tenantId, // ізоляція тенанта з auth middleware
});
await emailService.notifyTeam({ productId: product.id });
res.status(201).json({ success: true, data: product });
});
// services/products.service.js
const Product = require('../models/Product.model');
exports.create = async (data) => {
const product = new Product(data);
await product.save();
return product.populate('category'); // eager load зв'язку
};Валідатор запускається до контролера. Tenant ID приходить з auth middleware. Виклик email - окремий сервіс, не вбудований код. Кожна частина тестується незалежно.
Просунутий: транзакційне замовлення з відокремленим оновленням інвентарю
// services/orders.service.js
const mongoose = require('mongoose');
const Order = require('../models/Order.model');
const eventEmitter = require('../utils/eventEmitter');
exports.createOrder = async (orderData) => {
const session = await mongoose.startSession();
session.startTransaction();
try {
const [order] = await Order.create([orderData], { session });
eventEmitter.emit('order.created', { id: order.id }); // відокремлено
await session.commitTransaction();
return order;
} catch (err) {
await session.abortTransaction();
throw err;
} finally {
session.endSession();
}
};
// listeners/inventory.listener.js - реєструється один раз при запуску
const eventEmitter = require('../utils/eventEmitter');
const inventoryService = require('../services/inventory.service');
eventEmitter.on('order.created', async ({ id }) => {
await inventoryService.reserveStock(id); // асинхронно, не блокує відповідь
});Замовлення зберігається атомарно всередині транзакції. Резервування інвентарю відбувається після commit без блокування відповіді. Цей підхід використовується в e-commerce бекендах що обробляють тисячі транзакцій на секунду.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.