Паттерны проектирования Паттерны границ: DTO, мэпперы и отсутствующее значение
0%

Паттерны границ: DTO, мэпперы и отсутствующее значение

Паттерны границ: DTO, мэпперы и отсутствующее значение

У любой системы есть периметр, и на нём происходят две вещи, которые в остальном коде не происходят никогда. Внутрь приходят данные, которым нельзя верить: поле пришло строкой вместо числа, дата в неизвестном формате, обязательного ключа нет, а лишних ключей семь. Наружу уходят структуры, на которые немедленно начинают полагаться: как только вы отдали JSON с полем total, оно стало контрактом, и переименовать его вы уже не можете.

Внутренние главы трека — структурные, поведенческие — рассматривали объекты, которые уже валидны. Эта глава про то, как объект становится валидным, и что делать с тем, что валидным сделать не удалось.

Практическая мотивация простая. Подавляющее большинство продакшн-падений с трассировкой вида AttributeError: 'NoneType' object has no attribute ... или TypeError: unsupported operand — это не ошибки внутренней логики. Это данные с границы, которые прошли внутрь без проверки и взорвались через три слоя от места входа, где восстановить причину уже дорого.


Три представления одного заказа

Ключевая мысль, ради которой рисуется эта схема: DTO и доменный объект — разные вещи, потому что у них разные причины меняться. 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 классов преобразования, и каждый добавленный слой умножает работу. Это симптом того, что слоёв больше, чем реальных границ. Граница есть там, где меняется владелец контракта; между двумя вашими же классами внутри одного модуля границы нет, и мэппер там — чистые накладные расходы.


Толерантный читатель и совместимость

Tolerant Reader (Мартин Фаулер) — принцип «читай так мало, как можешь, и терпи всё остальное». Практически это значит: не падать от неизвестных полей, не полагаться на порядок элементов, не требовать точного соответствия схемы целиком, использовать значения по умолчанию для новых необязательных полей.

Обратная сторона — то, что вы отдаёте: здесь действует строгая дисциплина совместимости. Добавлять необязательные поля можно; удалять, переименовывать, менять тип и сужать множество допустимых значений — ломающее изменение, требующее версии. Это ровно те же правила, что у контракта расширения из главы про плагины и у схем сообщений в событийной архитектуре; детали версионирования API — в стилях API.

Когда чужая модель мира несовместима с вашей — например, партнёр присылает адрес одной строкой, а у вас структура из шести полей, — граница получает собственный слой перевода: анти-коррупционный слой (см. ограниченные контексты). Технически это Adapter, поднятый до уровня подсистемы: весь чужой словарь остаётся снаружи, внутрь проходят только ваши понятия.


Полный проход одного запроса

Обратите внимание, где проходит граница знаний: доменный сервис не возвращает «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, а какие не растворяются никогда.

Когда паттерн растворяется в системе типов

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов