Главная / Статьи

Многоуровневый дизайн API на Node.js: от громоздких контроллеров к чистой архитектуре

Узнайте, как переработать API Node.js с использованием слоев контроллера, сервиса и доступа к данным, чтобы устранить запутанную бизнес-логику, неоднородные ошибки и проблемы с масштабированием.

Издатель: REACTAPP.TOP10 мин чтения2199 слов

Источники: I Built a Node.js API. Then I Realized I Was Doing It Wrong.

Практические уроки по правильной структуризации, масштабированию и обслуживанию backend на Node.js, основанные на опыте создания производственных API.

Когда вы впервые начинаете писать API на Node.js, работа с backend может казаться удивительно простой.

Определите маршрут.

Подключите его к базе данных.

Отправьте обратно немного JSON.

Всё.

И некоторое время такой подход действительно работает.

Затем приложение начинает расти.

Вдруг вы сталкиваетесь с:

  • Бизнес-правилами, встроенными непосредственно в контроллеры
  • Запросами к базе данных, распределенными по всем файлам маршрутов
  • Повторяющейся логикой проверки
  • Обработкой ошибок, отличающейся в разных точках входа
  • Чрезмерно большими файлами
  • Кодом, который затрудняет автоматизированные тесты
  • Логикой аутентификации, переплетенной с логикой бизнес-процессов
  • Небольшими изменениями, нарушающими работу функций, которые казались не связанными

Технически API не сломано.

Проблема кроется в чем-то более тонком:

Внесение изменений в него становится по-настоящему сложным.

Именно в этот момент вы понимаете, что существует реальная разница между API, которое просто работает, и тем, что может выдерживать непрерывную разработку в реальных условиях.

1. Моя первоначальная архитектура Node.js

Типичное API на ранней стадии может выглядеть так:

const express = require("express");
const app = express();
app.get("/users", async (req, res) => {
  const users = await db.query("SELECT * FROM users");  res.json(users);
});app.post("/users", async (req, res) => {
  const { name, email } = req.body;  const user = await db.query(
    "INSERT INTO users (name, email) VALUES ($1, $2)",
    [name, email]
  );  res.json(user);
});app.listen(3000);

Ни один из этих фрагментов кода сам по себе не содержит ошибок.

Для быстрого прототипа это совершенно разумная отправная точка.

Проблемы начинаются, когда проект начинает масштабироваться.

Представьте наличие следующих слоев функционала:

  • Аутентификация
  • Авторизация
  • Проверка входных данных
  • Подтверждение email
  • Обработка платежей
  • Работа с купонами
  • Управление заказами
  • Отслеживание запасов
  • Уведомления
  • Обработка задач в фоновом режиме
  • Кэширование с использованием Redis
  • Теперь представьте, что все эти задачи вкладываются непосредственно в обработчики маршрутов.

    В таком случае у вас больше нет настоящей архитектуры.

    У вас есть массив кода, который просто выполняется.

    2. Проблема была не в Node.js

    Это ошибка, которую легко допустить на ранних этапах.

    Приходит инстинктивное желание думать:

    "Моя база кода становится беспорядочной просто потому, что приложение увеличивается в размерах."

    Это лишь часть проблемы.

    Настоящей причиной был отсутствие четкого разделения обязанностей.

    Один контроллер обрабатывал слишком много функций.

    Он отвечал за:

    1. Анализ входящего запроса
    2. Проверку введенных данных
    3. Подтверждение авторизации
    4. Выполнение бизнес-логики
  • Взаимодействие с базой данных
  • Формирование ответа
  • Обнаружение и обработка ошибок
  • Это шесть или семь разных задач, собранных в одном месте.

    Каждый раз, когда менялись требования, приходилось снова и снова редактировать тот же файл.

    Постоянно.

    3. Архитектура, которую я использую сейчас

    Более чистым подходом является разделение приложения на отдельные слои.

    Например:

    src/
    │
    ├── controllers/
    │   └── user.controller.ts
    │
    ├── services/
    │   └── user.service.ts
    │
    ├── repositories/
    │   └── user.repository.ts
    │
    ├── dto/
    │   └── create-user.dto.ts
    │
    ├── middleware/
    │   └── auth.middleware.ts
    │
    ├── routes/
    │   └── user.routes.ts
    │
    ├── utils/
    │   └── error-handler.ts
    │
    └── app.ts
    

    Основной принцип прост:

    Каждый слой должен иметь одну четко определенную задачу.

    4. Контроллеры не должны содержать бизнес-логику

    Задача контроллера должна ограничиваться вопросами HTTP.

    Примерно так:

    export class UserController {
      constructor(
        private readonly userService: UserService
      ) {}
    
      async createUser(req: Request, res: Response) {
        try {
          const user = await this.userService.createUser(req.body);      return res.status(201).json({
            success: true,
            data: user,
          });
        } catch (error) {
          return res.status(500).json({
            success: false,
            message: error.message,
          });
        }
      }
    }
    

    Контроллеру не нужно знать:

    • Где и как хранятся записи пользователей
    • Какая технология базы данных используется
  • Как генерируются хеши паролей
  • Необходимо ли отправлять письмо с подтверждением
  • Участвует ли Redis в каком-либо этапе
  • Всё это не входит в его обязанности.

    Его единственная задача — обработка входящих запросов и формирование выходных ответов.

    5. Логика бизнеса должна находиться в сервисах

    Именно в слое сервисов располагается фактическая логика приложения.

    export class UserService {
      constructor(
        private readonly userRepository: UserRepository
      ) {}
    
      async createUser(data: CreateUserDto) {
        const existingUser =
          await this.userRepository.findByEmail(data.email);    if (existingUser) {
          throw new Error("User already exists");
        }    const hashedPassword = await hashPassword(
          data.password
        );    return this.userRepository.create({
          ...data,
          password: hashedPassword,
        });
      }
    }
    

    Это позволяет использовать логику повторно.

    Другая часть системы, например, может просто вызвать:

    userService.createUser(data);
    

    без необходимости имитировать HTTP-запрос вообще.

    Такая возможность повторного использования имеет большое значение как для тестирования, так и для долгосрочной поддержки.

    6. Доступ к базе данных требует отдельного слоя

    Логика бизнеса не должна быть загромождена примитивными SQL-запросами или вызовами ORM, разбросанными повсюду.

    Именно эту проблему решают репозитории.

    export class UserRepository {
    
      async findByEmail(email: string) {
        return prisma.user.findUnique({
          where: {
            email,
          },
        });
      }  async create(data: CreateUserInput) {
        return prisma.user.create({
          data,
        });
      }
    }
    

    Благодаря этому разделение ответственностей становится очевидным.

    Request
       ↓
    Controller
       ↓
    Service
       ↓
    Repository
       ↓
    Database
    

    Каждый слой знает только то, что ему строго необходимо знать, и ничего больше.

    7. Почему DTO имеют большее значение, чем кажется

    Распространенной ошибкой на ранних этапах является передача необработанных тел запросов непосредственно в приложение.

    Что-то вроде этого:

    const user = await userService.createUser(req.body);
    

    Это рискованно.

    Что на самом деле находится внутри req.body?

    Можете ли вы быть уверены?

    DTO устанавливает четкий, определенный контракт относительно ожидаемого формата данных.

    class CreateUserDto {
      name: string;
      email: string;
      password: string;
    }
    

    После этого входные данные проверяются перед тем, как их позволят использовать в логике приложения.

    Например, загрузка данных вида:

    {
      "name": "Mit",
      "email": "mit@example.com",
      "password": "secret123"
    }
    

    может быть проверена по таким правилам, как:

    • Обязательные поля
    • Формат электронной почты
    • Длина пароля
    • Типы данных
  • Допустимые значения
  • Основной принцип здесь прост:

    Никогда не доверяйте данным, поступающим от клиента.

    Фронтенд не является защитным барьером.

    8. Важна последовательность в обработке ошибок

    Связанной проблемой на ранних этапах разработки API является неоднородность формата ответов.

    Один маршрут может возвращать:

    {
      "error": "User not found"
    }
    

    Другой отвечает следующим образом:

    {
      "message": "Not found"
    }
    

    А ещё один выглядит так:

    {
      "success": false,
      "data": null
    }
    

    Для тех, кто разрабатывает фронтенд под это API, такая неоднородность крайне утомительна.

    Применение единого централизованного подхода к обработке ошибок значительно упрощает работу с API.

    Например:

    {
      "success": false,
      "message": "User not found",
      "code": "USER_NOT_FOUND"
    }
    

    Конкретная структура ответа не имеет большого значения.

    Важно то, что она всегда одинакова.

    9. Аутентификация — это не авторизация

    Это различие часто становится препятствием для многих разработчиков на ранних этапах.

    Аутентификация отвечает на один вопрос:

    Кто вы?

    Авторизация отвечает на другой вопрос:

    Что вам разрешено делать?

    С точки зрения алгоритма процесс выглядит примерно так:

    Login
      ↓
    Authentication
      ↓
    JWT / Session
      ↓
    User identified
      ↓
    Authorization
      ↓
    Can this user delete the product?
    

    То, что пользователь прошел аутентификацию, еще не означает, что у него есть разрешение выполнять любые действия, которые он хочет.

    Именно поэтому проверки ролей и прав должны выполняться отдельно от базовой логики входа и аутентификации.

    10. Рано или поздно придется задуматься о масштабируемости

    Как только код становится достаточно хорошо организованным, появляется новый вопрос:

    Что произойдет при увеличении объема трафика?

    Именно здесь такие инструменты, как Redis, очереди задач и индексы баз данных, начинают играть важную роль.

    Представьте конечную точку, которая при каждом запросе выполняет дорогостоящий запрос к базе данных.

    Вместо того чтобы:

    Request
       ↓
    Database
       ↓
    Response
    

    система может в конечном итоге перейти к чему-то вроде:

    Request
       ↓
    Redis Cache
       ↓
    Cache Hit → Response
       │
       └── Cache Miss
              ↓
           Database
              ↓
           Redis
              ↓
           Response
    

    А для всего, что не должно блокировать ответ:

    API
     ↓
    Queue
     ↓
    Worker
     ↓
    Email / Notification / Processing
    

    это предотвращает задержку реального запроса из-за медленных, некритичных операций.

    Но есть важное замечание, которое стоит повторить:

    Не обращайтесь к Redis, очередям, Kafka, микросервисам или Kubernetes только потому, что они кажутся впечатляющими.

    Архитектура существует для решения реальных проблем.

    Она не должна создавать новые проблемы.

    11. Самый важный урок здесь

    Настоящее изменение образа мышления сводится к следующему:

    Прекратите оптимизировать скорость написания кода.

    Вместо этого оптимизируйте возможность его безопасной замены позже.

    При запуске первого проекта кажется эффективным собирать всё в один файл.

    И поначалу это действительно так.

    Но написание первой версии составляет лишь небольшую часть общей работы.

    Большая часть изменений в кодовой базе происходит позже.

    Со временем проекту потребуется:

    • Добавлять новые функции
    • Исправлять ошибки
    • Адаптироваться к меняющимся требованиям
    • Заменять библиотеки
    • Обновлять схемы базы данных
    • Внедрять механизмы аутентификации
    • Вводить системы разрешений
    • Ускорять медленные запросы
    • Интегрироваться с сервисами сторонних разработчиков

    Если архитектура превращает каждое из этих задач в сложную работу, то ранняя скорость выполнения не является настоящим преимуществом.

    12. Лучшая отправная точка

    Современный фронтенд на Node.js может быть развернут примерно вот так:

    src/
    │
    ├── modules/
    │   ├── users/
    │   │   ├── user.controller.ts
    │   │   ├── user.service.ts
    │   │   ├── user.repository.ts
    │   │   ├── user.dto.ts
    │   │   └── user.routes.ts
    │   │
    │   ├── products/
    │   │   ├── product.controller.ts
    │   │   ├── product.service.ts
    │   │   ├── product.repository.ts
    │   │   └── product.dto.ts
    │
    ├── middleware/
    ├── config/
    ├── database/
    ├── utils/
    └── app.ts
    

    Для крупных приложений организация кода по модулям или функциям обычно эффективнее, чем собирание всего в огромные общие папки, заполненные не связанными между собой контроллерами и сервисами.

    Конкретная структура папок не является ключевым моментом.

    Важно, чтобы ответственность каждой части кода была ясна.

    13. Семь правил, которых стоит придерживаться

    По сути, всё сводится к семи правилам:

    1. Сохраняйте контроллеры простыми

    Контроллеры предназначены для обработки HTTP-запросов, и ничего больше.

    2. Размещайте бизнес-логику в сервисах

    Именно в сервисах должна находиться реальная логика работы приложения.

    3. Изолируйте операции с базой данных

    Избегайте распространения запросов к базе данных по всему кодовому базису.

    4. Проверяйте входные данные извне

    Не предполагайте, что req.body содержит ожидаемую информацию.

    5. Стандартизируйте ошибки

    Сделайте ответы на сбои предсказуемыми и единообразными.

    6. Добавляйте инфраструктуру только тогда, когда это необходимо

    Redis, очереди и микросервисы — это инструменты, к которым обращаются тогда, когда это оправдано, а не признаки высокого уровня квалификации.

    7. Проектируйте с учетом изменений

    Любой, кто будет обслуживать этот код, будет вносить в него изменения гораздо чаще, чем писать его заново.

    Заключение

    Запустить API на Node.js не так уж и сложно.

    Сохранить его понятность на протяжении месяцев активной разработки — гораздо более сложная задача.

    Легко ошибочно считать хорошую разработку бэкенда способностью правильно настроить:

    GET
    POST
    PUT
    DELETE
    

    Но это на самом деле лишь отправная точка.

    Настоящий навык заключается в понимании того, где должна находиться каждая часть логики, как различные компоненты системы взаимодействуют друг с другом и как её развивать, не нарушая при этом всё остальное.

    Не каждому проекту нужна сложная архитектура.

    Ему нужна архитектура, пропорциональная сложности реальной задачи.

    Потому что цель никогда не была следующей:

    «Сделать так, чтобы всё работало».

    Настоящая цель — это:

    «Сделать так, чтобы было легко адаптироваться при неизбежных изменениях требований».