Практика: заказ в двух таблицах (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'.
Структура
Модель — агрегат 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 заказа.
| Язык | Особенность |
|---|---|
| PHP | enum OrderStatus: string и tryFrom, public private(set) для строк заказа, array_find из PHP 8.4; доменные InvalidArgumentException заворачиваются в CorruptRow с previous |
| Go | Value objects через конструкторы NewMoney/ParseEmail/ParseStatus с error; ячейки читает дженерик get[T]; две сентинел-ошибки и двойной %w: в цепочке остаются и ErrCorruptRow, и исходная ошибка |
| Python | Frozen 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_amount129900 иprice_currencyRUB, дата — в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 сентября.
Связанные темы
- Преобразователь данных (базовый пример)
- Репозиторий (Repository) — интерфейс коллекции заказов для домена; маппер — то, что у него внутри
- Единица работы (Unit of Work) — помнит, какие строки изменились, чтобы маппер не переписывал всё заново