Большинство ORM строятся вокруг естественного предположения: одной таблице соответствует одна entity. Это настолько привычно, что даже не обращаешь внимания.

Это не правило, но вокруг него проектируются почти все остальные механизмы: метаданные принадлежат классу, Unit of Work отслеживает экземпляры этого класса, миграции собираются из его описания, а объектная модель постепенно начинает повторять структуру базы данных.

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

В статье сперва посмотрим на два подхода работы с сущностями, а потом что получилось, когда я попробовал сделать по‑другому.


Проект «Теремок»

Модель пользователя как точка сосредоточения функционала
Модель пользователя как точка сосредоточения функционала

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

Проект был успешен, функционал рос и углублялся. Сначала у пользователя были логин, пароль и профиль. Потом добавились телефоны, адреса, реферальная механика и много чего ещё. Код был грамотно разнесён по модулям и по показателям coupling и cohesion всё было вполне себе в зелёной зоне. Папка с сущностями в подобных метриках тянула всех вниз, поэтому добавили в игнор, «обозвав» инфраструктурой.

При этом добавляя поле для своей задачи, Виктор с удивлением мог обнаружить, что параллельно сущность обзавелась ещё несколькими полями или даже связями. Не всегда можно было определить по названию, зачем конкретно нужно то или иное поле.

В какой‑то момент класс выглядел так:

#[Entity]
class User
{
    public int $id;
    public string $email;
    public string $password;
    public ?string $name;
    public ?string $avatarUrl;
    public ?string $stripeCustomerId;
    public ?int $addressId;
    public ?int $defaultAddressId;
    public ?string $utmSource;
    public ?string $referralCode;
    public ?DateTime $lastSeenAt;
    public bool $isBetaTester;
    // @deprecated
    public bool $isOnboarded;
    public ?DateTime $onboardedAt;
    public ?string $postSourceId;

    #[OneToOne(targetEntity: Cart::class)]
    public Cart $cart;

    #[OneToMany(targetEntity: Phone::class)]
    public array $phones;

    #[ManyToMany(targetEntity: Group::class)]
    public array $groups;

    #[OneToMany(targetEntity: Order::class)]
    public array $orders;
}

Сам по себе такой класс еще не катастрофа. Но часто он становится универсальным входом почти в любой пользовательский сценарий.

Нужно проверить логин и пароль? Грузим User.

Нужно показать имя и аватар? Снова грузим User.

Нужно достать идентификатор клиента в платежной системе? Берём BusinessUserId… шутка, опять User.

Дальше накладываются особенности ORM, связи, eager loading и человеческий фактор. В результате ради двух полей можно внезапно потянуть корзину, товары, категории и ещё много чего.

При этом плюсы такого подхода очевидны:

  • данные лежат в одном месте;

  • проще добавлять новые фичи на раннем этапе;

  • меньше джойнить таблицы при запросах БД.

Но цена тоже быстро становится заметной:

  • модель начинает знать или даже делать слишком много;

  • сценарии тянут лишние зависимости;

  • DTO и специальные выборки появляются не как осознанное решение, а как локальная попытка спастись.

Устраиваясь в другую компанию, Виктор выходил на этапе внедрения инфраструктуры мапингов сущностей в DTO и обратно.

Проект «Пойди туда, не знаю куда»

"Пилим" пользователя на модули
«Пилим» пользователя на модули

На другом проекте другой компании во всё том же городке П, архитектор ещё на этапе начала заложил несколько принципов для решения конкретно этой проблемы. Никаких жирных сущностей, никаких утечек между контекстами, каждый домен отвечает только за свою часть пользователя. Проект был новый, легаси слоёв и чёрных ящиков пока что не было.

Выглядело это так:

#[Entity(tableName: 'auth_users')]
class AuthUser
{
    public int $id;
    public string $email;
    public string $password;
}

#[Entity(tableName: 'profile_users')]
class ProfileUser
{
    public int $id;
    public string $name;
    public ?string $avatarUrl;
}

#[Entity(tableName: 'billing_users')]
class BillingUser
{
    public int $id;
    public string $email;
    public ?string $stripeCustomerId;
}

Поначалу для Виктора это выглядело непривычно, но со временем стали видны плюсы подхода.

Авторизация грузит только нужные поля. Профиль работает только со своим контекстом. Биллинг не знает ничего о корзине, комментариях и аватарке. У каждой модели понятная зона применения, а у разработчика — порядок и границы. Изменения вносятся в меньший участок проекта, больше контроля и прозрачности. Физически выстроенный контекст нарушить не так уж просто.

Проблемы начались весьма невинно. На странице подтверждения заказа Виктору нужно было показать имя покупателя, его адрес доставки и статус в программе лояльности. Для пользователя (да и менеджера) это выглядело как вполне логичное, простое обновление интерфейса. Здесь же задача оказалась нетривиальна.

Потому что в этой системе:

  • имя живет в одном контексте (таблице);

  • адрес в другом (пока выясняли данный вопрос — нашли имеющееся дублирование в одном из других модулей);

  • статус лояльности в третьем;

  • правила владения данными понимаются командами по‑разному.

К концу дня в треде было тридцать семь сообщений. Нейтральный тон испарился где‑то на двадцатом. Готовясь уходить домой, Виктор‑таки нашёл свободный слот для созвона всех причастных через три бизнес‑дня.

Дальше начинаются знакомые вещи: синхронизация, проекции, агрегаторы, специальные read‑модели, споры сначала о владельце поля, а потом уже с ним самим на тему того, кто, что и как будет синхронизировать.

Выбранный первоначальный «чистый» подход где‑то здесь уже сильно трещит после калибровки об реалии.

Завели ADR и на какое‑то время он стал полноправным участником собраний. Довольно быстро это уже стал матёрый документ с разными обоснованными точками зрения. В этот момент архитектор переключился на более приоритетное направление и решение вопроса отложили на следующий квартал.

Задачу в итоге закрыл стажёр, который просто сделал кросс‑доменный запрос в базу напрямую и к обеду уехал в универ. Такие дела.

Виктор же наш на этом моменте уже готовится опять сменить место работы, но тут мы его и оставим.

Получается странная дилемма

С одной стороны, entity постепенно превращается в универсальный объект «на все случаи жизни». Работать с ним с течением времени всё тяжелее.

С другой стороны, физическое разделение — решение тоже со своей ценой. Адаптация модели данных к новым требованиям рождает неожиданные вызовы. Приходится поддерживать больше инфраструктуры вокруг синхронизации этих представлений и быть начеку с дублированием.

Любопытно, что обе проблемы возникают из одного и того же предположения: структура объектной модели должна повторять структуру хранения данных.

Но что если нет?

Третий путь

Articulate ORM появилась как попытка позволить одной таблице полноценно иметь несколько независимых отображений:

#[Entity(tableName: 'users')]
class AuthUser
{
    #[PrimaryKey] public int $id;
    #[Property]   public string $email;
    #[Property]   public ?string $password;
}

#[Entity(tableName: 'users')]
class BillingUser
{
    #[PrimaryKey] public int $id;
    #[Property]   public string $email;
    #[Property]   public ?string $stripeCustomerId;
}

Генерируем миграцию на создание:

CREATE TABLE users (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    password VARCHAR(255) DEFAULT NULL,
    stripe_customer_id VARCHAR(255) DEFAULT NULL
)

Обе сущности работают с одной таблицей. Данные пользователя сохраняют «цельность». Мы лишь берём только то, что нужно под конкретный сценарий.

Конфигурируем под сценарий
Конфигурируем под сценарий

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

Здесь позволим себе немного отвлечься и поразмышлять над увиденным:

Вопрос из зала: разве это вообще задача ORM?

Конечно, ORM не должна проектировать bounded context, определять агрегаты или навязывать архитектурный стиль.

При этом современные ORM уже давно делают гораздо больше, чем просто отображают объекты в строки таблицы:

  • Отслеживают изменения объектов.

  • Строят Unit of Work.

  • Управляют консистентностью состояния внутри процесса.

  • Генерируют миграции.

  • Оптимизируют SQL.

То есть вся информация уже есть, но по тем или иным причинам работа строится из неявно заложенного когда‑то давно принципа.

Articulate лишь использует имеющиеся данные иначе: снимает ограничение «одна таблица — одна entity» и берёт на себя инфраструктуру, которая для этого нужна.

Что делается внутри

  • проверяется совместимость нескольких описаний одной таблицы;

  • объединяются изменения перед сохранением;

  • корректно генерируются миграции (см. пример выше);

  • обновляется кэш связанных сущностей.

Для пользователя это выглядит как несколько независимых entity. И даже читаем из базы каждую сущность отдельно, так как колонки у каждой сущности могут отличаться.

При записи же библиотека внутри себя понимает, что если сущности описываются одной строкой базы данных — тогда обновление делается одним запросом:

$authUser = $this->entityManager->find(AuthUser::class, 1);
$billingUser = $this->entityManager->find(BillingUser::class, 1);

$authUser->email = "new-email@example.test";
$billingUser->stripeCustomerId = 5;

$this->entityManager->flushAndClear();

Запросы в базу:

SELECT id, email, password FROM users WHERE id = 1 LIMIT 1
SELECT id, email, stripe_customer_id FROM users WHERE id = 1 LIMIT 1
UPDATE users SET email = 'new-email@example.test', stripe_customer_id = 4 WHERE id = 1

Second‑Level Cache для обоих обновится и при следующем запросе уже обе сущности получат новенькие email и идентификатор stripe.

Это позволяет комбинировать поля под конкретный процесс, не таща огромные модели ради новой фичи полностью.

Конечно, это не отменяет проектирование домена и грамотное размещение полей.

Вопрос из зала 2: Разве <одна из популярных ORM> так не умеет?

Пальцев двух рук не хватит перечислить что сейчас умеют ORM, особенно достаточно зрелые. Вопрос не в том, возможно ли это технически, а в том, является ли этот сценарий частью её модели.

Зрелые ORM существуют десятилетиями и обладают огромной гибкостью. За время разработки Articulate, да и до этого тоже, я не раз заглядывал в их код и документацию — это обычно очень сильные проекты.

Но модель их работы — другая. И поэтому рано или поздно начинают появляться ограничения.

Например, оказывается, что даже если прописать мапинг нескольких сущностей на одну таблицу и оно даже начнёт работать, то какой‑то механизм рассчитан на одну entity на таблицу и тут либо погружаться во всевозможные обходы, либо всё же признать, что библиотека под это не предназначена и это нормально.

Именно поэтому Articulate строится вокруг обозначенной идеи с самого начала. Проверка совместимости нескольких представлений одной таблицы, объединение изменений, генерация миграций и поддержка кэша — это не дополнительные расширения, а часть основной модели библиотеки.

В общем‑то, при желании Articulate будет «обратно‑совместим» с любыми фичами и сценариями других ORM, ведь это будет как раз частный случай: там один к одному, тут один ко многим.

Тестирование

Любая библиотека должна быть покрыта качественными авто‑тестами. Для ORM это требование как будто даже сильнее, потому что подобный функционал критичен на тему всяких неожиданных ситуаций. Поэтому с самого начала тестирование и проверка регрессии была одним из приоритетов в разработке.

Подход выбран прагматичный: никаких моков.

Тесты идут на реальном соединении с MySQL и PostgreSQL. Отдельный demo‑проект как демонстрирует пользователю основные сценарии, так и проверяет библиотеку в реальной интеграции.

Это решение перетекло из опыта: ORM бессмысленно тестировать только против in‑memory заглушек, потому что самые неприятные ошибки живут на стыке с реальной базой. Бессмысленно делать только юнит‑тестирование, потому что весь смысл в интеграции.

Каждый SQL‑затрагивающий тест гоняется на поддерживаемых диалектах:

/** @dataProvider databaseProvider */
public function testFind(Connection $connection, string $databaseName): void
{
    $tableName = $this->getTableName('users', $databaseName);
    // ...
}

Под каждую «фичу» можно объявить нужные сущности прямо в тесте или где‑то неподалёку:

<?php

#[Entity(tableName: 'shared_entities')]
class SharedTableAlpha {
    ...
}

#[Entity(tableName: 'shared_entities')]
class SharedTableBeta {
  ...
}

class SharedTableEntitiesTest extends DatabaseTestCase {
    ...
}

Мутационное тестирование — тестирует «качество» тестов. Infection запускает тысячи модифицированных версий кода (меняет > на >=, убирает условия, инвертирует булевы) и проверяет, упадут ли тесты. Если мутант выжил — тест не покрывает этот случай и это потенциальная проблема.

Текущий результат: 80%+. Было выше, но к релизу съехало. Наверстаем:)

Завершение

Возможно, если бы Виктор после знакомства с Articulate снова оказался в первой компании, ему не пришлось бы каждый раз продираться сквозь огромный класс User.

А если бы вернулся во вторую — не пришлось бы выбирать между одной большой сущностью и сложной инфраструктурой вокруг нескольких маленьких.

Модель «одна таблица — одна entity» не является ошибочной. Для огромного количества проектов она останется самым простым и понятным решением. Но изучение альтернативы позволяет лучше увидеть, что это должно быть именно архитектурное соглашение, а не фундаментальное ограничение ORM.

Articulate не проектирует архитектуру за разработчика и не отменяет необходимость думать о модели предметной области. Но если объектная модель уже перестала помещаться в классическое соответствие «одна таблица — одна entity», возможно, инфраструктура должна помочь, а не становиться ещё одним ограничением.

Репозиторий проекта: GitHub.
Больше примеров, документации: GitHub.