Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як структурувати велику програму на Express.js?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Структура Express.js додатку** - розподіл коду на routes, controllers, services, models і middleware так, щоб кожен шар відповідав за одну задачу. ```js // route викликає controller, controller викликає service, service викликає model router.get('/:id', authenticate, getUser); // route: тільки URL exports.getUser = asyncHandler(async (req, res) => { // controller: HTTP const user = await usersService.findById(req.params.id); res.json({ data: user }); }); exports.findById = async (id) => User.findById(id); // service: логіка ``` **Головне:** `app.js` (без listen) окремо від `server.js` (тільки listen) - тести імпортують додаток без запуску сервера.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Структура 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) | | 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:** ```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 бекендах що обробляють тисячі транзакцій на секунду.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.