Skip to main content

Як структурувати велику програму на 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.js

app.js і server.js: навіщо два файли

Це плутає людей першого разу. app.js налаштовує Express, реєструє middleware, монтує router-и та експортує об'єкт додатку. Він ніколи не викликає app.listen(). Це завдання server.js.

Причина - тестування. Імпортуєш app.js у supertest - можеш надсилати HTTP-запити без того, щоб займати реальний порт. Одна відповідальність на файл, жодних конфліктів.

js
// 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)
MiddlewareAuth, 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:

js
// неправильно: логіка 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:

js
// неправильно: блокує /login, /health і всі публічні маршрути app.use(authenticate); // правильно: захищай тільки потрібні маршрути usersRouter.use(authenticate);

Це найпоширеніша причина скарг типу "отримую 401 на health check" в Express-форумах. Завжди ця помилка.

3. Відсутній index-файл у routes:

js
// неправильно: app.js імпортує 20 файлів напряму app.use('/users', require('./routes/users.routes')); // ... ще 18 рядків // правильно: routes/index.js монтує все app.use('/api/v1', require('./routes'));

4. Прямі виклики моделей у controllers:

js
// неправильно: модель у контролері, немає перевикористання 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:

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), яку утворюють великі монолітні додатки коли команди пропускають цей крок.

Приклади

Базовий: тонкий маршрут, контролер, сервіс

js
// 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 доступний з будь-якого місця кодової бази.

Середній: створення продукту з валідацією і сповіщеннями

js
// 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 - окремий сервіс, не вбудований код. Кожна частина тестується незалежно.

Просунутий: транзакційне замовлення з відокремленим оновленням інвентарю

js
// 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 бекендах що обробляють тисячі транзакцій на секунду.

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

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

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

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