Kodokon kodokon.com

Архитектура API: маршруты, контроллеры, сервисы

Раздели API на слои с единственной ответственностью и свяжи их лёгким внедрением зависимостей, чтобы всё это можно было тестировать.

10 мин · 3 вопросов

Открыть этот урок в Kodokon

Один файл, где смешаны маршрутизация, бизнес-логика и доступ к данным, всегда обходится дорого: бизнес-правила не протестировать без поднятого сервера, а инфраструктуру не заменить без переписывания всего подряд. Разделение на три слоя решает обе проблемы. Маршруты объявляют URL и делегируют. Контроллеры переводят HTTP в бизнес-вызовы: читают запрос, выбирают код состояния, сериализуют ответ. Сервисы держат бизнес-правила и вообще ничего не знают про Express. Правило зависимостей строгое: каждый слой знает только тот, что под ним, и никогда наоборот.

JAVASCRIPT
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), разбросанных по контроллерам.

JAVASCRIPT
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);
      }
    },
  };
}
Чистый перевод в HTTP: коды состояния, сериализация, делегирование.

Сервис концентрирует решения: уникальность e-mail, правила создания. Он выбрасывает бизнес-ошибки - максимум обогащённые полем status - и при этом ничего не знает про Express. Свою зависимость, репозиторий данных, он тоже получает аргументом. Это и есть лёгкое внедрение: обычные фабричные функции, вызванные один раз при старте, заменяют тяжёлые контейнеры внедрения зависимостей и делают каждый слой заменяемым в тестах.

JAVASCRIPT
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);
    },
  };
}
Бизнес-логика живёт здесь, без req, res и SQL.

Остаётся корень композиции: единственное место, где фабрики собираются вместе. createApp получает инфраструктурные зависимости (здесь базу данных, о ней следующий урок) и возвращает готовое приложение Express, которое ещё не слушает порт. Отделить сборку от прослушивания кажется мелочью; но именно это позволит тестам поднимать API на эфемерном порту, а продакшен-сервер оставит в три строки: создать базу, создать приложение, слушать.

JAVASCRIPT
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;
}
app.js: единственная часть приложения, которая знает всех.

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. При таком разделении на слои где должно жить правило «один e-mail можно использовать только один раз»?
    • В маршруте, как можно ближе к URL
    • В контроллере, у которого есть доступ к req.body
    • В сервисе, который держит бизнес-правила
    • В глобальной middleware Express
  2. В чём основная выгода передачи зависимостей аргументами фабрики вместо прямого импорта в каждый модуль?
    • Фабрики ускоряют загрузку модулей
    • Можно подменить зависимость (база в памяти, поддельный репозиторий), не трогая модуль, который её использует
    • Express требует такого стиля для своих middleware
    • Это избавляет от написания отдельных файлов
  3. Почему createApp не начинает слушать сеть самостоятельно?
    • Потому что Express запрещает вызывать listen внутри функции
    • Чтобы тесты могли создать приложение, не открывая фиксированный порт, а точка входа сервера осталась тривиальной
    • Потому что listen асинхронный и заблокировал бы фабрику
    • Чтобы снизить расход памяти при старте