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.