Пошук уроків, статей та іншого контенту
Навчитеся поєднувати модулі, контролери та провайдери в масштабовану архітектуру NestJS-застосунку.
NestJS будує застосунок навколо трьох основних елементів:
модулі організовують функціональність і визначають межі частин застосунку;
контролери приймають HTTP-запити та формують відповіді;
провайдери містять бізнес-логіку й підключаються через dependency injection.
Типовий потік обробки запиту має такий вигляд:
HTTP-запит
↓
Контролер
↓
Провайдер
↓
РезультатМодулі об’єднують контролери та провайдери у функціональні блоки:
AppModule
├── UsersModule
│ ├── UsersController
│ └── UsersService
└── OrdersModule
├── OrdersController
└── OrdersServiceТака структура дає змогу розділяти відповідальність і не перетворювати застосунок на один великий файл.
Модуль у NestJS — це клас із декоратором @Module(). У ньому описується:
які контролери належать модулю;
які провайдери він створює;
які провайдери експортує для інших модулів;
які інші модулі імпортує.
Базова структура модуля:
import { Module } from '@nestjs/common';
@Module({
imports: [],
controllers: [],
providers: [],
exports: [],
})
export class UsersModule {}controllersМасив controllers містить контролери, які обробляють маршрути цього модуля.
@Module({
controllers: [UsersController],
})
export class UsersModule {}providersМасив providers містить провайдери, доступні всередині модуля.
@Module({
providers: [UsersService],
})
export class UsersModule {}NestJS створює екземпляр UsersService і може передати його в конструктор контролера.
importsМасив imports підключає інші модулі. Це потрібно, якщо поточний модуль використовує експортовані провайдери іншого модуля.
@Module({
imports: [UsersModule],
})
export class OrdersModule {}exportsМасив exports визначає публічний API модуля. Провайдер, доданий лише в providers, доступний тільки всередині власного модуля.
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}Після цього UsersService можна використовувати в модулі, який імпортує UsersModule.
Контролер відповідає за HTTP-рівень застосунку:
описує маршрути;
читає параметри запиту;
отримує тіло запиту;
викликає провайдери;
повертає результат клієнту.
Контролер не повинен містити складну бізнес-логіку. Його завдання — передати дані відповідному сервісу.
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
findAll() {
return this.usersService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(Number(id));
}
@Post()
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
}Декоратор @Controller('users') задає базовий шлях. Тому:
@Get() відповідає маршруту GET /users;
@Get(':id') відповідає маршруту GET /users/:id;
@Post() відповідає маршруту POST /users.
Провайдер — це клас, який NestJS може створити та передати іншим класам як залежність.
Найчастіше провайдери використовують для:
бізнес-логіки;
роботи з базою даних;
виклику зовнішніх сервісів;
перетворення або перевірки даних.
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
interface User {
id: number;
name: string;
email: string;
}
@Injectable()
export class UsersService {
private readonly users: User[] = [];
private nextId = 1;
findAll(): User[] {
return this.users;
}
findOne(id: number): User {
const user = this.users.find((item) => item.id === id);
if (!user) {
throw new NotFoundException('Користувача не знайдено');
}
return user;
}
create(dto: CreateUserDto): User {
const user: User = {
id: this.nextId++,
name: dto.name,
email: dto.email,
};
this.users.push(user);
return user;
}
}@Injectable() повідомляє NestJS, що клас можна використовувати як провайдер і передавати через dependency injection.
Залежність указується в конструкторі:
constructor(private readonly usersService: UsersService) {}NestJS сам знаходить провайдер UsersService, створює його екземпляр і передає контролеру.
Функціональність краще групувати за предметною областю. Наприклад, усе, що стосується користувачів, можна розмістити в каталозі users.
src/
├── app.module.ts
├── main.ts
└── users/
├── dto/
│ └── create-user.dto.ts
├── users.controller.ts
├── users.module.ts
└── users.service.tsDTO описує дані, які очікує endpoint.
export class CreateUserDto {
name: string;
email: string;
}У цьому прикладі DTO використовується як тип для тіла POST-запиту.
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
interface User {
id: number;
name: string;
email: string;
}
@Injectable()
export class UsersService {
private readonly users: User[] = [];
private nextId = 1;
findAll(): User[] {
return this.users;
}
findOne(id: number): User {
const user = this.users.find((user) => user.id === id);
if (!user) {
throw new NotFoundException('Користувача не знайдено');
}
return user;
}
create(dto: CreateUserDto): User {
const user: User = {
id: this.nextId++,
name: dto.name,
email: dto.email,
};
this.users.push(user);
return user;
}
}import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
findAll() {
return this.usersService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(Number(id));
}
@Post()
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
}import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}UsersModule об’єднує контролер і сервіс користувачів. Експорт UsersService поки не є обов’язковим для роботи самого контролера, але він потрібен, якщо інший модуль має використовувати цей сервіс.
AppModule є кореневим модулем. Він зазвичай не містить усю бізнес-логіку, а лише підключає функціональні модулі.
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
@Module({
imports: [UsersModule],
})
export class AppModule {}Точка входу запускає застосунок із AppModule:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();Після запуску застосунок матиме такі маршрути:
GET /users
GET /users/:id
POST /usersПриклад запиту для створення користувача:
POST /users
Content-Type: application/json
{
"name": "Олена",
"email": "olena@example.com"
}Приклад відповіді:
{
"id": 1,
"name": "Олена",
"email": "olena@example.com"
}Припустімо, що OrdersModule має отримати дані користувача. Він не повинен створювати UsersService самостійно. Замість цього:
UsersModule експортує UsersService;
OrdersModule імпортує UsersModule;
OrdersService отримує UsersService через конструктор.
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
@Injectable()
export class OrdersService {
constructor(private readonly usersService: UsersService) {}
findUserForOrder(userId: number) {
return this.usersService.findOne(userId);
}
}Модуль замовлень:
import { Module } from '@nestjs/common';
import { UsersModule } from '../users/users.module';
import { OrdersService } from './orders.service';
@Module({
imports: [UsersModule],
providers: [OrdersService],
})
export class OrdersModule {}Тут важливі обидві частини:
// users.module.ts
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}// orders.module.ts
@Module({
imports: [UsersModule],
providers: [OrdersService],
})
export class OrdersModule {}Якщо не додати UsersService до exports, інший модуль не зможе його інжектити. Якщо не додати UsersModule до imports, NestJS не матиме доступу до експортованого провайдера.
Залежності між модулями повинні бути зрозумілими та односпрямованими.
Наприклад:
AppModule
↓
OrdersModule
↓
UsersModuleБажано, щоб:
модулі залежали від абстракцій і публічних сервісів інших модулів;
внутрішні класи модуля не використовувалися напряму ззовні;
один модуль не імпортував безпосередньо внутрішні файли іншого модуля;
модулі мали чітку відповідальність.
Правильний імпорт:
import { UsersModule } from '../users/users.module';А доступ до сервісу відбувається через dependency injection:
constructor(private readonly usersService: UsersService) {}Не варто обходити межі модуля, імпортуючи внутрішню реалізацію лише для того, щоб створити її вручну.
Для масштабованої архітектури корисно дотримуватися такого розподілу:
Контролер:
приймає HTTP-запит;
отримує параметри;
викликає сервіс;
повертає результат.
Сервіс:
реалізує бізнес-правила;
працює з даними;
викликає інші сервіси;
повідомляє про помилки предметної області.
Модуль:
групує пов’язані класи;
визначає межі функціональності;
приховує внутрішні провайдери;
оголошує публічні залежності через exports.
Наприклад, пошук користувача не повинен реалізовуватися в контролері:
// Погано: бізнес-логіка знаходиться в контролері
@Get(':id')
findOne(@Param('id') id: string) {
return this.users.find((user) => user.id === Number(id));
}Краще передати цю операцію сервісу:
// Добре: контролер делегує операцію сервісу
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(Number(id));
}Таку логіку легше тестувати та повторно використовувати.
Для застосунку з кількома предметними областями структура може виглядати так:
src/
├── app.module.ts
├── main.ts
├── users/
│ ├── dto/
│ ├── users.controller.ts
│ ├── users.module.ts
│ └── users.service.ts
├── orders/
│ ├── dto/
│ ├── orders.controller.ts
│ ├── orders.module.ts
│ └── orders.service.ts
└── products/
├── dto/
├── products.controller.ts
├── products.module.ts
└── products.service.tsКожен каталог відповідає за окрему функціональність. Додавання нової можливості не вимагає змінювати один великий контролер або сервіс.
Контролер із великою кількістю умов, роботи з масивами або запитами до бази важко підтримувати.
Краще перенести цю логіку до сервісу.
Сервіс на кшталт AppService, який містить логіку користувачів, замовлень і платежів, швидко стає складним для змін.
Краще створювати сервіси в межах відповідних feature-модулів.
providersЯкщо сервіс не доданий до providers, NestJS не зможе створити його через dependency injection.
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}Якщо провайдер використовується в іншому модулі, його потрібно експортувати:
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}Модуль-споживач повинен імпортувати модуль, який експортує потрібний провайдер:
@Module({
imports: [UsersModule],
providers: [OrdersService],
})
export class OrdersModule {}Не варто створювати залежність через new у контролері:
// Погано
private readonly usersService = new UsersService();Це обходить контейнер dependency injection і ускладнює заміну залежності та тестування.
Потрібно передавати залежність через конструктор:
// Добре
constructor(private readonly usersService: UsersService) {}Модуль об’єднує пов’язані контролери та провайдери.
Контролер працює з HTTP-рівнем і делегує бізнес-логіку.
Провайдер містить бізнес-логіку та підключається через dependency injection.
imports підключає інші модулі.
exports відкриває провайдери для інших модулів.
Feature-модулі допомагають розділяти застосунок за предметними областями.
Кореневий AppModule переважно композиційно підключає функціональні модулі.
Чіткі межі модулів роблять NestJS-застосунок масштабованішим і простішим для тестування.