Преобразователь данных (Data Mapper)
Отдельный объект переносит данные между доменной моделью и хранилищем, так что модель не знает ни таблиц, ни колонок, ни того, что её вообще сохраняют.
Проблема
Клиент магазина Customer — имя, почта и признак VIP с правилом promote(). В таблице всё называется по-другому: имя лежит в колонке full_name, признак VIP — в is_vip числом 0 или 1, потому что так решили лет пять назад. Самый короткий путь — научить объект сохранять себя: дать ему ссылку на таблицу и метод save(). Это Active Record, и в маленьком CRUD он хорош. Но вместе с методом в объект приезжает хранилище: без таблицы его нельзя создать, каждое имя колонки живёт рядом с бизнес-правилом, а тест на promote() сначала поднимает базу.
Таблица в примере — словарь строк в памяти: колонка -> значение, как их отдаёт драйвер. Настоящей базы нет, но форма данных та же.
Решение
Вынести перенос данных в отдельный объект — преобразователь CustomerMapper. Он знает обе стороны: из Customer делает строку с колонками (toRow), из строки собирает Customer (fromRow), а save() и find() пишут и читают таблицу. Доменный объект не знает про маппер ничего, зависимость направлена в одну сторону: маппер -> модель.
Цена — второй класс на каждую сущность и необходимость доставать состояние объекта наружу. Выигрыш — модель и схема меняются независимо: переименовали колонку, поправили один маппер; поменяли правило VIP, хранение не тронули.
Структура
Доменный объект Customer — обычный класс с данными и поведением: конструктор, promote(), isVip(). В нём нет ни одного слова про таблицу, строку или сохранение, поэтому его создают и проверяют в одну строку. Стрелка на схеме идёт от маппера к модели, а не наоборот: модель можно вынести в отдельный пакет без хранилища, и она соберётся.
Как это собирается
Таблицу и маппер создают один раз при старте приложения: CustomersTable стоит на месте подключения к базе, CustomerMapper получает её в конструктор, и контейнер отдаёт этот маппер обработчикам. Обработчик запроса "сделать клиента VIP" идёт по одному пути: find(id) поднимает строку и собирает Customer, promote() меняет модель, save() переводит её обратно в колонки. Нет строки - find возвращает пустое значение, и обработчик отвечает 404, не трогая модель. В конце видно, что в таблицу легло is_vip=1, хотя Customer о колонках так и не узнал.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Active Record ActiveCustomer для сравнения: те же поля, тот же promote(), но первым аргументом конструктора идёт таблица, а save() и find() живут в самом классе. Имена колонок full_name и is_vip теперь рядом с бизнес-правилом, и чтобы проверить promote(), тесту приходится создавать таблицу. В реальном проекте на её месте база, транзакция и фикстуры.
| Язык | Особенность |
|---|---|
| PHP | Форма строки описана один раз через @phpstan-type CustomerRow и импортируется в маппер; toRow/fromRow — статические методы |
| Go | Строка — map[string]any, как её видит драйвер; ToRow/FromRow — функции пакета, маппер — структура с таблицей |
| Python | Строка — TypedDict, модель — dataclass; to_row/from_row — функции модуля, а не методы класса |
| TypeScript | Строка — интерфейс с snake_case-полями и is_vip: 0 | 1; перевод — две стрелочные функции рядом с классом маппера |
Запуск: make test T=patterns/enterprise/data-mapper
Плюсы и минусы
Тесты показывают разницу на одном правиле. promote() у Customer проверяется без всякого хранилища: создать, вызвать, сравнить. Маппер переводит поля в колонки (name -> full_name, true -> 1), и сохранённый клиент возвращается из таблицы равным исходному. У ActiveCustomer то же правило не проверить без таблицы: объект без неё не создаётся.
- ✅ Модель не зависит от хранилища: её тестируют и меняют без базы.
- ✅ Схема и модель расходятся свободно: другое имя колонки, другой тип, одна сущность в двух таблицах.
- ✅ Всё знание о колонках собрано в одном месте на сущность.
- ❌ Второй класс на каждую сущность и код перевода, который почти повторяет поля.
- ❌ Маппер должен видеть состояние объекта: публичные поля, геттеры или конструктор восстановления. Инкапсуляция слегка страдает.
- ❌ Для простого CRUD, где модель совпадает с таблицей, это лишний слой.
Когда не применять
- Объект — просто строка таблицы без правил: Active Record или даже голый массив короче и честнее.
- В проекте уже есть ORM с маппингом (Doctrine, Hibernate, SQLAlchemy в classical mapping): маппер уже написан, его надо настроить, а не писать заново.
- Прототип или скрипт на один раз: разделение окупается, когда модель живёт долго и растёт.
Отличия между языками
- PHP —
finalкласс сreadonlyполями и приватнымvip; маппер читает его черезisVip(). Форма строки —@phpstan-type, и PHPStan проверяет, чтоtoRowне забыл колонку. В Doctrine маппер не пишут: его роль играют атрибуты на классе иEntityManager, а сам класс остаётся обычным объектом. - Go — модель с экспортируемыми полями — обычный стиль Go, геттеры не нужны. Строка
map[string]anyтребует приведения типов вFromRow; в проекте на этом местеrows.Scan(&c.ID, &c.Name, ...)изdatabase/sql, который делает то же самое руками. - Python —
TypedDictописывает строку для проверки типов, а в рантайме это обычныйdict. Перевод — функции модуля: в Python нет причины прятать их в класс. SQLAlchemy умеет и Active Record-стиль (декларативные модели), и классический маппинг на чистые классы. - TypeScript — строка описана интерфейсом с теми же snake_case-именами, что в базе, и литеральным типом
0 | 1: компилятор не даст положить вis_viptrue. Модель с приватнымvipмаппер читает черезisVip().
Практика
Учебный маппер переводит одну строку в один объект, а данные в таблице всегда правильные. В жизни сущность — это агрегат из нескольких таблиц, поля — value objects, а строки в базе бывают битыми. Практика: заказ в двух таблицах собирает заказ с деньгами, почтой и статусом из таблиц orders и order_lines, удаляет из таблицы снятые строки заказа, падает с понятной ошибкой на битых данных и проверяет, что доменный код не знает о хранилище.
Связанные темы
- Практика: заказ в двух таблицах — агрегат, value objects и проверка данных при загрузке
- Репозиторий (Repository) — коллекция сущностей для домена; внутри репозитория обычно работает маппер
- Единица работы (Unit of Work) — решает, какие объекты сохранить; сам перевод в строки делает маппер
- Снимок (Memento) — тоже выносит состояние объекта наружу и восстанавливает его, но без знания о схеме хранения