Пошук уроків, статей та іншого контенту
Спроєктуємо структуру backend-проєкту з модулями, конфігурацією, залежностями та зрозумілими межами відповідальності.
Структура backend-проєкту визначає:
де розміщувати бізнес-правила;
які модулі можуть залежати один від одного;
як замінити базу даних або HTTP-фреймворк;
де створюються залежності застосунку;
як ізолювати конфігурацію та інфраструктурний код;
наскільки легко тестувати окремі частини системи.
У невеликому проєкті можна розмістити весь код у кількох файлах. Проте зі зростанням системи така організація швидко призводить до проблем:
контролери починають містити бізнес-логіку;
моделі напряму працюють із базою даних;
конфігурація читається в різних місцях;
модулі імпортують один одного циклічно;
заміна інструмента вимагає змін у всій системі.
Мета хорошої структури — не створити якомога більше папок, а чітко розділити відповідальності.
Backend-проєкт доцільно поділити на такі рівні:
domain — бізнес-сутності та правила;
application — сценарії використання системи;
interfaces — HTTP, CLI, message queues та інші точки входу;
config — централізована конфігурація;
main — складання застосунку та запуск процесу.
Один із можливих варіантів структури:
project/
├── package.json
├── package-lock.json
├── .env.example
├── src/
│ ├── main.js
│ ├── config/
│ │ └── index.js
│ ├── domain/
│ │ └── user/
│ │ ├── user.js
│ │ └── user-errors.js
│ ├── application/
│ │ └── users/
│ │ ├── create-user.js
│ │ └── get-user.js
│ ├── infrastructure/
│ │ └── users/
│ │ ├── in-memory-user-repository.js
│ │ └── postgres-user-repository.js
│ └── interfaces/
│ └── http/
│ ├── create-server.js
│ └── routes.js
└── test/
├── unit/
└── integration/Назви папок можуть відрізнятися. Важливіше за конкретні назви — напрямок залежностей і межі відповідальності.
Залежності мають рухатися від зовнішніх шарів до внутрішніх:
interfaces → application → domain
infrastructure → application або domain
main → усі необхідні модулі для складання
config → модулі, яким потрібна конфігураціяНаприклад:
HTTP-обробник може викликати сценарій застосунку;
сценарій застосунку може працювати з репозиторієм;
доменна сутність не повинна імпортувати HTTP-модуль;
доменна логіка не повинна знати про PostgreSQL;
main.js може знати про конкретну реалізацію репозиторію та передати її сценарію.
Це дозволяє замінити реалізацію без зміни коду, який не повинен про неї знати.
Проблемна залежність виглядає так:
domain/user.js → postgres-client.jsУ такому разі доменна сутність залежить від конкретної бази даних. Її складно тестувати незалежно, а перенесення логіки в інший контекст стає дорожчим.
Кращий варіант:
application/create-user.js → UserRepository
infrastructure/postgres-user-repository.js → реалізує UserRepository
main.js → передає реалізацію в applicationУ JavaScript інтерфейс репозиторію часто не описується окремим ключовим словом. Його роль виконує контракт, за яким об’єкт повинен мати певні методи.
Домен містить правила, які є важливими незалежно від способу доступу до системи.
Наприклад, користувач може мати правило:
ім’я не може бути порожнім;
email має бути коректного формату;
email зберігається у нормалізованому вигляді.
Ці правила не повинні перебувати в HTTP-контролері, оскільки користувача можна створити не лише через HTTP.
// src/domain/user/user.js
export class User {
constructor({ id, email, name }) {
if (!email || !email.includes("@")) {
throw new Error("Некоректний email");
}
if (!name || name.trim().length < 2) {
throw new Error("Ім'я має містити щонайменше 2 символи");
}
this.id = id;
this.email = email.trim().toLowerCase();
this.name = name.trim();
}
}Доменний модуль не імпортує http, fs, клієнт бази даних або змінні оточення.
Application-рівень описує операції, які може виконувати система.
Сценарій CreateUser:
отримує вхідні дані;
створює доменну сутність;
передає її репозиторію;
повертає результат.
Він не повинен знати, чи зберігаються дані в пам’яті, PostgreSQL або зовнішньому API.
// src/application/users/create-user.js
import { User } from "../../domain/user/user.js";
export function createUser({ userRepository }) {
if (!userRepository || typeof userRepository.save !== "function") {
throw new TypeError("Потрібен репозиторій користувачів");
}
return async function execute(input) {
const user = new User({
id: undefined,
email: input.email,
name: input.name,
});
return userRepository.save(user);
};
}Функція createUser є фабрикою сценарію. Вона отримує залежності один раз, а потім повертає функцію, яку можна викликати для кожного запиту.
Такий підхід називають dependency injection — залежності передаються ззовні, а не створюються всередині модуля.
Інфраструктура містить конкретні технологічні реалізації:
клієнти баз даних;
файлові сховища;
зовнішні HTTP-клієнти;
черги повідомлень;
кеші;
логери.
Приклад репозиторію в пам’яті:
// src/infrastructure/users/in-memory-user-repository.js
import { randomUUID } from "node:crypto";
export function createInMemoryUserRepository() {
const users = new Map();
return {
async save(user) {
const savedUser = {
id: user.id ?? randomUUID(),
email: user.email,
name: user.name,
};
users.set(savedUser.id, savedUser);
return savedUser;
},
async findById(id) {
return users.get(id) ?? null;
},
};
}Репозиторій має контракт, потрібний application-рівню:
{
save(user): Promise<User>,
findById(id): Promise<User | null>
}Application-код не повинен залежати від того, що всередині використовується Map.
Composition root — це єдине місце, де створюються та з’єднуються конкретні залежності.
У Node.js-проєкті ним зазвичай є main.js.
Саме тут можна:
завантажити конфігурацію;
створити репозиторій;
створити application-сценарії;
створити HTTP-сервер;
запустити процес.
main.js
├── config
├── userRepository
├── createUser
└── httpServerІнші модулі не повинні самостійно створювати глобальні підключення або імпортувати конкретні реалізації без потреби.
Змінні оточення потрібно читати в одному модулі, а не безпосередньо в кожному сервісі.
Переваги:
конфігурація має єдину структуру;
значення можна перевірити під час запуску;
помилки виявляються одразу;
інші модулі не залежать від назв змінних оточення.
// src/config/index.js
function parsePort(value) {
const port = Number.parseInt(value, 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("PORT має бути цілим числом від 1 до 65535");
}
return port;
}
export function loadConfig(env = process.env) {
return Object.freeze({
nodeEnv: env.NODE_ENV ?? "development",
port: parsePort(env.PORT ?? "3000"),
});
}Модулі отримують готовий об’єкт конфігурації:
const config = loadConfig();
console.log(config.port);Але вони не повинні самостійно робити таке:
const port = Number(process.env.PORT);у десятках різних файлів.
Конфігурацію необхідно перевіряти на старті, якщо відсутнє значення робить запуск небезпечним.
Наприклад, для production-сервісу такими значеннями можуть бути:
адреса бази даних;
секрет для підпису токенів;
ключ зовнішнього API.
Значення за замовчуванням доречні лише тоді, коли вони безпечні для конкретного середовища. Не слід додавати реальні паролі або секрети до репозиторію.
Файл .env.example може документувати очікувані змінні:
NODE_ENV=development
PORT=3000
DATABASE_URL=HTTP-рівень відповідає за транспорт:
розбір URL;
читання тіла запиту;
перевірку базового формату HTTP-вхідних даних;
виклик application-сценарію;
перетворення результату на HTTP-відповідь;
мапінг помилок на статус-коди.
HTTP-обробник не повинен реалізовувати правила домену.
Повний мінімальний приклад структури можна запустити без сторонніх пакетів.
package.json{
"name": "structured-node-backend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/main.js"
}
}// src/application/users/create-user.js
import { User } from "../../domain/user/user.js";
export function createUser({ userRepository }) {
return async function execute(input) {
const user = new User({
email: input.email,
name: input.name,
});
return userRepository.save(user);
};
}// src/domain/user/user.js
export class User {
constructor({ id, email, name }) {
if (!email || !email.includes("@")) {
throw new Error("Некоректний email");
}
if (!name || name.trim().length < 2) {
throw new Error("Ім'я має містити щонайменше 2 символи");
}
this.id = id;
this.email = email.trim().toLowerCase();
this.name = name.trim();
}
}// src/infrastructure/users/in-memory-user-repository.js
import { randomUUID } from "node:crypto";
export function createInMemoryUserRepository() {
const users = new Map();
return {
async save(user) {
const savedUser = {
id: user.id ?? randomUUID(),
email: user.email,
name: user.name,
};
users.set(savedUser.id, savedUser);
return savedUser;
},
};
}// src/interfaces/http/create-server.js
import { createServer as createHttpServer } from "node:http";
function sendJson(response, statusCode, body) {
response.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
});
response.end(JSON.stringify(body));
}
async function readJsonBody(request) {
let body = "";
for await (const chunk of request) {
body += chunk;
if (body.length > 1_000_000) {
throw new Error("Тіло запиту надто велике");
}
}
if (!body) {
return {};
}
return JSON.parse(body);
}
export function createServer({ config, createUser }) {
return createHttpServer((request, response) => {
void handleRequest(request, response, { createUser }).catch((error) => {
console.error(error);
sendJson(response, 500, { error: "Внутрішня помилка сервера" });
});
});
async function handleRequest(request, response, dependencies) {
const requestUrl = new URL(
request.url ?? "/",
`http://${request.headers.host ?? `localhost:${config.port}`}`,
);
if (request.method === "GET" && requestUrl.pathname === "/health") {
sendJson(response, 200, { status: "ok" });
return;
}
if (request.method === "POST" && requestUrl.pathname === "/users") {
let input;
try {
input = await readJsonBody(request);
} catch {
sendJson(response, 400, { error: "Некоректний JSON" });
return;
}
try {
const user = await dependencies.createUser(input);
sendJson(response, 201, user);
} catch (error) {
sendJson(response, 400, {
error: error instanceof Error ? error.message : "Некоректні дані",
});
}
return;
}
sendJson(response, 404, { error: "Маршрут не знайдено" });
}
}// src/main.js
import { loadConfig } from "./config/index.js";
import { createUser } from "./application/users/create-user.js";
import { createInMemoryUserRepository } from "./infrastructure/users/in-memory-user-repository.js";
import { createServer } from "./interfaces/http/create-server.js";
const config = loadConfig();
const userRepository = createInMemoryUserRepository();
const createUserUseCase = createUser({
userRepository,
});
const server = createServer({
config,
createUser: createUserUseCase,
});
server.listen(config.port, () => {
console.log(`Сервер запущено на порту ${config.port}`);
});Запуск:
npm startПеревірка health endpoint:
curl http://localhost:3000/healthСтворення користувача:
curl -X POST http://localhost:3000/users \
-H "content-type: application/json" \
-d '{"email":"olena@example.com","name":"Olena"}'У цьому прикладі HTTP-модуль не знає, як зберігаються користувачі. Сценарій не знає про HTTP. Доменна сутність не знає ні про HTTP, ні про репозиторій.
У великих системах лише поділу за технічними шарами може бути недостатньо. Якщо всі сутності зберігати в одній глобальній папці domain, модулі різних бізнес-областей можуть змішуватися.
Зручнішим є поділ за функціональними модулями:
src/
├── modules/
│ ├── users/
│ │ ├── domain/
│ │ ├── application/
│ │ ├── infrastructure/
│ │ └── interfaces/
│ ├── orders/
│ │ ├── domain/
│ │ ├── application/
│ │ ├── infrastructure/
│ │ └── interfaces/
│ └── billing/
│ ├── domain/
│ ├── application/
│ ├── infrastructure/
│ └── interfaces/
├── config/
└── main.jsТакий підхід називають модульною структурою або структурою за функціональністю.
Він добре підходить, коли:
у системі є кілька незалежних бізнес-областей;
кожна область має власні сценарії та правила;
команді потрібно працювати над модулями паралельно;
модулі можуть мати різний життєвий цикл.
Модуль повинен мати:
публічний API;
внутрішні файли, які не використовуються напряму з інших модулів;
власні правила та application-сценарії;
чіткі залежності від інших модулів.
Наприклад, замість імпорту внутрішнього файлу:
import { User } from "../users/domain/user.js";краще мати публічний модульний API:
import { findUserById } from "../users/index.js";Файл index.js стає точкою входу модуля та приховує його внутрішню структуру.
Залежності потрібно додавати до того шару, який ними користується.
Наприклад:
HTTP-фреймворк належить до interfaces;
драйвер бази даних — до infrastructure;
бібліотека валідації може належати до application або interfaces залежно від її ролі;
інструменти тестування та форматування — до devDependencies.
У package.json не слід додавати бібліотеки «про всяк випадок». Кожна залежність збільшує:
розмір проєкту;
поверхню для вразливостей;
складність оновлень;
кількість зовнішніх контрактів, які потрібно підтримувати.
Файл package-lock.json потрібно зберігати в репозиторії. Він фіксує конкретні версії транзитивних залежностей і робить встановлення відтворюваним.
Для CI та production-збірок зазвичай використовують:
npm ciЦя команда встановлює залежності відповідно до lock-файлу.
Циклічна залежність виникає, коли:
A → B → AНаприклад:
users-service.js → orders-service.js → users-service.jsЦикли ускладнюють:
порядок ініціалізації модулів;
тестування;
розуміння відповідальності;
подальше розділення модулів.
Способи усунення циклів:
Перенести спільне правило в нижчий незалежний модуль.
Передавати залежність через параметри, а не імпортувати її напряму.
Створити application-сценарій, який координує обидва модулі.
Розділити дві відповідальності, якщо модулі надто тісно пов’язані.
Наприклад, замість того щоб users напряму викликав orders, оркестрацію можна розмістити в окремому сценарії:
application/
└── register-user-and-create-order.jsЦей сценарій отримує обидва сервіси як залежності.
Внутрішня структура модуля не повинна ставати частиною контракту інших частин системи.
Публічний API може виглядати так:
// src/modules/users/index.js
export { createUser } from "./application/create-user.js";
export { createUserRepository } from "./infrastructure/user-repository.js";Інші модулі імпортують лише те, що явно експортується:
import { createUser } from "../modules/users/index.js";Якщо пізніше файли всередині users буде переміщено, зовнішній код не доведеться змінювати.
Не потрібно експортувати всі внутрішні утиліти. Експортуйте лише ті функції, які справді є частиною контракту модуля.
Для кожного модуля корисно відповісти на запитання:
Яку бізнес-проблему вирішує цей модуль?
Які дані він має право змінювати?
Які операції він надає іншим модулям?
Які технології він приховує?
Чи можна протестувати його без HTTP і бази даних?
Що станеться, якщо замінити його зовнішню залежність?
Ознаки надто великого модуля:
у нього багато непов’язаних сценаріїв;
зміна однієї функції вимагає змін у різних частинах;
модуль імпортують майже всі інші модулі;
він містить і HTTP, і SQL, і бізнес-правила;
його складно описати одним коротким реченням.
Ознаки надто дрібного поділу:
для простої операції потрібно пройти через багато файлів;
модулі не мають самостійної відповідальності;
абстракції не приховують складність, а лише переміщують її;
більшість файлів містить одну-дві механічні функції.
Структура повинна зменшувати когнітивне навантаження, а не збільшувати кількість навігації між файлами.
Контролер не повинен перевіряти всі бізнес-правила, змінювати кілька сховищ і координувати складний сценарій.
Контролер має бути адаптером між транспортом і application-рівнем.
Проблемний підхід:
export function createUser(input) {
const repository = createPostgresRepository();
// ...
}Таку функцію важко протестувати з підробленим репозиторієм. Краще передавати репозиторій зовні.
process.env у всьому проєктіЦе створює приховані залежності та різну поведінку в різних модулях. Конфігурацію потрібно завантажувати централізовано.
Глобальний клієнт бази даних або сервіс часто створюється під час імпорту модуля. Це ускладнює:
тестування;
повторну ініціалізацію;
запуск кількох екземплярів у одному процесі;
обробку помилок підключення.
Краще створювати такі об’єкти в composition root і передавати їх залежним модулям.
Якщо кожен файл стає доступним для імпорту з будь-якого місця, межі модуля фактично зникають. Потрібно мати обмежений публічний API.
Цикли часто вказують на неправильний розподіл відповідальності. Їх не варто приховувати додатковими index.js; краще змінити напрямок залежностей.
Конфігурація не повинна бути розподілена між доменними, HTTP- та інфраструктурними файлами. Окремий модуль конфігурації спрощує запуск і перевірку середовища.
Перед створенням папок визначте:
Основні бізнес-модулі системи.
Сценарії, які підтримує кожен модуль.
Доменні правила, що не залежать від технологій.
Зовнішні залежності кожного модуля.
Публічний API для взаємодії між модулями.
Єдине місце складання залежностей.
Конфігураційні значення, необхідні для запуску.
Після цього перевірте структуру за напрямком залежностей:
Чи може domain працювати без infrastructure?
Чи може application працювати без HTTP?
Чи створюються конкретні реалізації лише в composition root?
Чи існують циклічні імпорти?
Чи має кожен модуль чітку відповідальність?Структура backend-проєкту повинна відображати відповідальності, а не лише типи файлів.
Доменний рівень містить бізнес-правила і не залежить від технологій.
Application-рівень реалізує сценарії використання.
Infrastructure-рівень приховує бази даних, файлові системи та зовнішні сервіси.
Interfaces відповідає за HTTP та інші способи взаємодії із системою.
Конфігурацію потрібно завантажувати й перевіряти централізовано.
Конкретні залежності слід створювати в composition root.
Dependency injection зменшує зв’язаність і спрощує тестування.
Публічний API модуля має приховувати його внутрішню структуру.
Циклічні залежності зазвичай свідчать про неправильні межі між модулями.
Хороша структура не максимізує кількість папок, а робить напрямок залежностей і відповідальність очевидними.