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
Если использовать `domain.User` как JSON-ответ, клиент получит `password_hash` и `is_active`. Это утечка внутренних деталей и потенциальная проблема безопасности. DTO - это фильтр между доменом и внешним миром.

Поток данных: Request → Domain → Response

Полный цикл обработки HTTP-запроса выглядит так:

Поток данных HTTP: Client JSON через Request DTO в domain и обратно через Response DTO

Функции маппинга

Маппинг - это преобразование одной структуры в другую. В 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);
    }
}
Use-case не должен знать о HTTP. Если ты передаёшь `*http.Request` или `http.ResponseWriter` за пределы handler - ты нарушаешь границу слоёв. Use-case принимает свою Input-структуру с примитивными типами.

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 меняется только адаптер
В простых CRUD-сервисах (3-5 полей, никакой бизнес-логики) отдельные DB-модели - лишний код. Если доменная модель = строка таблицы один-в-один, можно добавить `db:` теги прямо на доменную структуру. Но как только появляются различия - разделяй.

Полная картина: 4 модели

Поток данных через четыре модели: Request DTO, Domain, DB row, Response DTO

В полноценном hex-приложении данные проходят через 4 структуры. Это выглядит как много кода. Но каждая структура существует по конкретной причине и защищает границу между слоями.

Когда маппинг избыточен

Не каждому проекту нужны 4 слоя моделей:

  • Микросервис с 2-3 эндпоинтами - можно обойтись без отдельных DB-моделей
  • Внутренний инструмент без публичного API - Request DTO может совпадать с Input
  • Прототип - используй доменную модель везде, разделишь когда стабилизируется

Начинай с минимума и добавляй слои, когда появляется реальная необходимость: поля начинают расходиться, тесты становятся хрупкими, или утекают внутренние данные.

Мини-задание

  • Выбери один endpoint в своём проекте и создай для него Request DTO и Response DTO
  • Напиши функции toDomain() и toResponse() для маппинга
  • Убедись, что handler не передаёт *http.Request в use-case
  • Проверь: не утекают ли чувствительные поля (пароль, внутренние флаги) в JSON-ответ?

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.