Головна / Статті

Багатошаровий дизайн 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);

Жоден з цих фрагментів коду сам по собі не є неправильним.

Для швидкого прототипування це абсолютно розумна відправна точка.

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

  • Аутентифікація
  • Авторизація
  • Перевірка вхідних даних
  • Підтвердження електронної пошти
  • Обробка платежів
  • Керування купонами
  • Управління замовленнями
  • Відстеження запасів
  • Повідомлення
  • Обробка завдань у фоновому режимі
  • Кешування на основі 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. Краща відправна точка

    Сучасний backend на 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
    

    Це насправді лише вихідна точка.

    Справжня майстерність полягає у розумінні того, де має знаходитися кожен елемент логіки, як різні частини системи взаємодіють між собою та як її розвивати, не пошкоджуючи при цьому все інше.

    Не кожен проект потребує складної архітектури.

    Йому потрібна архітектура, пропорційна складності реальної проблеми.

    Тому що метою ніколи не було:

    „Зробити так, щоб це працювало.“

    Справжня мета — це:

    „Зробити його легким для адаптації, коли вимоги неминуче змінюються.“