Раздели API на слои с единственной ответственностью и свяжи их лёгким внедрением зависимостей, чтобы всё это можно было тестировать.
Открыть этот урок в KodokonОдин файл, где смешаны маршрутизация, бизнес-логика и доступ к данным, всегда обходится дорого: бизнес-правила не протестировать без поднятого сервера, а инфраструктуру не заменить без переписывания всего подряд. Разделение на три слоя решает обе проблемы. Маршруты объявляют URL и делегируют. Контроллеры переводят HTTP в бизнес-вызовы: читают запрос, выбирают код состояния, сериализуют ответ. Сервисы держат бизнес-правила и вообще ничего не знают про Express. Правило зависимостей строгое: каждый слой знает только тот, что под ним, и никогда наоборот.
import { Router } from 'express';
export function createUserRouter(controller) {
const router = Router();
router.get('/', controller.list);
router.post('/', controller.create);
return router;
}Контроллер - это фабрика, которая получает сервис аргументом. Никаких бизнес-правил он не содержит: его работа сводится к тому, чтобы извлечь данные из req, вызвать сервис и превратить результат в HTTP-ответ. Любая ошибка отправляется в next, чтобы её обработала центральная middleware ошибок - никаких res.status(500), разбросанных по контроллерам.
export function createUserController(service) {
return {
async list(req, res, next) {
try {
const users = await service.listUsers();
res.json(users);
} catch (err) {
next(err);
}
},
async create(req, res, next) {
try {
const user = await service.createUser(req.body);
res.status(201).json(user);
} catch (err) {
next(err);
}
},
};
}Сервис концентрирует решения: уникальность e-mail, правила создания. Он выбрасывает бизнес-ошибки - максимум обогащённые полем status - и при этом ничего не знает про Express. Свою зависимость, репозиторий данных, он тоже получает аргументом. Это и есть лёгкое внедрение: обычные фабричные функции, вызванные один раз при старте, заменяют тяжёлые контейнеры внедрения зависимостей и делают каждый слой заменяемым в тестах.
export function createUserService(repository) {
return {
listUsers() {
return repository.findAll();
},
createUser(input) {
const found = repository.findByEmail(input.email);
if (found) {
const err = new Error('Email already used');
err.status = 409;
throw err;
}
return repository.insert(input);
},
};
}Остаётся корень композиции: единственное место, где фабрики собираются вместе. createApp получает инфраструктурные зависимости (здесь базу данных, о ней следующий урок) и возвращает готовое приложение Express, которое ещё не слушает порт. Отделить сборку от прослушивания кажется мелочью; но именно это позволит тестам поднимать API на эфемерном порту, а продакшен-сервер оставит в три строки: создать базу, создать приложение, слушать.
import express from 'express';
import { createUserRepository } from './db/users.js';
import { createUserService } from './services/users.js';
import {
createUserController,
} from './controllers/users.js';
import { createUserRouter } from './routes/users.js';
export function createApp({ db }) {
const app = express();
app.use(express.json());
const repository = createUserRepository(db);
const service = createUserService(repository);
const controller = createUserController(service);
app.use('/api/users', createUserRouter(controller));
return app;
}createApp не начинает слушать сеть самостоятельно?