Пошук уроків, статей та іншого контенту
Опишете структуру відповідей за допомогою DTO та відокремите публічний контракт від моделей бази даних.
Response DTO — це клас, який описує дані, що API повертає клієнту.
Наприклад, внутрішня модель користувача може містити:
пароль або його хеш;
службові поля;
технічні ідентифікатори;
дані, які не повинні бути доступні клієнту.
Клієнту натомість потрібен обмежений публічний об’єкт:
{
"id": 1,
"name": "Олена",
"email": "olena@example.com"
}Для цього створюють окремий DTO відповіді:
export class UserResponseDto {
id: number;
name: string;
email: string;
}Такий DTO визначає публічний контракт API: які поля доступні клієнту та в якому форматі.
Модель бази даних описує внутрішню структуру застосунку. Вона не обов’язково збігається зі структурою, яку потрібно надати клієнту.
Наприклад, внутрішня модель може мати такий вигляд:
class UserEntity {
id: number;
name: string;
email: string;
passwordHash: string;
createdAt: Date;
isBlocked: boolean;
}Якщо повернути цей об’єкт безпосередньо з контролера, можна випадково розкрити:
passwordHash;
службове поле isBlocked;
дату у форматі, який не передбачений API;
поля, які згодом з’являться в моделі бази даних.
Крім того, зміна моделі бази даних тоді автоматично впливатиме на API. Це створює сильну залежність між внутрішньою реалізацією та публічним контрактом.
Response DTO допомагає розділити ці відповідальності:
Модель бази даних → сервіс → перетворення → Response DTO → клієнтDTO є звичайним TypeScript-класом із властивостями, які дозволено повертати клієнту.
export class UserResponseDto {
constructor(
public readonly id: number,
public readonly name: string,
public readonly email: string,
) {}
}У цьому прикладі DTO містить лише три публічні поля. Внутрішні поля користувача в ньому відсутні.
readonly забороняє змінювати властивості після створення об’єкта в TypeScript. Це корисно для DTO, оскільки відповідь зазвичай формується один раз і більше не змінюється.
Найпростіший і найпрозоріший спосіб — створити DTO у сервісі або окремій функції-мапері.
function toUserResponse(user: UserEntity): UserResponseDto {
return new UserResponseDto(
user.id,
user.name,
user.email,
);
}Тут кожне поле вибирається явно. passwordHash, createdAt та isBlocked не потраплять у відповідь випадково.
Нижче наведено повний приклад для NestJS. Він використовує об’єкт у пам’яті замість справжньої бази даних, але принцип перетворення залишається таким самим.
import {
Controller,
Get,
Module,
NotFoundException,
Param,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
class UserEntity {
constructor(
public readonly id: number,
public readonly name: string,
public readonly email: string,
public readonly passwordHash: string,
public readonly createdAt: Date,
public readonly isBlocked: boolean,
) {}
}
export class UserResponseDto {
constructor(
public readonly id: number,
public readonly name: string,
public readonly email: string,
) {}
}
class UsersService {
private readonly users: UserEntity[] = [
new UserEntity(
1,
'Олена',
'olena@example.com',
'secret-password-hash',
new Date('2024-01-15T10:00:00.000Z'),
false,
),
];
findById(id: number): UserEntity {
const user = this.users.find((item) => item.id === id);
if (!user) {
throw new NotFoundException('Користувача не знайдено');
}
return user;
}
}
@Controller('users')
class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
getUser(@Param('id') id: string): UserResponseDto {
const user = this.usersService.findById(Number(id));
return new UserResponseDto(
user.id,
user.name,
user.email,
);
}
}
@Module({
controllers: [UsersController],
providers: [UsersService],
})
class AppModule {}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();Після запуску запит:
GET http://localhost:3000/users/1поверне:
{
"id": 1,
"name": "Олена",
"email": "olena@example.com"
}Поле passwordHash не потрапляє у відповідь, оскільки воно не використовується під час створення UserResponseDto.
Коли перетворення стає довшим, його зручно винести з контролера в окрему функцію.
function toUserResponse(user: UserEntity): UserResponseDto {
return new UserResponseDto(
user.id,
user.name,
user.email,
);
}Тоді контролер відповідає лише за обробку HTTP-запиту:
@Get(':id')
getUser(@Param('id') id: string): UserResponseDto {
const user = this.usersService.findById(Number(id));
return toUserResponse(user);
}Це робить код контролера коротшим і дозволяє повторно використовувати однакове перетворення в кількох місцях.
Для списку об’єктів використовують масив response DTO:
@Get()
getUsers(): UserResponseDto[] {
const users = this.usersService.findAll();
return users.map(toUserResponse);
}Відповідь матиме форму:
[
{
"id": 1,
"name": "Олена",
"email": "olena@example.com"
},
{
"id": 2,
"name": "Андрій",
"email": "andrii@example.com"
}
]Кожен елемент масиву проходить через той самий мапер, тому внутрішні поля не потрапляють до жодного об’єкта відповіді.
Response DTO може містити інший DTO. Наприклад, відповідь профілю може містити публічні дані користувача та статистику:
export class UserStatisticsResponseDto {
constructor(
public readonly postsCount: number,
public readonly commentsCount: number,
) {}
}
export class UserProfileResponseDto {
constructor(
public readonly user: UserResponseDto,
public readonly statistics: UserStatisticsResponseDto,
) {}
}Формування відповіді:
const response = new UserProfileResponseDto(
new UserResponseDto(user.id, user.name, user.email),
new UserStatisticsResponseDto(12, 48),
);JSON-відповідь:
{
"user": {
"id": 1,
"name": "Олена",
"email": "olena@example.com"
},
"statistics": {
"postsCount": 12,
"commentsCount": 48
}
}Кожна частина відповіді має власний DTO, тому її структуру легко зрозуміти та змінювати незалежно.
Тип повернення методу контролера варто вказувати явно:
@Get(':id')
getUser(@Param('id') id: string): UserResponseDto {
// ...
}Для списку:
@Get()
getUsers(): UserResponseDto[] {
// ...
}Це допомагає TypeScript перевірити, що метод повертає правильну структуру. Наприклад, компілятор повідомить про помилку, якщо код спробує повернути об’єкт без обов’язкового поля email.
Водночас важливо розуміти: типізація TypeScript працює під час розробки та компіляції. Самого типу повернення недостатньо, щоб автоматично видалити зайві поля з об’єкта. Для цього потрібно явно створювати DTO або застосовувати окреме перетворення.
Response DTO описує не модель бази даних, а домовленість між сервером і клієнтом.
Наприклад, усередині застосунку дата може зберігатися як Date, а API може повертати її як ISO-рядок:
export class UserResponseDto {
constructor(
public readonly id: number,
public readonly name: string,
public readonly email: string,
public readonly registeredAt: string,
) {}
}
function toUserResponse(user: UserEntity): UserResponseDto {
return new UserResponseDto(
user.id,
user.name,
user.email,
user.createdAt.toISOString(),
);
}Тепер клієнт завжди отримує дату в узгодженому форматі:
{
"id": 1,
"name": "Олена",
"email": "olena@example.com",
"registeredAt": "2024-01-15T10:00:00.000Z"
}Зміна способу зберігання дати в базі даних не обов’язково змінить API. Достатньо оновити перетворення в одному місці.
@Get(':id')
getUser(@Param('id') id: string) {
return this.usersService.findById(Number(id));
}Такий код може випадково повернути всі поля моделі, зокрема passwordHash.
Краще явно сформувати DTO:
@Get(':id')
getUser(@Param('id') id: string): UserResponseDto {
const user = this.usersService.findById(Number(id));
return toUserResponse(user);
}DTO для створення користувача та DTO відповіді мають різне призначення.
Під час створення користувач може передати:
{
"name": "Олена",
"email": "olena@example.com",
"password": "strong-password"
}А у відповіді пароль повертати не можна:
{
"id": 1,
"name": "Олена",
"email": "olena@example.com"
}Тому для вхідних даних і для відповіді створюють різні DTO.
return {
...user,
};Цей підхід переносить усі поля об’єкта, включно з внутрішніми. Навіть якщо зараз модель має лише безпечні поля, пізніше до неї можуть додати секретне поле, яке автоматично почне повертатися API.
Краще перелічувати публічні поля явно:
return new UserResponseDto(
user.id,
user.name,
user.email,
);Такий запис:
getUser(): UserResponseDto {
return user as UserResponseDto;
}не перетворює об’єкт і не видаляє зайві властивості. as UserResponseDto лише повідомляє TypeScript, що розробник вважає об’єкт потрібного типу.
Безпечніше створити новий DTO та скопіювати до нього лише потрібні значення.
Response DTO описує структуру даних, які API повертає клієнту.
Модель бази даних і публічний DTO мають різні призначення.
Не слід повертати модель бази даних безпосередньо з контролера.
Явне створення DTO запобігає витоку внутрішніх полів.
Функція-мапер перетворює внутрішню модель на публічний об’єкт.
Для списків використовують UserResponseDto[].
Для вкладених відповідей створюють окремі DTO.
Тип повернення методу контролера допомагає перевірити структуру під час компіляції, але сам по собі не очищає об’єкт від зайвих полів.