Тема 7. Фреймворк NestJS. Контролери та маршрутизація

Best Practices для контролерів та маршрутизації

Рекомендації щодо організації контролерів, RESTful дизайн

Best Practices для контролерів та маршрутизації

🎯 Мета лекції

  • Опанувати принципи проєктування контролерів згідно з Single Responsibility Principle
  • Засвоїти розділення відповідальності між контролерами та сервісами
  • Вивчити RESTful конвенції для іменування ендпоінтів та використання HTTP-методів
  • Практикувати правильне використання HTTP статус-кодів для різних сценаріїв
  • Навчитися організовувати контролери за доменами та версіонувати API
  • Зрозуміти важливість DTO та валідації для безпеки та підтримуваності
  • Ознайомитися з анти-патернами та частими помилками при розробці контролерів
  • Засвоїти рекомендації щодо документування API через Swagger

🔑 Ключові терміни

  • Single Responsibility Principle (принцип єдиної відповідальності): компонент має одну причину для зміни
  • Separation of Concerns (розділення відповідальності): ізоляція різних аспектів логіки
  • RESTful API: архітектурний стиль з використанням HTTP-методів та стандартизованих маршрутів
  • Anti-pattern (анти-патерн): поширене рішення, що призводить до проблем
  • API Versioning (версіонування API): підтримка кількох версій API одночасно
  • Idempotent Operation (ідемпотентна операція): дає той самий результат при повторних викликах
  • Defensive Programming (захисне програмування): перевірка вхідних даних та обробка крайніх випадків

Принцип Single Responsibility для контролерів

Single Responsibility Principle (SRP) — один з п'яти принципів SOLID, що стверджує: клас повинен мати лише одну причину для зміни. Для контролерів це означає, що вони мають займатися лише маршрутизацією та валідацією, делегуючи всю бізнес-логіку сервісам.

Анатомія правильного контролера

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

rectangle "HTTP Request" as Request #DBEAFE
rectangle "Controller Layer" as Controller #DCFCE7 {
    rectangle "1. Routing" as R1 #F1F5F9
    rectangle "2. Validation" as R2 #F1F5F9
    rectangle "3. Transformation" as R3 #F1F5F9
    rectangle "4. Delegation" as R4 #F1F5F9
}

rectangle "Service Layer" as Service #FEF3C7 {
    rectangle "Business Logic" as BL #F1F5F9
    rectangle "Data Access" as DA #F1F5F9
    rectangle "External APIs" as EA #F1F5F9
}

rectangle "HTTP Response" as Response #DBEAFE

Request --> R1
R1 --> R2
R2 --> R3
R3 --> R4
R4 --> BL
BL --> DA
BL --> EA
EA --> Response

note right of Controller
  Контролер — тонкий шар:
  - Приймає HTTP-запит
  - Валідує через DTO
  - Перетворює параметри
  - Делегує сервісу
  - Формує HTTP-відповідь
end note

note right of Service
  Сервіс — товстий шар:
  - Бізнес-правила
  - Доступ до БД
  - Виклики API
  - Складні обчислення
end note

@enduml

✅ Правильна архітектура:

// ✅ Контролер — тонкий шар маршрутизації
@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  @HttpCode(HttpStatus.CREATED)
  async create(@Body() createUserDto: CreateUserDto) {
    // Лише делегація — вся логіка у сервісі
    return this.usersService.create(createUserDto);
  }

  @Get()
  async findAll(@Query('role') role?: 'user' | 'admin') {
    return this.usersService.findAll(role);
  }

  @Get(':id')
  async findOne(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.findOne(id);
  }
}
// ✅ Сервіс — товстий шар бізнес-логіки
@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly usersRepository: Repository<User>,
    private readonly emailService: EmailService,
  ) {}

  async create(createUserDto: CreateUserDto): Promise<User> {
    // Перевірка бізнес-правил
    const existingUser = await this.usersRepository.findOne({
      where: { email: createUserDto.email },
    });

    if (existingUser) {
      throw new ConflictException('Email already exists');
    }

    // Хешування пароля
    const hashedPassword = await bcrypt.hash(createUserDto.password, 10);

    // Створення користувача
    const user = this.usersRepository.create({
      ...createUserDto,
      password: hashedPassword,
    });

    const savedUser = await this.usersRepository.save(user);

    // Відправка welcome email
    await this.emailService.sendWelcome(savedUser.email);

    return savedUser;
  }
}

❌ Анти-патерн — бізнес-логіка у контролері:

// ❌ ПОГАНО: контролер містить бізнес-логіку
@Controller('users')
export class UsersController {
  constructor(
    @InjectRepository(User)
    private readonly usersRepository: Repository<User>,
    private readonly emailService: EmailService,
  ) {}

  @Post()
  async create(@Body() createUserDto: CreateUserDto) {
    // ❌ Бізнес-логіка у контролері
    const existingUser = await this.usersRepository.findOne({
      where: { email: createUserDto.email },
    });

    if (existingUser) {
      throw new ConflictException('Email already exists');
    }

    const hashedPassword = await bcrypt.hash(createUserDto.password, 10);

    const user = this.usersRepository.create({
      ...createUserDto,
      password: hashedPassword,
    });

    const savedUser = await this.usersRepository.save(user);

    await this.emailService.sendWelcome(savedUser.email);

    return savedUser;
  }
}

Проблеми анти-патерну:

  1. Складність тестування — потрібно мокувати репозиторій та email-сервіс у тестах контролера
  2. Дублювання коду — логіку неможливо переви використати в інших місцях
  3. Порушення SRP — контролер має кілька причин для зміни (зміна логіки email, зміна хешування)
  4. Складність підтримки — логіка розмазана по контролерах

RESTful дизайн та іменування

REST API має дотримуватися стандартизованих конвенцій, що робить його передбачуваним та зрозумілим для розробників.

Іменування ресурсів

Правила:

  1. Використовуйте іменники у множині — /users, /products, /orders
  2. Уникайте дієслів — HTTP-метод вже вказує дію
  3. kebab-case для складених слів — /user-profiles, /order-items
  4. Ієрархічні ресурси — /users/:userId/orders (замовлення користувача)

✅ Правильно:

GET    /users              // Список користувачів
POST   /users              // Створення користувача
GET    /users/1            // Один користувач
PATCH  /users/1            // Оновлення користувача
DELETE /users/1            // Видалення користувача

GET    /users/1/orders     // Замовлення користувача 1
POST   /users/1/orders     // Створення замовлення для користувача 1

GET    /order-items        // Всі елементи замовлень

❌ Неправильно:

GET    /getUsers           // ❌ Дієслово
POST   /createUser         // ❌ Дієслово + однина
GET    /user/1             // ❌ Однина
DELETE /deleteUser?id=1    // ❌ Дієслово + query замість param
GET    /users/1/getOrders  // ❌ Дієслово

GET    /order_items        // ❌ snake_case замість kebab-case

HTTP-методи та їх семантика

МетодПризначенняІдемпотентністьBodyСтатус успіху
GETЧитання ресурсів✅ Так❌ Немає200 OK
POSTСтворення ресурсу❌ Ні✅ Так201 Created
PUTПовне заміщення✅ Так✅ Так200 OK, 204 No Content
PATCHЧасткове оновлення⚠️ Іноді✅ Так200 OK
DELETEВидалення ресурсу✅ Так❌ Немає204 No Content

Ідемпотентність — властивість операції давати той самий результат при повторних викликах:

// GET — ідемпотентний (завжди повертає той самий користувач)
GET /users/1
GET /users/1
GET /users/1
// → Всі виклики повертають { id: 1, name: "Alice" }

// DELETE — ідемпотентний (після першого виклику користувач видалено)
DELETE /users/1  // 204 No Content
DELETE /users/1  // 404 Not Found (але стан не змінився)
DELETE /users/1  // 404 Not Found

// POST — НЕ ідемпотентний (кожен виклик створює новий ресурс)
POST /users { name: "Bob" }  // { id: 2, name: "Bob" }
POST /users { name: "Bob" }  // { id: 3, name: "Bob" } ❌ Дубль!

HTTP статус-коди

Успішні відповіді (2xx):

КодНазваВикористання
200OKУспішна операція з тілом відповіді (GET, PATCH, PUT)
201CreatedРесурс створено (POST)
204No ContentУспішна операція без тіла відповіді (DELETE)

Помилки клієнта (4xx):

КодНазваВикористання
400Bad RequestНевалідні дані (провал валідації DTO)
401UnauthorizedВідсутня або невалідна автентифікація
403ForbiddenАвтентифікація пройдена, але доступ заборонено
404Not FoundРесурс не знайдено
409ConflictКонфлікт (email вже існує, race condition)
422Unprocessable EntityВалідні дані, але бізнес-правила порушено

Помилки сервера (5xx):

КодНазваВикористання
500Internal Server ErrorНепередбачена помилка сервера
503Service UnavailableСервіс тимчасово недоступний

Приклад використання у контролері:

@Controller('users')
export class UsersController {
  @Post()
  @HttpCode(HttpStatus.CREATED) // 201
  async create(@Body() dto: CreateUserDto) {
    return this.usersService.create(dto);
  }

  @Get()
  // За замовчуванням 200 OK
  async findAll() {
    return this.usersService.findAll();
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT) // 204
  async remove(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.remove(id);
  }
}

Використання DTO замість прямих параметрів

DTO (Data Transfer Objects) інкапсулюють структуру даних та правила валідації, що робить API безпечнішим та зрозумілішим.

Чому DTO, а не прості параметри?

❌ Без DTO — прямі параметри:

@Post()
async create(
  @Body('email') email: string,
  @Body('name') name: string,
  @Body('role') role: string,
  @Body('age') age: number,
  @Body('address') address: string,
) {
  // ❌ Проблеми:
  // 1. Немає валідації
  // 2. Немає типізації
  // 3. Важко додати нове поле (5+ параметрів)
  // 4. Немає документації структури
  
  return this.usersService.create({ email, name, role, age, address });
}

✅ З DTO — інкапсуляція та валідація:

// create-user.dto.ts
export class CreateUserDto {
  @IsEmail()
  @IsNotEmpty()
  email: string;

  @IsString()
  @MinLength(2)
  @MaxLength(50)
  name: string;

  @IsEnum(['user', 'admin'])
  role: 'user' | 'admin';

  @IsInt()
  @Min(18)
  @Max(120)
  age: number;

  @IsString()
  @IsOptional()
  address?: string;
}

// Controller
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() createUserDto: CreateUserDto) {
  // ✅ Переваги:
  // 1. Автоматична валідація через ValidationPipe
  // 2. Повна типізація
  // 3. Легко розширювати
  // 4. Самодокументований код
  
  return this.usersService.create(createUserDto);
}

DTO для query-параметрів

// find-users-query.dto.ts
export class FindUsersQueryDto {
  @IsOptional()
  @IsEnum(['user', 'admin'])
  role?: 'user' | 'admin';

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page?: number = 1;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  limit?: number = 10;

  @IsOptional()
  @IsEnum(['name', 'email', 'createdAt'])
  sortBy?: string = 'createdAt';

  @IsOptional()
  @IsEnum(['asc', 'desc'])
  sortOrder?: 'asc' | 'desc' = 'desc';
}

// Controller
@Get()
async findAll(@Query() query: FindUsersQueryDto) {
  return this.usersService.findAll(query);
}

// Використання:
// GET /users?role=admin&page=2&limit=20&sortBy=name&sortOrder=asc

Response DTO для безпеки

Проблема: Entity може містити чутливі поля (паролі, токени), які не повинні повертатися клієнту.

Рішення: Response DTO з виключенням полів:

// user.entity.ts
import { Exclude } from 'class-transformer';

export class User {
  id: number;
  email: string;
  name: string;
  role: 'user' | 'admin';

  @Exclude() // Ніколи не серіалізується у відповіді
  password: string;

  @Exclude()
  refreshToken?: string;

  createdAt: Date;
  updatedAt: Date;
}

// main.ts — увімкнення ClassSerializerInterceptor
import { ClassSerializerInterceptor } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
  
  await app.listen(3000);
}

Результат:

GET /users/1

{
  "id": 1,
  "email": "alice@example.com",
  "name": "Alice Johnson",
  "role": "admin",
  "createdAt": "2024-01-15T00:00:00.000Z",
  "updatedAt": "2024-01-15T00:00:00.000Z"
}
// password та refreshToken автоматично виключені

Організація контролерів за доменами

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

Модульна структура

src/
├── users/
│   ├── users.controller.ts
│   ├── users.service.ts
│   ├── users.module.ts
│   ├── dto/
│   │   ├── create-user.dto.ts
│   │   └── update-user.dto.ts
│   └── entities/
│       └── user.entity.ts
├── products/
│   ├── products.controller.ts
│   ├── products.service.ts
│   ├── products.module.ts
│   └── ...
├── orders/
│   ├── orders.controller.ts
│   ├── orders.service.ts
│   ├── orders.module.ts
│   └── ...
└── app.module.ts

Переваги:

  • Інкапсуляція — кожен модуль незалежний
  • Масштабованість — легко додавати нові модулі
  • Підтримуваність — зміни у users не впливають на products

Версіонування API

Для підтримки зворотної сумісності використовуйте версіонування:

Варіант 1: URI Versioning

// src/main.ts
app.setGlobalPrefix('api');
app.enableVersioning({
  type: VersioningType.URI,
});

// src/users/users.controller.ts
@Controller({ path: 'users', version: '1' })
export class UsersV1Controller {
  // GET /api/v1/users
}

@Controller({ path: 'users', version: '2' })
export class UsersV2Controller {
  // GET /api/v2/users
}

Варіант 2: Header Versioning

app.enableVersioning({
  type: VersioningType.HEADER,
  header: 'X-API-Version',
});

// Клієнт надсилає:
// X-API-Version: 1

Варіант 3: Media Type Versioning

app.enableVersioning({
  type: VersioningType.MEDIA_TYPE,
  key: 'v=',
});

// Клієнт надсилає:
// Accept: application/json;v=1

Валідація та обробка помилок

Глобальна валідація

// src/main.ts
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,              // Видаляє поля, не описані у DTO
    forbidNonWhitelisted: true,   // Повертає помилку при зайвих полях
    transform: true,              // Автоматично трансформує типи
    transformOptions: {
      enableImplicitConversion: true, // "123" → 123 для @IsNumber()
    },
  }));
  
  await app.listen(3000);
}

Кастомні валідатори

// validators/is-strong-password.validator.ts
import { registerDecorator, ValidationOptions } from 'class-validator';

export function IsStrongPassword(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isStrongPassword',
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: {
        validate(value: any) {
          if (typeof value !== 'string') return false;
          
          // Мінімум 8 символів, 1 велика літера, 1 цифра, 1 спецсимвол
          const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
          return regex.test(value);
        },
        defaultMessage() {
          return 'Password must contain at least 8 characters, 1 uppercase, 1 number, and 1 special character';
        },
      },
    });
  };
}

// Використання у DTO
export class CreateUserDto {
  @IsStrongPassword()
  password: string;
}

Глобальна обробка помилок

// filters/http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    const message =
      exception instanceof HttpException
        ? exception.getResponse()
        : 'Internal server error';

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      message,
    });
  }
}

// main.ts
app.useGlobalFilters(new AllExceptionsFilter());

Анти-патерни та частіhelpers

Анти-патерн 1: Використання @Req та @Res

❌ Погано:

@Get()
async findAll(@Req() req: Request, @Res() res: Response) {
  const users = await this.usersService.findAll();
  res.status(200).json(users); // ❌ Ручне управління відповіддю
}

✅ Добре:

@Get()
async findAll() {
  return this.usersService.findAll(); // ✅ NestJS автоматично серіалізує
}

Чому погано:

  • Втрата автоматичної серіалізації
  • Складність тестування
  • Ручне управління статус-кодами

Анти-патерн 2: Масивні контролери

❌ Погано — один контролер для всього:

@Controller('api')
export class ApiController {
  // ❌ 50+ методів у одному контролері
  @Get('users')
  getUsers() { }
  
  @Post('users')
  createUser() { }
  
  @Get('products')
  getProducts() { }
  
  @Post('orders')
  createOrder() { }
  
  // ... ще 40+ методів
}

✅ Добре — окремі контролери:

@Controller('users')
export class UsersController { }

@Controller('products')
export class ProductsController { }

@Controller('orders')
export class OrdersController { }

Анти-патерн 3: Ігнорування помилок

❌ Погано:

@Get(':id')
async findOne(@Param('id') id: number) {
  const user = await this.usersService.findOne(id);
  return user; // ❌ Якщо user === null, повертає 200 OK з null
}

✅ Добре:

@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
  const user = await this.usersService.findOne(id);
  
  if (!user) {
    throw new NotFoundException(`User with ID ${id} not found`);
  }
  
  return user; // ✅ 404 якщо не знайдено, 200 якщо знайдено
}

Документування API через Swagger

Swagger (OpenAPI) автоматично генерує інтерактивну документацію API.

Встановлення

npm install @nestjs/swagger

Налаштування у main.ts

import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const config = new DocumentBuilder()
    .setTitle('Users API')
    .setDescription('API для управління користувачами')
    .setVersion('1.0')
    .addTag('users')
    .addBearerAuth()
    .build();

  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api-docs', app, document);

  await app.listen(3000);
  console.log('Swagger docs: http://localhost:3000/api-docs');
}
bootstrap();

Декоратори Swagger

import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger';

@ApiTags('users')
@Controller('users')
export class UsersController {
  @Post()
  @ApiOperation({ summary: 'Створити нового користувача' })
  @ApiBody({ type: CreateUserDto })
  @ApiResponse({ status: 201, description: 'Користувача створено', type: User })
  @ApiResponse({ status: 400, description: 'Невалідні дані' })
  @ApiResponse({ status: 409, description: 'Email вже існує' })
  async create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }

  @Get(':id')
  @ApiOperation({ summary: 'Отримати користувача за ID' })
  @ApiParam({ name: 'id', type: 'number', description: 'ID користувача' })
  @ApiResponse({ status: 200, description: 'Користувача знайдено', type: User })
  @ApiResponse({ status: 404, description: 'Користувача не знайдено' })
  async findOne(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.findOne(id);
  }
}

Результат: Інтерактивна документація за адресою http://localhost:3000/api-docs

Резюме Best Practices

🏗️ Архітектура

  • SRP — контролер = тонкий шар
  • Делегація — логіка у сервісах
  • Модульність — один контролер = один домен
  • Версіонування — підтримка старих клієнтів

📋 RESTful дизайн

  • Іменники у множині — /users, /products
  • Правильні методи — GET, POST, PATCH, DELETE
  • Статус-коди — 200, 201, 204, 404, 409
  • Ідемпотентність — GET, PUT, DELETE

🛡️ Валідація та безпека

  • DTO — завжди, не прямі параметри
  • ValidationPipe — whitelist, forbidNonWhitelisted
  • Response DTO — виключення чутливих полів
  • Обробка помилок — HttpException для 4xx/5xx

📚 Документація

  • Swagger — автоматична документація
  • @ApiOperation — опис ендпоінтів
  • @ApiResponse — можливі відповіді
  • @ApiTags — групування у розділи
Підсумкова рекомендація: Дотримуйтесь принципу Single Responsibility — контролери мають бути тонким шаром маршрутизації. Використовуйте DTO для всіх вхідних даних, дотримуйтесь RESTful конвенцій та документуйте API через Swagger. Ці практики забезпечать підтримуваність, масштабованість та зрозумілість коду для всієї команди.
Copyright © 2026