handbookНаписать

patterns/enterprise/data-mapper/practice/README.md · практика · 4 языка · 34 теста

база: Преобразователь данных

Практика: заказ в двух таблицах (Order mapper) ​

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

Базовый пример: Преобразователь данных. Там одна строка превращается в один объект, а данные в таблице всегда правильные. В жизни сущность лежит в нескольких таблицах, её поля — value objects, которых в базе нет, а строки приходят после миграций, ручных правок и старых версий кода.

Задача ​

Интернет-магазин хранит заказы в двух таблицах: orders (кто, когда, статус) и order_lines (артикул, количество, цена). Схема плоская, как любит база: деньги — две колонки price_amount в копейках и price_currency, дата — строка 2026-10-09, статус — строка. Модель живёт по своим правилам: Money не бывает отрицательным и всегда с валютой, Email проверен, статус — закрытый набор, а строки принадлежат заказу и без него не существуют.

Первая версия собирала заказ в контроллере: SELECT, цикл по строкам, new Money($row['price_amount'], ...) в трёх местах. Когда менеджер руками поставил статус lost, заказ падал где-то в шаблоне письма через неделю после правки. А удалённая из корзины позиция оставалась в order_lines, потому что сохранение умело только добавлять.

Что урезано ​

  • Нет базы и SQL: таблицы — два списка строк в памяти, строка — словарь колонок со значениями, как их отдаёт драйвер. Запросы заменены фильтрами по списку.
  • Нет транзакции: в базе save делал бы UPSERT заказа и замену строк в одной транзакции, здесь это два присваивания.
  • Строки заказа сохраняются заменой: старые удаляются, текущие вставляются. Настоящий маппер или ORM сравнил бы их и выдал минимальный набор INSERT/UPDATE/DELETE.
  • Валидация почты — регулярка «что-то@что-то.что-то», валюта — три заглавные буквы. Нет total(), скидок и смены статуса: модель ровно настолько большая, чтобы было что переводить.
  • Проверка «модель не знает про хранилище» читает в исходнике кусок между комментариями Domain: и Storage:. В проекте модель и маппер лежат в разных пакетах, и это проверяет инструмент: deptrac, import-linter, go list с запретом импортов.

Решение ​

Маппер OrderMapper отвечает за весь путь заказа в таблицы и обратно. save() пишет одну строку в orders и по строке на каждую позицию в order_lines, предварительно убрав старые строки этого заказа: так позиция, снятая через removeLine(), исчезает и из таблицы, а строки других заказов не трогаются. find() находит строку заказа, отбирает его строки из второй таблицы и собирает агрегат через конструкторы value objects.

Загрузка — граница доверия. Всё, что пришло из таблицы, проходит через те же конструкторы, что и данные от пользователя: Money отвергнет отрицательную сумму, Email — строку без @, статус проверяется по списку. Кроме того, маппер сам проверяет форму строки: колонка есть и тип совпадает. Любая проблема становится одной ошибкой CorruptRow (в Python и TypeScript CorruptRowError, в Go ErrCorruptRow) с id заказа и причиной: order A-17: unknown status 'lost'.

Структура ​

rows

builds

throws

Order

+id: string

+customer: Email

+status: OrderStatus

+placedOn: date

+lines: OrderLine[]

+removeLine(sku)

OrderLine

+sku: string

+qty: int

+price: Money

Money

+amount: int

+currency: string

Email

+value: string

Tables

+orders: Row[]

+orderLines: Row[]

OrderMapper

-db: Tables

+save(order)

+find(id) : Order

CorruptRow

Модель — агрегат Order со строками OrderLine и три value object: Money (копейки и валюта, проверка в конструкторе), Email, OrderStatus. Ни один из них не знает про колонки, строки таблиц и маппер, и тест это проверяет. removeLine() меняет только список строк в памяти: что из этого надо удалить в базе, решает маппер при сохранении.

Таблицы и строки ​

Таблицы Tables — два списка строк с колонками в snake_case: orders (id, customer_email, status, placed_on) и order_lines (order_id, sku, qty, price_amount, price_currency). Значения в строке нетипизированы: это то, что вернул драйвер, и доверять ему нельзя. Связь строк с заказом — колонка order_id, вложенных объектов в таблице нет.

Как это собирается ​

Так выглядит обработчик "убрать строку из заказа" целиком. Tables стоят на месте подключения к базе, и в них уже лежат строки: заказ A-17 в порядке, а у B-3 в колонке почты осталось anna от старого импорта. OrderMapper создаётся один раз и отдаётся обработчику. Дальше путь один: find собирает агрегат из двух таблиц, removeLine меняет его в памяти, save переписывает строки заказа, и снятая строка исчезает из order_lines.

Ошибки разбирает вызывающий код, а не маппер. Нет заказа - 404. Строка битая - маппер бросает CorruptRow с номером заказа и причиной, обработчик пишет её в лог и отвечает 500, а не отдаёт клиенту полупустой заказ. Чужой заказ B-3 при этом не тронут: его строка в order_lines осталась как была.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

Перевод в маппере идёт в две стороны по-разному. Запись простая: модель уже проверена, достаточно разложить Money на две колонки, дату — в строку YYYY-MM-DD, статус — в его значение. Чтение строгое: каждая ячейка достаётся типизированным помощником (text/int, в Go — get[T]), который сообщает о пропущенной колонке, а значение проходит через конструктор доменного типа. Ошибки внутри — обычные ошибки значения; наружу выходит только CorruptRow с id заказа.

ЯзыкОсобенность
PHPenum OrderStatus: string и tryFrom, public private(set) для строк заказа, array_find из PHP 8.4; доменные InvalidArgumentException заворачиваются в CorruptRow с previous
GoValue objects через конструкторы NewMoney/ParseEmail/ParseStatus с error; ячейки читает дженерик get[T]; две сентинел-ошибки и двойной %w: в цепочке остаются и ErrCorruptRow, и исходная ошибка
PythonFrozen dataclass с проверкой в __post_init__, StrEnum, date.fromisoformat; все проблемы — ValueError, маппер ловит одно исключение и поднимает CorruptRowError через from
TypeScriptСтатус — объединение литералов из массива as const, строка — Record<string, unknown>, и компилятор заставляет проверить тип каждой ячейки; Error с cause

Запуск: make test T=patterns/enterprise/data-mapper/practice

Что проверяют тесты ​

Тесты одинаковы во всех языках: заказ A-17 от anna@example.com, оплачен 9 октября 2026, две позиции — клавиатура за 1299 рублей и две мыши по 24,90.

  • Круговой тест: объект -> строки -> объект даёт заказ, равный исходному, вместе с value objects и датой. Ненайденный заказ — пустой результат (в Go ErrNotFound), а не ошибка разбора.
  • Агрегат раскладывается ровно в одну строку orders и две строки order_lines, деньги — в price_amount 129900 и price_currency RUB, дата — в 2026-10-09.
  • После removeLine("MS-2") и сохранения в order_lines остаётся одна строка A-17, а обе строки соседнего заказа A-18 на месте.
  • Пять битых ячеек: статус lost, почта anna, дата 09.10.2026, цена -500, нет колонки price_currency. Каждая даёт CorruptRow, в тексте которой есть order A-17 и само плохое значение или имя колонки: по такой ошибке строку можно найти и поправить.
  • Доменная часть исходника, от Domain: до Storage:, не упоминает Tables, Mapper, Row, order_lines и price_amount: модель собирается без слоя хранения.

Где граница ​

  • Сущностей больше трёх-четырёх, есть связи многие-ко-многим, ленивые коллекции, отслеживание изменений и оптимистичные блокировки: это ORM (Doctrine, Hibernate, SQLAlchemy, TypeORM, GORM). Писать такой маппер руками — значит писать свою ORM. Ручной маппер остаётся там, где ORM мешает: отчёты, массовые выгрузки, сложный SQL, чужая схема.
  • Сохранять заменой строк перестаёт быть приемлемо, когда у строк есть свои id, на них ссылаются или строк тысячи. Тогда маппер сравнивает старое и новое, а помнит старое Единица работы.
  • Если доменная модель совпадает с таблицей один в один и правил в ней нет, маппер — лишний слой: Active Record или прямые запросы проще.
  • Проверять каждую ячейку при загрузке дорого на миллионах строк. Для отчётов и выгрузок читают сразу в DTO без value objects, а строгий маппер оставляют для агрегатов, которые потом меняют.

Отличия между языками ​

  • PHP — enum со строковыми значениями даёт tryFrom без ручного списка, public private(set) позволяет читать lines снаружи, а менять только через removeLine(). Строка — array<string, mixed>, и PHPStan не пропустит её значение в типизированный конструктор без is_string/is_int.
  • Go — у value objects экспортированные поля, поэтому Money{Amount: -5} собрать можно; проверка живёт в конструкторах, и маппер идёт только через них. Обёртка fmt.Errorf("%w: order %s: %w", ...) сохраняет и общую ошибку для errors.Is, и причину. Деньги и количество — int64, как их отдаёт database/sql.
  • Python — frozen=True и __post_init__ дают value object в несколько строк, а равенство dataclass делает круговой тест одним ==. Все проблемы разбора — ValueError (его же бросают StrEnum и date.fromisoformat), поэтому маппер ловит один тип.
  • TypeScript — Record<string, unknown> не даёт использовать ячейку без проверки типа, а Date для даты без времени требует аккуратности: строка разбирается как UTC-полночь и сверяется обратно, иначе new Date("09.10.2026") молча превратится в 10 сентября.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS