Паттерны границ: DTO, мэпперы и отсутствующее значение
У любой системы есть периметр, и на нём происходят две вещи, которые в остальном коде не происходят
никогда. Внутрь приходят данные, которым нельзя верить: поле пришло строкой вместо числа, дата
в неизвестном формате, обязательного ключа нет, а лишних ключей семь. Наружу уходят структуры,
на которые немедленно начинают полагаться: как только вы отдали JSON с полем total, оно стало
контрактом, и переименовать его вы уже не можете.
Внутренние главы трека — структурные, поведенческие — рассматривали объекты, которые уже валидны. Эта глава про то, как объект становится валидным, и что делать с тем, что валидным сделать не удалось.
Практическая мотивация простая. Подавляющее большинство продакшн-падений с трассировкой вида
AttributeError: 'NoneType' object has no attribute ... или TypeError: unsupported operand —
это не ошибки внутренней логики. Это данные с границы, которые прошли внутрь без проверки и
взорвались через три слоя от места входа, где восстановить причину уже дорого.
Три представления одного заказа
байты, JSON, protobuf
структуры нет, есть формат"] -->|десериализация| D["DTO
поля есть, типы базовые
инвариантов НЕТ"] D -->|разбор + проверка| M["Доменный объект
инварианты гарантированы
невалидное состояние непредставимо"] M -->|формирование ответа| D2["DTO ответа
стабильный контракт наружу"] D2 -->|сериализация| W2["Провод"] D -.->|"ошибки разбора"| E["Отчёт об ошибках
все сразу, с путями к полям"]
Ключевая мысль, ради которой рисуется эта схема: DTO и доменный объект — разные вещи, потому что у них разные причины меняться. DTO меняется, когда меняется контракт с внешним миром. Доменный объект меняется, когда меняются правила бизнеса. Это два независимых источника изменений, и слияние их в один класс означает, что каждое изменение бизнес-правил рискует сломать чужих клиентов, а каждое требование внешнего партнёра лезет в вашу модель.
Честная оговорка сразу. Если сервис маленький, потребитель один и он ваш, лишний слой DTO — это цена абстракции без выигрыша. Признаки, что слой нужен: у контракта есть внешние потребители; контракт версионируется; модель содержит поля, которые нельзя показывать наружу; формат ответа отличается от структуры домена (агрегация, денормализация). Если ни одного признака нет — отдавайте домен напрямую и разделяйте позже.
Что точно нельзя отдавать наружу напрямую — это объекты ORM. Причина не в чистоте, а в трёх
конкретных эффектах: ленивые связи превращают сериализацию в лавину запросов (N+1), в ответ утекают
поля вроде password_hash и internal_notes, а переименование колонки становится ломающим
изменением API (см. репозитории и persistence).
Парсить, а не валидировать
Самый ценный сдвиг мышления на границе сформулировала Алексис Кинг в «Parse, don’t validate»:
- Валидация отвечает на вопрос «данные корректны?» и возвращает
bool. Знание, полученное при проверке, теряется сразу после неё: тип объекта не изменился, и следующий слой обязан проверять заново — или верить на слово. - Разбор отвечает на вопрос «во что превращаются эти данные?» и возвращает другой тип, самим своим существованием доказывающий корректность. Проверка выполняется один раз, на входе, и знание сохраняется в системе типов.
# ПЛОХО: проверили и забыли. Дальше по коду никто не знает, что email проверен.
def validate_email(raw: str) -> bool:
return "@" in raw and len(raw) <= 254
def register(raw_email: str) -> None:
if not validate_email(raw_email):
raise ValueError("плохой email")
save(raw_email) # сюда можно передать любую строку — компилятор не возразит
from typing import NewType
from dataclasses import dataclass
# ХОРОШО: тип-доказательство. Создать его можно только через разбор.
@dataclass(frozen=True)
class Email:
value: str
@classmethod
def parse(cls, raw: str) -> "Email":
raw = raw.strip().lower()
if "@" not in raw or len(raw) > 254:
raise InvalidEmail(raw)
return cls(raw) # единственный путь получить Email — пройти проверку
def register(email: Email) -> None:
# Проверять нечего: наличие значения типа Email и есть доказательство корректности.
save(email.value)
Приём называется smart constructor: конструктор закрыт, публичен только разбирающий метод. Дальше он масштабируется на все «строки со смыслом»:
OrderId = NewType("OrderId", str)
CustomerId = NewType("CustomerId", str)
def load(order_id: OrderId) -> Order: ...
load(CustomerId("c-42")) # mypy: ошибка типов. Раньше это был баг в проде.
Обе идентификаторы — строки в рантайме, но перепутать их больше нельзя: это фантомная типизация
для бедных. В TypeScript тот же приём делают через branded types
(type OrderId = string & { readonly __brand: "OrderId" }), в Rust — через
newtype, в Java — через record-обёртку.
Более общий принцип: сделать невалидное состояние непредставимым. Не «проверять, что у заказа без позиций нельзя посчитать сумму», а спроектировать так, чтобы заказ без позиций не существовал как тип. Подробнее — алгебраические типы данных, и это же будет темой следующей главы.
Notification: все ошибки сразу, а не первая
Разбор через исключения имеет неприятное свойство: он останавливается на первой ошибке. Для внутренней проверки это правильно, для формы регистрации — катастрофа: пользователь исправляет по одному полю за отправку и уходит на третьей итерации.
Паттерн Notification (Мартин Фаулер, «Replacing Throwing Exceptions with Notification in Validations») заменяет исключение объектом-накопителем:
from dataclasses import dataclass, field
@dataclass
class Notification:
"""Накопитель ошибок: путь к полю + машиночитаемый код + сообщение."""
errors: list[tuple[str, str, str]] = field(default_factory=list)
def error(self, path: str, code: str, message: str) -> None:
self.errors.append((path, code, message))
@property
def ok(self) -> bool:
return not self.errors
def parse_order(raw: dict) -> tuple[Order | None, Notification]:
n = Notification()
items: list[OrderLine] = []
for i, raw_item in enumerate(raw.get("items", [])):
try:
items.append(OrderLine.parse(raw_item))
except ParseError as e:
# Путь важен не меньше сообщения: фронтенд подсветит конкретное поле.
n.error(f"items[{i}].{e.field}", e.code, e.message)
if not items:
n.error("items", "empty", "заказ должен содержать хотя бы одну позицию")
try:
email = Email.parse(raw["email"])
except (KeyError, InvalidEmail):
n.error("email", "invalid", "укажите корректный адрес почты")
email = None
if not n.ok:
return None, n # объект не создан — и не мог быть создан
return Order(email=email, items=items), n
Правило выбора между исключением и накопителем: исключение — для того, что не должно случаться (баг, сбой инфраструктуры); накопитель — для ожидаемых ошибок ввода. Ошибка пользователя — это не исключительная ситуация, а штатный сценарий, и она заслуживает нормального возвращаемого значения.
Функциональный вариант того же — аппликативная валидация из функциональных паттернов: там накопление ошибок получается структурно, без ручного списка. А в обработке ошибок разобрана общая дисциплина: что логировать, что показывать пользователю и что никогда не отдавать наружу (стек, SQL, внутренние идентификаторы).
Отсутствующее значение: Optional, Null Object, Special Case
Тони Хоар назвал изобретение null «ошибкой на миллиард долларов». На границе эта ошибка встречается чаще всего: поля в JSON может не быть, запись в БД может отсутствовать, внешний сервис может ответить пустотой.
Три разных ответа для трёх разных ситуаций:
| Приём | Когда применять | Что даёт | Чем опасен |
|---|---|---|---|
| Optional / Maybe | Отсутствие — нормальный, но значимый случай, который должен обработать вызывающий | Компилятор/типизатор заставляет обработать пустоту | Многословность при глубокой вложенности |
| Null Object | Отсутствие означает «ничего не делать», и это безопасно | Убирает проверки из клиента | Молча проглатывает ошибки: «письма не приходят, и никто не знает почему» |
| Special Case | Отсутствие имеет собственное поведение и имя предметной области | Явное имя случая: АнонимныйКлиент, УдалённыйПользователь |
Требует дисциплины: случаев не должно стать двадцать |
# Null Object уместен: логгер, который ничего не пишет.
class NullLogger:
def info(self, msg: str, **kw) -> None: pass
def error(self, msg: str, **kw) -> None: pass
# Клиент не пишет `if self.logger is not None` в двадцати местах.
# Special Case уместен: у «незарегистрированного клиента» есть СВОИ правила.
@dataclass(frozen=True)
class AnonymousCustomer:
"""Не заглушка, а полноценный участник домена со своим поведением."""
def discount_percent(self) -> int:
return 0
def can_pay_on_credit(self) -> bool:
return False
def display_name(self) -> str:
return "Гость"
# Null Object НЕ уместен: платёжный шлюз, который «как бы» списал деньги.
class NullPaymentGateway:
def charge(self, order, amount) -> str:
return "fake-receipt" # катастрофа: заказ отмечен оплаченным, денег нет
Граница между уместным и неуместным Null Object проходит по вопросу: «ничего не делать» — это корректный исход или замаскированный сбой? Для логгера и метрик — корректный. Для платежа, записи в БД, отправки уведомления — замаскированный, и цена маскировки — потерянные деньги и молчаливая порча данных.
Мэппинг: главный источник скучной работы
Между слоями надо перекладывать поля. Способа два, и у каждого своя экономика.
| Критерий | Ручной мэппер | Рефлексивный / генерируемый |
|---|---|---|
| Скорость написания | медленно, руками | быстро, «по соглашению» |
| Поведение при новом поле | поле не перенесётся, пока не допишете | перенесётся само — включая то, что не должно уйти наружу |
| Ошибка при переименовании | ошибка компиляции/типов | молчаливый null в проде |
| Производительность | максимальная | рефлексия в рантайме дороже; кодогенерация — как ручной |
| Отладка | обычный код, точка останова | «магия», стек в недрах библиотеки |
| Тесты | нужны | нужны больше |
Практический вывод, к которому приходит большинство команд: на границе наружу — ручной мэппинг
или генерация с явными правилами; внутри системы — что угодно. Разница в цене ошибки: молчаливая
утечка поля internal_score в публичный API — это инцидент, а такое же поле, случайно
скопированное между внутренними слоями, — мелочь.
Тест, который обязателен для любого способа, — золотой файл контракта:
def test_order_response_contract(snapshot):
"""Ответ API сравнивается с эталонным JSON. Любое изменение формы
становится видимым в диффе ревью — включая случайно добавленное поле."""
order = make_order(id="o-1", total=Decimal("1990.00"))
assert to_response(order) == snapshot("order_response_v1.json")
Отдельная патология — взрыв мэпперов: N слоёв × M сущностей = N×M классов преобразования, и каждый добавленный слой умножает работу. Это симптом того, что слоёв больше, чем реальных границ. Граница есть там, где меняется владелец контракта; между двумя вашими же классами внутри одного модуля границы нет, и мэппер там — чистые накладные расходы.
Толерантный читатель и совместимость
тип не приводится Разобрано --> Принято: обязательные поля разобраны Принято --> Обработано: домен принял команду Принято --> Отклонено: нарушен доменный инвариант Обработано --> [*] Отклонено --> [*]: код ошибки + путь к полю + идентификатор запроса note right of Разобрано Незнакомые поля НЕ являются ошибкой: отправитель мог обновиться раньше вас. Строго — только к тому, что вы читаете. end note
Tolerant Reader (Мартин Фаулер) — принцип «читай так мало, как можешь, и терпи всё остальное». Практически это значит: не падать от неизвестных полей, не полагаться на порядок элементов, не требовать точного соответствия схемы целиком, использовать значения по умолчанию для новых необязательных полей.
Обратная сторона — то, что вы отдаёте: здесь действует строгая дисциплина совместимости. Добавлять необязательные поля можно; удалять, переименовывать, менять тип и сужать множество допустимых значений — ломающее изменение, требующее версии. Это ровно те же правила, что у контракта расширения из главы про плагины и у схем сообщений в событийной архитектуре; детали версионирования API — в стилях API.
Когда чужая модель мира несовместима с вашей — например, партнёр присылает адрес одной строкой, а у вас структура из шести полей, — граница получает собственный слой перевода: анти-коррупционный слой (см. ограниченные контексты). Технически это Adapter, поднятый до уровня подсистемы: весь чужой словарь остаётся снаружи, внутрь проходят только ваши понятия.
Полный проход одного запроса
Это знание живёт ровно в слое API.
Обратите внимание, где проходит граница знаний: доменный сервис не возвращает «404» и не бросает
HTTPException. Он говорит на языке предметной области, а перевод в коды протокола — работа
периметра. Как только домен начинает знать про HTTP, тестировать его можно только через HTTP.
Типичные ошибки
| Ошибка | Как выглядит | Что делать |
|---|---|---|
| Сериализация ORM-сущностей наружу | Лавина запросов, утечка полей, ломкий контракт | DTO ответа + ручной мэппинг |
| Валидация вместо разбора | Проверили в контроллере, проверяем снова в сервисе | Разбор в тип, который сам является доказательством |
| Проверка «где-нибудь потом» | None взрывается через три слоя |
Вся проверка — на входе, до создания доменного объекта |
| Исключение на первой ошибке формы | Пользователь исправляет по одному полю | Notification с путями к полям |
| Строки вместо типов идентификаторов | load(customer_id) вместо order_id |
NewType, branded type, newtype |
| Null Object на операции с эффектом | «Оплата прошла», денег нет | Null Object только там, где бездействие корректно |
| Отдаём наружу внутренние коды ошибок | В ответе стек, имя таблицы, SQL | Отдельный словарь публичных кодов |
| Строгий читатель | Падаем от нового поля партнёра | Игнорировать незнакомое, строго читать нужное |
| Мэппер между каждыми двумя классами | N×M классов преобразования | Граница там, где меняется владелец контракта |
| Автомэппинг наружу | Новое поле модели уехало в публичный API | Явные правила + золотой файл контракта в тестах |
Как это выглядит в проде
- pydantic (Python) — библиотека, устроенная ровно как «парсить, а не валидировать»: модель и есть результат разбора; ошибки собираются списком с путями. Схожая идея в zod для TypeScript.
- Protobuf / Avro + реестр схем — совместимость проверяется машинно: реестр не даст зарегистрировать несовместимое изменение схемы.
- MapStruct (Java) и AutoMapper (.NET) — генерация и рефлексия соответственно; MapStruct генерирует обычный код на этапе компиляции, поэтому ошибки видны сразу.
- Serde (Rust) — разбор в типы с явным контролем неизвестных полей (
deny_unknown_fields). - GraphQL и OpenAPI — контракт как отдельный артефакт, из которого генерируются DTO обеих сторон; ломающие изменения ловит diff схемы в CI.
- Java
Optional, C# nullable reference types, Kotlin?— платформенная поддержка отсутствующего значения; включённый strict-режим переводит целый класс ошибок из рантайма в компиляцию.
Мини-итог
- Граница — единственное место, где данные превращаются из «формата» в «смысл». Всё, что не проверено здесь, будет проверяться (или взрываться) везде.
- DTO и доменный объект разделяют не из чистоты, а из-за разных причин изменения. Нет внешних потребителей и версионирования — слой не нужен.
- Парсить, а не валидировать: проверка должна возвращать тип, а не
bool. Знание сохраняется в системе типов, а не в голове следующего разработчика. - Ошибки ввода — штатный результат: собирайте их все, с путями к полям. Исключения оставьте для того, чего быть не должно.
- Отсутствующее значение имеет три разных ответа: Optional (обработай явно), Null Object (бездействие корректно), Special Case (у пустоты есть имя и поведение). Null Object на операции с эффектом — скрытый инцидент.
- Мэппинг наружу пишите явно; внутри — как удобно. Контракт закрепляйте золотым файлом в тестах.
- Читайте толерантно, пишите строго: незнакомое поле — не ошибка, а ваше новое поле — обязательство.
Источники
- Alexis King, «Parse, don’t validate», 2019 — lexi-lambda.github.io.
- Martin Fowler, «Replacing Throwing Exceptions with Notification in Validations», 2016 — martinfowler.com.
- Martin Fowler, «Patterns of Enterprise Application Architecture», 2002 — Data Transfer Object, Special Case, Service Layer; каталог на martinfowler.com/eaaCatalog.
- Martin Fowler, «TolerantReader» — martinfowler.com/bliki/TolerantReader.html.
- Tony Hoare, «Null References: The Billion Dollar Mistake», QCon 2009 — infoq.com.
- Bobby Woolf, «Null Object», в сборнике «Pattern Languages of Program Design 3», 1997.
- Scott Wlaschin, «Designing with Types» — fsharpforfunandprofit.com: как сделать невалидные состояния непредставимыми.
- pydantic — docs.pydantic.dev, раздел про модель ошибок с путями.
- Confluent, «Schema Evolution and Compatibility» — docs.confluent.io.
Что дальше
Половина приёмов этой главы — smart constructor, типы-идентификаторы, «невалидное состояние непредставимо» — работает не за счёт объектной структуры, а за счёт системы типов. Это общая закономерность, и на ней логично закончить трек: многие паттерны GoF существуют ровно до тех пор, пока в языке нет подходящей конструкции. Заключительная глава — про то, какие паттерны растворяются в функциях высшего порядка, дженериках, sealed-типах и typestate, а какие не растворяются никогда.