Частая ошибка в .NET-проектах — использовать одни и те же модели на всех слоях. Контроллер принял
CreateUserRequest, передал его прямо в сервис, сервис вернул UserResponse обратно в контроллер. Выглядит просто. Но это ловушка.REST DTO это контракт с клиентом. Сервисный слой это бизнес-логика. Это разные ответственности.
Пример плохого кода:
// REST DTO
public class CreateUserRequest
{
public string Email { get; set; }
public string Password { get; set; }
public string Role { get; set; } // "admin", "user"
}
// Контроллер передаёт REST DTO прямо в сервис
[HttpPost]
public async Task<IActionResult> Create(CreateUserRequest request)
{
var user = await _userService.CreateAsync(request); // не надо
return Ok(user);
}
// Сервис знает о REST DTO
public async Task<UserResponse> CreateAsync(CreateUserRequest request)
{
// бизнес-логика завязана на HTTP-контракт
}
Теперь представьте, что появился второй клиент. Он шлёт другой формат. Или вы хотите вызвать
CreateAsync из фоновой задачи, где нет никакого HTTP-запроса. Приходится либо тащить ненужный DTO, либо переписывать сервис.Ещё хуже поле
Role в CreateUserRequest. Клиент сам указывает, кем хочет стать. Даже если оно не используется или перезаписывается далее, выглядит это не очень.Как правильно
Каждый слой работает со своей моделью. Контроллер маппит входящий DTO в команду или модель сервисного слоя. Сервис возвращает доменный результат. Контроллер маппит его в ответный DTO:
// REST DTO — только для HTTP-слоя
public class CreateUserRequest
{
public string Email { get; set; }
public string Password { get; set; }
// Role здесь нет — клиент не решает
}
public class UserResponse
{
public Guid Id { get; set; }
public string Email { get; set; }
public string Role { get; set; }
}
// Сервисная модель — внутренний контракт
public class CreateUserCommand
{
public string Email { get; set; }
public string Password { get; set; }
public UserRole Role { get; set; } // enum, не строка
}
public class UserResult
{
public Guid Id { get; set; }
public string Email { get; set; }
public UserRole Role { get; set; }
}
// Контроллер — маппит и не лезет в логику
[HttpPost]
public async Task<IActionResult> Create(CreateUserRequest request)
{
var command = new CreateUserCommand
{
Email = request.Email,
Password = request.Password,
Role = UserRole.User // роль задаётся здесь, не клиентом
};
var result = await _userService.CreateAsync(command);
var response = new UserResponse
{
Id = result.Id,
Email = result.Email,
Role = result.Role.ToString()
};
return Ok(response);
}
// Сервис — ничего не знает про HTTP
public async Task<UserResult> CreateAsync(CreateUserCommand command)
{
// чистая бизнес-логика
}
Почему это важно
Сервис перестаёт зависеть от формата HTTP-запроса. Его можно вызвать из воркера, gRPC-эндпоинта, теста без изменений.
Контракт с клиентом можно менять независимо от внутренней логики. Добавили поле в
CreateUserRequest, сервис об этом не знает.Валидация и маппинг живут в одном месте — в контроллере. Сервис получает уже проверенные данные в нужном формате.
Для маппинга удобно использовать
AutoMapper или Mapster, но даже ручной маппинг лучше, чем слитые слои.Граница между HTTP-слоем и бизнес-логикой — это не формальность. Это то, что позволяет менять одно, не трогая другое.
📍 Навигация: Вакансии • Задачи • Собесы
🐸 Библиотека шарписта
#il_люминатор