Антон Ларичев

Введение
Когда Node.js проект вырастает из учебного «hello world» в реальный продукт, привычный подход «всё в одном файле» начинает мешать. Контроллеры обращаются к базе напрямую, бизнес-логика переплетается с HTTP-слоем, а тесты превращаются в боль. Решение — продуманная архитектура: разделение на слои, выделение модулей и внедрение зависимостей (Dependency Injection, DI).
В этой статье разберём, как организовать Node.js приложение так, чтобы оно оставалось гибким при росте кодовой базы, легко тестировалось и позволяло безболезненно менять реализации компонентов.
Слоистая архитектура
Классический подход — разделить приложение на три-четыре слоя, где каждый отвечает за свою зону ответственности:
- Controller (Presentation) — принимает HTTP-запросы, валидирует входные данные, формирует ответ.
- Service (Business Logic) — содержит бизнес-правила, не знает про HTTP и базу данных.
- Repository (Data Access) — работает с источником данных: ORM, SQL, внешние API.
- Domain (Entity) — модели предметной области и инварианты.
Главное правило: зависимости направлены строго вниз. Контроллер знает о сервисе, сервис — о репозитории. Обратные связи запрещены.
// controllers/user.controller.js
export class UserController {
constructor(userService) {
// зависимость передана извне
this.userService = userService;
}
async getById(req, res) {
const user = await this.userService.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
}
}
// services/user.service.js
export class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
async findById(id) {
// здесь только бизнес-логика, без HTTP и SQL
return this.userRepository.findById(id);
}
}
Модули и границы
Слои описывают «горизонтальное» деление. Но при росте проекта важнее «вертикальное» — по доменам или фичам. Это называется модульной (feature-based) структурой.
src/
users/
user.controller.js
user.service.js
user.repository.js
user.model.js
orders/
order.controller.js
order.service.js
order.repository.js
shared/
logger.js
db.js
Каждый модуль самодостаточен: имеет собственный слой данных, бизнес-логики и контроллеров. Общение между модулями идёт через явные публичные интерфейсы, а не через прямой импорт внутренних файлов соседа.
Dependency Injection: зачем и как
Dependency Injection — это паттерн, при котором объект получает свои зависимости извне, а не создаёт их сам. Без DI код выглядит так:
// плохо: жёсткая связь
import { db } from '../db.js';
export class UserService {
async findById(id) {
return db.query('SELECT * FROM users WHERE id = $1', [id]);
}
}
Здесь сервис намертво привязан к конкретному инстансу базы. Замокать его в тестах сложно, заменить Postgres на MongoDB — почти нереально.
С DI зависимости передаются через конструктор:
// хорошо: зависимость инжектируется
export class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
async findById(id) {
return this.userRepository.findById(id);
}
}
Теперь сервис не знает, откуда берутся данные — это решает тот, кто его собирает.
Простейший DI-контейнер
Для небольшого проекта достаточно ручной сборки в одной точке (composition root):
// container.js
import { Pool } from 'pg';
import { UserRepository } from './users/user.repository.js';
import { UserService } from './users/user.service.js';
import { UserController } from './users/user.controller.js';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const userRepository = new UserRepository(pool);
const userService = new UserService(userRepository);
const userController = new UserController(userService);
export { userController };
Для более крупных приложений подойдут библиотеки вроде awilix, tsyringe или встроенный DI в NestJS.
Тестирование с DI
DI делает юнит-тесты тривиальными — достаточно передать заглушку:
import { test } from 'node:test';
import assert from 'node:assert';
import { UserService } from './user.service.js';
test('возвращает пользователя по id', async () => {
// мок-репозиторий вместо настоящего
const fakeRepo = {
findById: async (id) => ({ id, name: 'Anna' }),
};
const service = new UserService(fakeRepo);
const user = await service.findById(1);
assert.strictEqual(user.name, 'Anna');
});
Частые ошибки
- Сквозные импорты слоёв. Контроллер тянет репозиторий напрямую — слоистость нарушена, бизнес-логика размазывается по проекту.
- «Анемичные» сервисы. Сервис превращается в тонкую прокладку, которая просто вызывает репозиторий — это знак, что логику стоит вынести в доменные модели.
- Глобальные синглтоны. Импорт готового инстанса базы или логгера из модуля убивает тестируемость и плодит скрытые зависимости.
- Слишком ранняя абстракция. Не стоит писать пять интерфейсов «на вырост» для маленького проекта — DI вводится тогда, когда появляется реальная боль с тестами или заменой реализации.
- DI-контейнер как сервис-локатор. Если компоненты сами достают зависимости из контейнера, это уже не DI, а антипаттерн Service Locator.
Заключение
Грамотная архитектура Node.js приложения строится на трёх китах: разделении на слои с однонаправленными зависимостями, модульной организации по доменам и внедрении зависимостей через конструкторы. Такой подход даёт предсказуемую структуру, простое тестирование и возможность менять реализации без переписывания половины кода.
Начните с малого: вынесите бизнес-логику из контроллеров в сервисы, отделите работу с данными в репозитории и передавайте зависимости явно. Когда проект подрастёт — добавьте DI-контейнер. Архитектура должна служить задаче, а не наоборот.






Комментарии
0