Пошук уроків, статей та іншого контенту
Побудуємо сервісний шар із бізнес-правилами, валідацією та взаємодією з репозиторієм.
Сервісний шар містить бізнес-правила застосунку. У NestJS його зазвичай представляє клас із декоратором @Injectable().
Сервіс:
приймає дані від контролера;
перевіряє бізнес-правила;
координує кілька операцій;
взаємодіє з репозиторієм;
повертає результат або повідомляє про помилку.
Контролер не повинен вирішувати, чи можна створювати товар, а репозиторій не повинен знати, які бізнес-правила діють у системі.
Наприклад:
контролер отримує HTTP-запит;
сервіс перевіряє назву та ціну товару;
сервіс перевіряє унікальність назви через репозиторій;
репозиторій зберігає товар.
Controller → Service → RepositoryСервісу не обов’язково знати, де саме зберігаються дані. Це може бути PostgreSQL, MongoDB або тимчасове сховище в пам’яті.
Для цього описуємо контракт репозиторію:
export interface Product {
id: string;
name: string;
price: number;
}
export interface ProductRepository {
findById(id: string): Promise<Product | null>;
findByName(name: string): Promise<Product | null>;
save(product: Product): Promise<Product>;
}Сервіс залежатиме від ProductRepository, а не від конкретного класу бази даних.
У NestJS інтерфейси TypeScript не існують під час виконання програми, тому їх не можна напряму використати як токен залежності. Для цього створюють окремий токен:
export const PRODUCT_REPOSITORY = Symbol('PRODUCT_REPOSITORY');Сервіс отримує репозиторій через конструктор:
@Injectable()
export class ProductsService {
constructor(
@Inject(PRODUCT_REPOSITORY)
private readonly productsRepository: ProductRepository,
) {}
}Тепер залежність можна замінити іншою реалізацією без змін у сервісі.
Валідація на рівні сервісу потрібна навіть тоді, коли контролер уже перевіряє структуру HTTP-запиту. Сервіс має гарантувати, що його правила не можна обійти при виклику з іншого місця.
Для товару встановимо такі правила:
Назва не може бути порожньою.
Ціна має бути скінченним числом, більшим за нуль.
Назва товару має бути унікальною.
Під час оновлення товар має існувати.
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
export interface Product {
id: string;
name: string;
price: number;
}
export interface ProductRepository {
findById(id: string): Promise<Product | null>;
findByName(name: string): Promise<Product | null>;
save(product: Product): Promise<Product>;
}
export const PRODUCT_REPOSITORY = Symbol('PRODUCT_REPOSITORY');
export class CreateProductDto {
name!: string;
price!: number;
}
export class UpdateProductDto {
name?: string;
price?: number;
}
@Injectable()
export class ProductsService {
constructor(
@Inject(PRODUCT_REPOSITORY)
private readonly productsRepository: ProductRepository,
) {}
async create(dto: CreateProductDto): Promise<Product> {
const name = this.normalizeName(dto.name);
this.validatePrice(dto.price);
const existingProduct = await this.productsRepository.findByName(name);
if (existingProduct) {
throw new ConflictException(
'Товар із такою назвою вже існує',
);
}
const product: Product = {
id: crypto.randomUUID(),
name,
price: dto.price,
};
return this.productsRepository.save(product);
}
async update(
id: string,
dto: UpdateProductDto,
): Promise<Product> {
const product = await this.productsRepository.findById(id);
if (!product) {
throw new NotFoundException('Товар не знайдено');
}
const name =
dto.name === undefined
? product.name
: this.normalizeName(dto.name);
const price =
dto.price === undefined
? product.price
: dto.price;
this.validatePrice(price);
if (name !== product.name) {
const productWithSameName =
await this.productsRepository.findByName(name);
if (
productWithSameName &&
productWithSameName.id !== product.id
) {
throw new ConflictException(
'Товар із такою назвою вже існує',
);
}
}
return this.productsRepository.save({
...product,
name,
price,
});
}
async findById(id: string): Promise<Product> {
const product = await this.productsRepository.findById(id);
if (!product) {
throw new NotFoundException('Товар не знайдено');
}
return product;
}
private normalizeName(name: string): string {
if (typeof name !== 'string') {
throw new BadRequestException(
'Назва товару має бути рядком',
);
}
const normalizedName = name.trim();
if (normalizedName.length < 2) {
throw new BadRequestException(
'Назва товару має містити щонайменше 2 символи',
);
}
return normalizedName;
}
private validatePrice(price: number): void {
if (
typeof price !== 'number' ||
!Number.isFinite(price) ||
price <= 0
) {
throw new BadRequestException(
'Ціна має бути числом, більшим за нуль',
);
}
}
}Методи normalizeName і validatePrice є приватними, оскільки вони реалізують внутрішні деталі сервісу. Зовнішній код працює з методами create, update та findById.
Сервіс може викидати стандартні винятки NestJS:
BadRequestException — вхідні дані порушують правила;
NotFoundException — потрібний ресурс не знайдено;
ConflictException — операція суперечить поточному стану системи.
NestJS автоматично перетворює ці винятки на HTTP-відповіді:
{
"statusCode": 409,
"message": "Товар із такою назвою вже існує",
"error": "Conflict"
}Сервіс не повинен вручну формувати HTTP-відповіді. Його завдання — викинути відповідний виняток, а HTTP-шар NestJS обробить його автоматично.
Контролер залишається тонким: він приймає запит і делегує роботу сервісу.
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
} from '@nestjs/common';
@Controller('products')
export class ProductsController {
constructor(
private readonly productsService: ProductsService,
) {}
@Post()
create(@Body() dto: CreateProductDto): Promise<Product> {
return this.productsService.create(dto);
}
@Get(':id')
findById(@Param('id') id: string): Promise<Product> {
return this.productsService.findById(id);
}
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateProductDto,
): Promise<Product> {
return this.productsService.update(id, dto);
}
}Контролер не перевіряє унікальність назви та не викликає репозиторій напряму. Ці правила належать сервісному шару.
Для прикладу використаємо репозиторій, який зберігає дані в Map. У реальному застосунку його можна замінити реалізацією для бази даних.
import { Injectable } from '@nestjs/common';
@Injectable()
export class InMemoryProductsRepository
implements ProductRepository
{
private readonly products = new Map<string, Product>();
async findById(id: string): Promise<Product | null> {
return this.products.get(id) ?? null;
}
async findByName(name: string): Promise<Product | null> {
for (const product of this.products.values()) {
if (product.name === name) {
return product;
}
}
return null;
}
async save(product: Product): Promise<Product> {
this.products.set(product.id, product);
return product;
}
}Репозиторій виконує операції з даними, але не вирішує, чи дозволена операція.
Наприклад, репозиторій може зберегти товар із ціною -10, якщо його викликати напряму. Це не його відповідальність. Перевірка ціни виконується в ProductsService.
Токен PRODUCT_REPOSITORY пов’язує контракт із конкретною реалізацією:
import { Module } from '@nestjs/common';
@Module({
controllers: [ProductsController],
providers: [
ProductsService,
{
provide: PRODUCT_REPOSITORY,
useClass: InMemoryProductsRepository,
},
],
})
export class ProductsModule {}Тепер NestJS побудує такий ланцюжок залежностей:
ProductsController
↓
ProductsService
↓
PRODUCT_REPOSITORY → InMemoryProductsRepositoryЯкщо пізніше з’явиться реалізація для бази даних, достатньо змінити провайдер у модулі:
{
provide: PRODUCT_REPOSITORY,
useClass: DatabaseProductsRepository,
}ProductsService при цьому залишиться без змін.
Нижче наведено приклад усіх основних частин сервісного шару. Його можна розмістити в одному файлі src/main.ts у звичайному NestJS-проєкті.
import {
BadRequestException,
Body,
ConflictException,
Controller,
Get,
Inject,
Injectable,
Module,
NotFoundException,
Param,
Patch,
Post,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
interface Product {
id: string;
name: string;
price: number;
}
interface ProductRepository {
findById(id: string): Promise<Product | null>;
findByName(name: string): Promise<Product | null>;
save(product: Product): Promise<Product>;
}
const PRODUCT_REPOSITORY = Symbol('PRODUCT_REPOSITORY');
class CreateProductDto {
name!: string;
price!: number;
}
class UpdateProductDto {
name?: string;
price?: number;
}
@Injectable()
class InMemoryProductsRepository
implements ProductRepository
{
private readonly products = new Map<string, Product>();
async findById(id: string): Promise<Product | null> {
return this.products.get(id) ?? null;
}
async findByName(name: string): Promise<Product | null> {
for (const product of this.products.values()) {
if (product.name === name) {
return product;
}
}
return null;
}
async save(product: Product): Promise<Product> {
this.products.set(product.id, product);
return product;
}
}
@Injectable()
class ProductsService {
constructor(
@Inject(PRODUCT_REPOSITORY)
private readonly repository: ProductRepository,
) {}
async create(dto: CreateProductDto): Promise<Product> {
const name = this.normalizeName(dto.name);
this.validatePrice(dto.price);
const existingProduct = await this.repository.findByName(name);
if (existingProduct) {
throw new ConflictException(
'Товар із такою назвою вже існує',
);
}
return this.repository.save({
id: crypto.randomUUID(),
name,
price: dto.price,
});
}
async findById(id: string): Promise<Product> {
const product = await this.repository.findById(id);
if (!product) {
throw new NotFoundException('Товар не знайдено');
}
return product;
}
async update(
id: string,
dto: UpdateProductDto,
): Promise<Product> {
const product = await this.findById(id);
const name =
dto.name === undefined
? product.name
: this.normalizeName(dto.name);
const price =
dto.price === undefined
? product.price
: dto.price;
this.validatePrice(price);
const productWithSameName =
await this.repository.findByName(name);
if (
productWithSameName &&
productWithSameName.id !== product.id
) {
throw new ConflictException(
'Товар із такою назвою вже існує',
);
}
return this.repository.save({
...product,
name,
price,
});
}
private normalizeName(name: string): string {
if (typeof name !== 'string') {
throw new BadRequestException(
'Назва товару має бути рядком',
);
}
const normalizedName = name.trim();
if (normalizedName.length < 2) {
throw new BadRequestException(
'Назва товару має містити щонайменше 2 символи',
);
}
return normalizedName;
}
private validatePrice(price: number): void {
if (
typeof price !== 'number' ||
!Number.isFinite(price) ||
price <= 0
) {
throw new BadRequestException(
'Ціна має бути числом, більшим за нуль',
);
}
}
}
@Controller('products')
class ProductsController {
constructor(
private readonly productsService: ProductsService,
) {}
@Post()
create(@Body() dto: CreateProductDto): Promise<Product> {
return this.productsService.create(dto);
}
@Get(':id')
findById(@Param('id') id: string): Promise<Product> {
return this.productsService.findById(id);
}
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateProductDto,
): Promise<Product> {
return this.productsService.update(id, dto);
}
}
@Module({
controllers: [ProductsController],
providers: [
ProductsService,
{
provide: PRODUCT_REPOSITORY,
useClass: InMemoryProductsRepository,
},
],
})
class AppModule {}
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
void bootstrap();Після запуску застосунку можна створити товар запитом:
POST /products
Content-Type: application/json
{
"name": "Механічна клавіатура",
"price": 2499
}Якщо повторити запит із такою самою назвою, сервіс поверне помилку конфлікту. Якщо передати від’ємну ціну, буде повернуто помилку валідації.
Якщо один бізнес-сценарій змінює кілька ресурсів, саме сервіс зазвичай координує ці зміни.
Наприклад, оформлення замовлення може містити такі кроки:
знайти товар;
перевірити його доступність;
створити замовлення;
зменшити залишок товару.
Контролер не повинен виконувати ці кроки самостійно. Сервіс оркеструє операції та визначає, у якому порядку вони виконуються.
Якщо для операцій потрібна транзакція бази даних, її також слід організовувати на рівні сервісного сценарію або відповідного інфраструктурного компонента, а не в контролері.
Погано:
@Post()
async create(@Body() dto: CreateProductDto) {
if (dto.price <= 0) {
throw new BadRequestException('Некоректна ціна');
}
return this.repository.save(dto);
}У такому випадку правило працює лише для цього HTTP-методу. Інший контролер або фонове завдання може обійти його.
Краще передати операцію сервісу:
@Post()
create(@Body() dto: CreateProductDto) {
return this.productsService.create(dto);
}Контролер не повинен напряму працювати з базою даних або репозиторієм. Це створює сильну залежність HTTP-шару від способу зберігання даних.
Репозиторій відповідає за читання та запис. Перевірка унікальності в конкретному запиті може бути його технічною операцією, але рішення «чи дозволено створити товар» належить сервісу.
Оновлення частини полів не означає, що нові значення не потрібно перевіряти. Сервіс має сформувати повний новий стан об’єкта й перевірити його перед збереженням.
Перевірка форми даних і перевірка бізнес-правил — різні завдання.
Приклади структурної перевірки:
поле name має бути рядком;
поле price має бути числом;
поле є обов’язковим.
Приклади бізнес-перевірки:
назва має бути унікальною;
ціна не може бути нульовою;
товар має існувати перед оновленням.
Навіть якщо структурна валідація виконується на вході, ключові бізнес-інваріанти потрібно захищати в сервісі.
Сервісний шар містить бізнес-правила та координує сценарії застосунку.
Контролер приймає запит і делегує роботу сервісу.
Репозиторій відповідає за доступ до даних.
Залежність сервісу від репозиторію краще описувати через інтерфейс і токен.
Бізнес-валідація має виконуватися в сервісі, щоб правила не можна було обійти.
BadRequestException, NotFoundException і ConflictException допомагають передавати помилки до HTTP-рівня NestJS.
Завдяки dependency injection реалізацію репозиторію можна замінити без змін у бізнес-логіці.