DTO и маппинг: не пускаем внешний мир в домен
DTO и маппинг: не пускаем внешний мир в домен
В гексагональной архитектуре каждый слой работает со своими моделями данных. HTTP handler оперирует JSON-структурами, база данных - строками таблиц, а домен - entity и value objects. DTO (Data Transfer Object) - это структуры-переносчики, которые помогают преобразовывать данные между слоями.
Три модели для одной сущности
Рассмотрим пользователя. У него три представления:
// 1. HTTP Request DTO - то, что приходит от клиента
type CreateUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
Password string `json:"password"`
}
// 2. Domain Model - ядро бизнес-логики
type User struct {
ID int64
Email string
Name string
PasswordHash string
CreatedAt time.Time
IsActive bool
}
// 3. HTTP Response DTO - то, что уходит клиенту
type UserResponse struct {
ID int64 `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
}
<?php
// src/UserInterface/Http/Dto/CreateUserRequest.php
declare(strict_types=1);
namespace App\UserInterface\Http\Dto;
// 1. HTTP Request DTO - то, что приходит от клиента
final readonly class CreateUserRequest
{
public function __construct(
public string $email,
public string $name,
public string $password,
) {}
}
// 2. Domain Model - ядро бизнес-логики
namespace App\Domain\User;
final class User
{
public function __construct(
private readonly int $id,
private readonly string $email,
private readonly string $name,
private readonly string $passwordHash,
private readonly \DateTimeImmutable $createdAt,
private bool $isActive,
) {}
public function id(): int { return $this->id; }
public function email(): string { return $this->email; }
public function name(): string { return $this->name; }
public function createdAt(): \DateTimeImmutable { return $this->createdAt; }
}
// 3. HTTP Response DTO - то, что уходит клиенту
namespace App\UserInterface\Http\Dto;
final readonly class UserResponse
{
public function __construct(
public int $id,
public string $email,
public string $name,
public string $createdAt,
) {}
}
Обрати внимание на различия:
- Request содержит
Password(открытый текст), домен хранитPasswordHash - Домен содержит
IsActive, но в response это поле не отдаётся - Response форматирует
CreatedAtкак строку, домен хранитtime.Time
Поток данных: Request → Domain → Response
Полный цикл обработки HTTP-запроса выглядит так:
Функции маппинга
Маппинг - это преобразование одной структуры в другую. В Go это обычные функции, в PHP - статические методы-фабрики на DTO или отдельный mapper-сервис:
// adapter/http/mapper.go
package http
import (
"myapp/internal/domain"
)
// toDomain преобразует HTTP-запрос в доменную модель
func toDomainUser(req CreateUserRequest, passwordHash string) *domain.User {
return &domain.User{
Email: req.Email,
Name: req.Name,
PasswordHash: passwordHash,
}
}
// toResponse преобразует доменную модель в HTTP-ответ
func toUserResponse(u *domain.User) UserResponse {
return UserResponse{
ID: u.ID,
Email: u.Email,
Name: u.Name,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
}
}
// toListResponse - для списков
func toUserListResponse(users []*domain.User) []UserResponse {
result := make([]UserResponse, 0, len(users))
for _, u := range users {
result = append(result, toUserResponse(u))
}
return result
}
<?php
// src/UserInterface/Http/Mapper/UserMapper.php
declare(strict_types=1);
namespace App\UserInterface\Http\Mapper;
use App\Domain\User\User;
use App\UserInterface\Http\Dto\CreateUserRequest;
use App\UserInterface\Http\Dto\UserResponse;
final class UserMapper
{
// toDomain преобразует HTTP-запрос в доменную модель
public static function toDomain(CreateUserRequest $req, string $passwordHash): User
{
return new User(
id: 0,
email: $req->email,
name: $req->name,
passwordHash: $passwordHash,
createdAt: new \DateTimeImmutable(),
isActive: true,
);
}
// toResponse преобразует доменную модель в HTTP-ответ
public static function toResponse(User $u): UserResponse
{
return new UserResponse(
id: $u->id(),
email: $u->email(),
name: $u->name(),
createdAt: $u->createdAt()->format(\DateTimeInterface::RFC3339),
);
}
/**
* toListResponse - для списков
*
* @param User[] $users
* @return UserResponse[]
*/
public static function toListResponse(array $users): array
{
return array_map(fn (User $u): UserResponse => self::toResponse($u), $users);
}
}
В Symfony эту же задачу решает Serializer с #[Groups] - помечаешь поля, какие сериализуются для запроса, какие для ответа, и нормализатор делает маппинг автоматически. Mapper-классы остаются полезны, когда у DTO и Domain объекта разные имена полей или нужны вычисляемые поля.
Handler использует эти функции для преобразования:
func (h *RegisterHandler) Handle(w http.ResponseWriter, r *http.Request) {
// 1. Декодируем JSON в Request DTO
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid json")
return
}
// 2. Вызываем use-case (передаём Input, не DTO и не *http.Request)
out, err := h.registerUC.Execute(r.Context(), app.RegisterInput{
Email: req.Email,
Name: req.Name,
Password: req.Password,
})
if err != nil {
handleError(w, err)
return
}
// 3. Маппим результат в Response DTO
resp := toUserResponse(out.User)
respondJSON(w, http.StatusCreated, resp)
}
<?php
// src/UserInterface/Http/Controller/RegisterController.php
declare(strict_types=1);
namespace App\UserInterface\Http\Controller;
use App\Application\UseCase\RegisterInput;
use App\Application\UseCase\RegisterUseCase;
use App\UserInterface\Http\Dto\CreateUserRequest;
use App\UserInterface\Http\Mapper\UserMapper;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\SerializerInterface;
final class RegisterController
{
public function __construct(
private readonly RegisterUseCase $registerUC,
private readonly SerializerInterface $serializer,
) {}
public function __invoke(Request $request): JsonResponse
{
// 1. Декодируем JSON в Request DTO
$req = $this->serializer->deserialize(
$request->getContent(),
CreateUserRequest::class,
'json',
);
// 2. Вызываем use-case (передаём Input, не DTO и не Request)
$out = $this->registerUC->execute(new RegisterInput(
email: $req->email,
name: $req->name,
password: $req->password,
));
// 3. Маппим результат в Response DTO
$resp = UserMapper::toResponse($out->user);
return new JsonResponse($resp, Response::HTTP_CREATED);
}
}
DB-модели: ещё один слой маппинга
Помимо HTTP DTO, существуют модели базы данных. Если ты используешь GORM или sqlx (Go), Doctrine ORM или DBAL (PHP), у тебя наверняка есть struct/класс с тегами db: или #[Column]:
// adapter/postgres/model.go
package postgres
import "time"
type userRow struct {
ID int64 `db:"id"`
Email string `db:"email"`
Name string `db:"name"`
PasswordHash string `db:"password_hash"`
IsActive bool `db:"is_active"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func toUserRow(u *domain.User) userRow {
return userRow{
ID: u.ID,
Email: u.Email,
Name: u.Name,
PasswordHash: u.PasswordHash,
IsActive: u.IsActive,
CreatedAt: u.CreatedAt,
UpdatedAt: time.Now(),
}
}
func (r userRow) toDomain() *domain.User {
return &domain.User{
ID: r.ID,
Email: r.Email,
Name: r.Name,
PasswordHash: r.PasswordHash,
IsActive: r.IsActive,
CreatedAt: r.CreatedAt,
}
}
<?php
// src/Infrastructure/Persistence/Doctrine/UserRow.php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Doctrine;
use App\Domain\User\User;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'users')]
final class UserRow
{
#[ORM\Id]
#[ORM\Column(name: 'id', type: Types::BIGINT)]
public int $id = 0;
#[ORM\Column(name: 'email', type: Types::STRING)]
public string $email = '';
#[ORM\Column(name: 'name', type: Types::STRING)]
public string $name = '';
#[ORM\Column(name: 'password_hash', type: Types::STRING)]
public string $passwordHash = '';
#[ORM\Column(name: 'is_active', type: Types::BOOLEAN)]
public bool $isActive = true;
#[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)]
public \DateTimeImmutable $createdAt;
#[ORM\Column(name: 'updated_at', type: Types::DATETIME_IMMUTABLE)]
public \DateTimeImmutable $updatedAt;
}
// src/Infrastructure/Persistence/Doctrine/UserRowMapper.php
final class UserRowMapper
{
public static function fromDomain(User $u): UserRow
{
$row = new UserRow();
$row->id = $u->id();
$row->email = $u->email();
$row->name = $u->name();
$row->passwordHash = $u->passwordHash();
$row->isActive = $u->isActive();
$row->createdAt = $u->createdAt();
$row->updatedAt = new \DateTimeImmutable();
return $row;
}
public static function toDomain(UserRow $row): User
{
return new User(
id: $row->id,
email: $row->email,
name: $row->name,
passwordHash: $row->passwordHash,
createdAt: $row->createdAt,
isActive: $row->isActive,
);
}
}
Зачем отдельные DB-модели? Потому что:
- Доменная модель не должна зависеть от ORM-тегов (
gorm:"column:...") - Имена полей в БД (
password_hash) могут отличаться от имён в домене - В БД могут быть поля (
updated_at), которых нет в домене - При смене ORM или переходе на raw SQL меняется только адаптер
Полная картина: 4 модели
В полноценном hex-приложении данные проходят через 4 структуры. Это выглядит как много кода. Но каждая структура существует по конкретной причине и защищает границу между слоями.
Когда маппинг избыточен
Не каждому проекту нужны 4 слоя моделей:
- Микросервис с 2-3 эндпоинтами - можно обойтись без отдельных DB-моделей
- Внутренний инструмент без публичного API - Request DTO может совпадать с Input
- Прототип - используй доменную модель везде, разделишь когда стабилизируется
Начинай с минимума и добавляй слои, когда появляется реальная необходимость: поля начинают расходиться, тесты становятся хрупкими, или утекают внутренние данные.
Мини-задание
- Выбери один endpoint в своём проекте и создай для него Request DTO и Response DTO
- Напиши функции
toDomain()иtoResponse()для маппинга - Убедись, что handler не передаёт
*http.Requestв use-case - Проверь: не утекают ли чувствительные поля (пароль, внутренние флаги) в JSON-ответ?