handbookНаписать

patterns/enterprise/data-mapper/README.md · тема · 4 языка · 19 тестов

+ практика: заказ в двух таблицах

Преобразователь данных (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, хранение не тронули.

Структура ​

reads and writes

builds

saves itself

Customer

+id: int

+name: string

+email: string

+promote()

+isVip() : bool

CustomersTable

+upsert(row)

+find(id) : row

CustomerMapper

-table: CustomersTable

+save(customer)

+find(id) : Customer

+toRow(customer) : row

+fromRow(row) : Customer

ActiveCustomer

-table: CustomersTable

+promote()

+save()

+find(table, id) : ActiveCustomer

Доменный объект 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_vip true. Модель с приватным vip маппер читает через isVip().

Практика ​

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

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

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