DTO, мапперы и слой анти-коррупции

Разделяем, что система отдаёт наружу и чем оперирует внутри, и ставим «переводчик» на границе с чужими сервисами.

DTO (Data Transfer Object) — простой объект-контейнер без поведения, чья задача — перенести данные через границу (по сети, между слоями). Anti-Corruption Layer (ACL) — прослойка-переводчик, которая не пускает чужую модель данных в ваш домен, конвертируя её в ваши понятия.

Зачем это нужно на практике

Доменная модель внутри приложения богатая: у неё есть поведение, инварианты, иногда секреты (хэш пароля, внутренние флаги). Отдавать её as-is наружу — в JSON-ответ API — опасно и неудобно: можно случайно слить лишнее поле, а любая правка внутренней модели ломает контракт API. DTO разрывает эту связь: наружу уходит отдельный плоский объект ровно с теми полями, что нужны клиенту. Внутреннюю модель тогда можно рефакторить свободно — внешний контракт остаётся стабильным.

ACL решает зеркальную проблему — входящие данные из чужих систем. Сторонний платёжный шлюз или legacy-сервис присылает данные в своей кривой модели: суммы в копейках, статусы кодами вроде "st": "OK", поля с непонятными именами. Если пустить это прямо в домен, чужие соглашения «заразят» ваш код. ACL ставит на границе переводчик, который превращает чужую модель в вашу чистую — и домен остаётся незапятнанным.

DTO: контракт наружу

DTO — это «слепок данных для передачи». Без методов бизнес-логики, без секретов: только то, что вы согласились отдавать. Маппер (часто называемый assembler) собирает DTO из доменного объекта.

from dataclasses import dataclass

class User:                       # доменная модель: есть секреты и поведение
    def __init__(self, id, name, password_hash, is_admin):
        self.id = id
        self.name = name
        self.password_hash = password_hash   # НЕ должно утечь в API
        self.is_admin = is_admin
    def display_role(self):
        return "админ" if self.is_admin else "пользователь"

@dataclass
class UserDTO:                    # ровно то, что отдаём наружу
    id: int
    name: str
    role: str

def to_dto(user):                # маппер домен -> DTO
    return UserDTO(id=user.id, name=user.name, role=user.display_role())

u = User(1, "Аня", "5f4dcc3b...", is_admin=True)
dto = to_dto(u)
print(dto)
print("Хэш пароля в DTO отсутствует:", not hasattr(dto, "password_hash"))

Вывод:

UserDTO(id=1, name='Аня', role='админ')
Хэш пароля в DTO отсутствует: True

password_hash физически не попал в DTO — утечь нечему. А is_admin превратился в человекочитаемое role: наружу мы отдаём представление, а не сырое внутреннее поле. Если завтра внутри переименуем is_admin в permissions, контракт API не дрогнет — поменяется лишь маппер.

Маппер как явный слой

Соблазн — сериализовать доменный объект «как есть» (например, asdict(user) или общий сериализатор по всем полям). Это удобно ровно до первого инцидента: добавили внутреннее поле — и оно утекло в ответ. Поэтому маппер делают явным: он перечисляет поля поимённо. Да, это «лишний» код, но именно он — точка контроля над тем, что покидает систему. В крупных проектах мапперы группируют по направлению: домен в DTO для ответов, DTO запроса в домен/команду для входящих данных.

Anti-Corruption Layer: переводчик на границе

Теперь входящая сторона. Чужой сервис говорит на своём языке — ACL переводит его на ваш, и ни одна чужая структура не просачивается дальше границы.

from dataclasses import dataclass

external_payment = {            # как отдаёт ЧУЖОЙ сервис
    "txn_id": "PMT-0099",
    "amt_cents": 39000,         # сумма в копейках
    "cur": "RUB",
    "st": "OK",                 # их коды статусов
}

@dataclass
class Payment:                  # НАША чистая модель
    id: str
    amount_rub: float
    is_paid: bool

STATUS = {"OK": True, "FAIL": False, "PENDING": False}

class PaymentACL:               # переводчик между мирами
    def translate(self, raw):
        return Payment(
            id=raw["txn_id"],
            amount_rub=raw["amt_cents"] / 100,
            is_paid=STATUS.get(raw["st"], False),
        )

payment = PaymentACL().translate(external_payment)
print(payment)
print("Сумма в рублях:", payment.amount_rub)

Вывод:

Payment(id='PMT-0099', amount_rub=390.0, is_paid=True)
Сумма в рублях: 390.0

Дальше границы живёт только наш Payment с понятными полями: рубли вместо копеек, is_paid вместо загадочного "st". Если чужой сервис сменит формат — поправить нужно один класс PaymentACL, а не весь домен. Это и есть «защита от коррупции»: чужие соглашения остаются за стеной.

Как это работает под капотом

В реальных стеках DTO часто оформляют декларативно. В Python это Pydantic-модели (FastAPI отдаёт и принимает именно их как response_model), в Java — отдельные record-классы рядом с JPA-сущностями, в .NET — view-models. Внутри всё равно есть маппер: вручную, либо через генераторы вроде MapStruct (Java) или AutoMapper (.NET). Принцип неизменен — наружный объект отделён от внутреннего.

ACL — понятие из Domain-Driven Design. Архитектурно его реализуют как набор адаптеров и переводчиков на границе bounded context: входящие данные проходят через них и нормализуются. Часто ACL сочетают с паттерном Adapter (приведение интерфейса) и Facade (упрощённый вход в подсистему). Главное — провести чёткую линию: «всё, что приходит извне, переводится здесь, и ни строкой дальше».

Частые ошибки

  • Отдавать доменную модель напрямую в API. Рано или поздно утечёт лишнее поле или поломается контракт при рефакторинге. DTO существует именно чтобы развязать эти две вещи.
  • «Анемичные» DTO везде вместо домена. DTO — для передачи через границу, а не для бизнес-логики. Если по всему коду гоняются плоские DTO без поведения, домен размывается. Внутри работайте с богатыми объектами.
  • ACL, который протекает. Если чужие поля (amt_cents, "st") всё-таки доходят до сервисов, защита фиктивна. Перевод обязан быть полным на самой границе.
  • Дублирование маппинга. Один и тот же перевод, размазанный по контроллерам, рассинхронизируется. Соберите маппинг в одном месте (маппер/ACL) и переиспользуйте.

Итоги

  • DTO — плоский объект для передачи данных через границу; наружу отдаём его, а не доменную модель.
  • Явный маппер — точка контроля над тем, какие поля покидают систему; не сериализуйте домен «как есть».
  • Anti-Corruption Layer переводит чужую модель в вашу на границе, не пуская чужие соглашения в домен.
  • На практике это Pydantic/record/view-model для DTO и адаптеры-переводчики для ACL — приём из DDD.
Проверьте себя
1. Зачем отдавать наружу DTO вместо самой доменной модели?
ADTO работает быстрее доменной модели
BЧтобы развязать внешний контракт от внутренней модели и не сливать лишние/секретные поля
CДоменную модель нельзя сериализовать в JSON
DDTO автоматически шифрует данные
2. Какую задачу решает Anti-Corruption Layer?
AШифрует трафик между микросервисами
BПереводит чужую модель данных в вашу на границе, не пуская чужие соглашения в домен
CКэширует ответы внешних сервисов
DБалансирует нагрузку между репликами БД