וואלה164 קמ"ש במקום 60: נהגת חדשה בת 18 נתפסה במהירות קיצונית בלב חיפהThe Jerusalem PostKatz threatens to destroy rest of Gaza City, evacuate one million Gazans if hostage takenRTP DesportoPresidente do V. Guimarães agastado com início de época "frustrante"Daily MaverickStudent opens fire outside school in Turkey, eight pupils wounded, NTV reportsInquirerPalace backs ombudsman’s probe into DICT’s alleged ‘ghost internet’ schemeBollywood HungamaThe Royals Season 2: Gurfateh Pirzada joins Sini Shetty and Ishaan Khatter in new love triangleגיקטייםהסטארטאפ שיצא מאינטל נמכר עכשיו בחצי מיליארד דולרSCMP ChinaWho from China’s side will be in the room at this week’s Xi-Trump summit?ColliderHenry Cavill's Clichéd Thriller Is Officially the Streaming Guilty Pleasure You Can't SkipХабрСтиль, проверенный временем: лучшие пиксельные игры в 2026 годуComplete SportsSerena’s Ex-Coach Tips Alcaraz To Claim Two Major Titles In 2027NPRHouthis try to push deeper into western Yemen, upending life, witnesses tell NPR
The Daily Newsstand · Free, Always
Tuesday, September 22, 2026

Anemic vs Rich entities: нечестная дилемма

Translate

Знаменитый тезис Мартина Фаулера «Anemic Domain Model — это Antipattern» знаком многим.

Тезис простой и звучит убедительно: если модель — это просто набор геттеров и сеттеров, а бизнес-логика живёт в сервисах, то это анемичная модель, процедурный код в объектной обёртке, шаг в сторону от объектного ориентирования. Короче, это плохо. Правильнее — переносить поведение внутрь объекта, рядом с данными. Заказ должен уметь рассчитывать свою сумму, добавлять предметы и применять скидку. Данные и поведение живут вместе, инкапсуляция работает, сервисы превращаются в тонкие оркестраторы. Объекты — во главе вашего дизайна.

В статье порассуждаем, почему всё не так просто, как реальность иногда вмешивается в стройную теорию и что с этим можно сделать с примером реализации на PHP.

The good, the bad, the ugly. Order not specified

The good, the bad, the ugly. Order not specified

Конечно, у Фаулера речь шла о Domain Model. Entity — термин Эванса из DDD: объект с идентичностью, основа домена.

Дальше терминология упрощается сознательно, но бесповоротно, и мы будем говорить об ORM Entity как о том объекте, который одновременно представляет состояние и играет роль domain object. Отчасти потому, что инфраструктура уже «схлопнула» две концепции ранее. Во многих ORM работа с одной таблицей предполагает одну основную entity-модель.

Не раз и не два на проектах наблюдалась такая картина: модель становится entity (спасибо, ORM), entity разрастается вместе с таблицей (хм, спасибо ORM), и спор про Anemic vs Rich идёт уже не про модель, а про строку в базе. А тут уже как будто не до этого.

Rich entities — звучит красиво, а на деле…

Как это выглядит на деле

Возьмём Order — частый пример из DDD. Используется много где: в корзине, в личном кабинете, в бэкофисе, в интеграции с провайдером доставки, в биллинге и уведомлениях.

На старте проекта выглядит аккуратно:

class Order
{
    public function addItem(Item $item): void { ... }
    public function calculateTotal(): Money { ... }
}

Дальше приходит задача: в бэкофисе менеджер должен уметь менять статус заказа и видеть причину отмены. Куда класть логику? По заветам DDD — в Order, это же Rich entity.

public function markAsCancelled(CancellationReason $reason): void { ... }

Через полгода нужна интеграция с провайдером доставки. Генерация трек-номера, статус посылки, вебхуки об изменении локации.

public function attachTrackingNumber(string $number): void { ... }
public function updateShipmentStatus(ShipmentStatus $status): void { ... }

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

public function applyLoyaltyPoints(int $points): void { ... }
public function recalculateAfterPartialRefund(): void { ... }

Каждое отдельное решение выглядело вполне логично. Поведение живёт рядом с данными, никто не пишет сервис ради двух строк логики. Но открыв класс Order по итогу, мы видим пятнадцать методов, три вложенных объекта-значения и комментарий «TODO: разделить это когда-нибудь». Order знает о корзине, о доставке, о лояльности, о биллинге и совсем чуть-чуть об уведомлениях.

Формально Rich domain model. По факту — тот самый God Object, только как будто оправданный.

One to rule them all

One to rule them all

Здесь обычно происходит откат к анемичной модели.

Кто-то не выдерживает и выносит recalculateAfterPartialRefund обратно в RefundService, потому что видеть эту логику внутри сущности Order, которую дергает корзина, уже физически неприятно. И вот двадцать методов назад класс из простых геттеров и сеттеров был антипаттерном, а теперь — вроде разумный компромисс. Но на самом деле…

Почему так происходит на самом деле

Проблема в том, что если следовать подходу с Rich Entity достаточно долго на достаточно большом проекте, получается не богатая модель, а её злая двойняшка — раздутый объект, который знает и умеет сильно больше любого конкретного сценария.

Ирония в том, что оба состояния — анемичное и раздутое — выглядят как провал одного и того же совета.

Давайте отбросим ретроспективные нападки на команду: дескать, плохо провели границы контекстов на старте. Про это есть другие статьи. На всякий случай оговоримся, что иногда всё немного иначе. В отдельных случаях — сильно иначе.

В типичном ORM таблица orders имеет одну изменяемую entity. Вокруг неё можно строить проекции и DTO, а некоторые ORM технически допускают и несколько отображений одной таблицы. Но поддержка такого сценария со стороны библиотеки рано или поздно кончается. Какая-то часть библиотеки оказывается не предназначена под такой «своеобразный» сценарий.

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

Unit of Work, optimistic locking, кэш, генерация схемы и миграций должны понимать, когда объекты являются разными представлениями одного физического состояния. В большинстве ORM это не базовый сценарий и в лучшем случае требует ручной инфраструктуры. Не все команды на это готовы.

Анемичная модель в этом смысле уже даже не признак каких-то проблем дизайна. Это защитная реакция в ситуации, где сущность физически не может быть узкой ровно под конкретную задачу. Со временем это всё въедается в подкорку и многие опытные коллеги не задумываясь выдают подход «геттеры и сеттеры» как единственно правильное решение для сущностей ещё на старте проекта.

Классический (I)DDD-ответ на эту же дилемму другой: развести таблицы по контекстам и синхронизировать доменными событиями. У Cart — таблица, у Backoffice — другая, у Shipping — третья. Подход иногда корректный и в литературе по DDD описан хорошо. Но цена у него всё же высока. Подробнее разбиралось в моей прошлой статье, ссылка есть по тексту ниже.

Получается странный выбор. Следовать совету буквально — Order разрастается чуть ли не до модуля и работать с ним всё сложнее. Не следовать совету — реальная логика уезжает в сервисы, а класс действительно превращается в мешок с геттерами, и тогда уже перед Фаулером как-то неловко. Наверное, надо назваться гексагональной архитектурой и можно жить дальше. Но давайте всё же поищем решение…

В поисках решения

Как мы убедились, размер entity — это иногда не архитектурное решение, а инфраструктурное ограничение, которое либо опускается, либо выдаётся за первое.

... Что? Да.

В статье про Articulate уже говорилось, что эта ORM как раз вводит возможность «одна таблица = несколько сущностей». Этот же механизм неожиданно выглядит и возможным решением дилеммы Anemic vs Rich.

Вместо одного Order на таблицу orders можно завести несколько «срезов», каждый — под конкретный контекст/модуль:

#[Entity(table: 'orders')]
class CartOrder
{
    #[Column] private array $items;
    #[Column] private Money $subtotal;

    public function addItem(Item $item): void { ... }
    public function calculateTotal(): Money { ... }
    public function cancel(): void { ... }
}

#[Entity(table: 'orders')]
class BackofficeOrder
{
    #[Column] private OrderStatus $status;
    #[Column] private ?CancellationReason $cancellationReason;

    public function markAsCancelled(CancellationReason $reason): void { ... }
}

#[Entity(table: 'orders')]
class ShipmentOrder
{
    #[Column] private ?string $trackingNumber;
    #[Column] private ShipmentStatus $shipmentStatus;

    public function attachTrackingNumber(string $number): void { ... }
    public function updateShipmentStatus(ShipmentStatus $status): void { ... }
}

Три entity, одна таблица. Каждая вполне себе Rich в терминах DDD и доменных моделей: поведение живёт рядом со своими данными. И эти данные — ровно те, что нужны конкретному функционалу, а не всё, что только есть в строке. ShipmentOrder «не видит» items, поэтому физически не может обрасти логикой расчёта корзины. CartOrder «не видит» trackingNumber, поэтому логистика туда не просочится, просто потому что «ну поле же тут рядом лежит».

Через тернии к звёздам

Через тернии к звёздам

Совет «кладите поведение туда же, где данные» начинает работать так, как задумывался. Раньше правильный по форме и вредный по факту, этот совет даже трактовался некоторыми как анти-паттерн (!). Потому что «там же, где данные» означало «в самом широком возможном месте». Здесь же «там же, где данные» означает «в месте ровно того размера, который нужен этой задаче».

При этом не требуется проектировать границы контекстов или таблицы как-то сильно иначе. Иногда проектирование таблицы БД решает задачи за рамками дизайна домена. Если появляется реальная потребность в узкой модели, то инфраструктура просто перестаёт быть причиной от неё отказаться.

CartOrder и BackofficeOrder живут в одном приложении, в одной кодовой базе, под одной схемой: колонка status значит ровно одно, владелец у неё один (хотя смотреть на неё может несколько моделей), и меняется она в одном месте. Это не два сервиса, интегрирующихся через БД, здесь мы работаем в одной транзакционной границе. Поэтому и инфраструктурную цену за всякие кросс-контекстные инварианты можно не платить, Articulate постарается упаковать все изменения в минимум запросов:

$backofficeOrder = $this->backofficeRepo->find(3);
$backofficeOrder->markAsCancelled('Cancel by client request');
...
// other method
$cartOrder = $this->cartOrderRepo->find(3); // same row in database
$cardOrder->cancel();

...
// and finally:
$this->entityManager->flush();

// Result SQL: UPDATE orders SET subtotal = 0, status = 'canceled' WHERE id = 3;

Конечно, это не отменяет самого главного...

Что это не отменяет

Articulate не решает проектирование домена. Если провести границу между CartOrder и BackofficeOrder неправильно — например раскидать логику пересчёта скидки по обеим — получится та же каша, только в профиль. Разделение по таблицам-контекстам всё ещё требует думать, какое поведение кому принадлежит, библиотека лишь убирает ситуацию, где единственный доступный вариант — положить всё в одно место, потому что технически возможен только один вариант «положить всё в одно место», ну вы поняли.

Также библиотека не принуждает к такому подходу. Для небольшого проекта, где Order действительно используется в одном-двух сценариях, разделение на несколько entity лишнее. Rich model в изначальном смысле Фаулера прекрасно работает, когда контекст один и он совпадает с таблицей. Articulate здесь выполняет роль… такой же ORM, как и любая другая. Если в будущем понадобится несколько представлений — библиотека это предоставит. Если нет — будет работать, как и работала.

Внедрение такого механизма работы с таблицами привносит с собой некоторые особенности, о чём можно поговорить подробнее в другой раз, здесь же давайте пока завершать…

Завершение

Тот самый Order, разрастающийся до пятнадцати методов, я видел не в одном проекте — обычно ближе к тому моменту, когда «TODO: разделить это» уже пару лет как в коде. И каждый раз выход был как будто очевиден: завести узкую entity для того единственного контекста, которому она нужна, и не тащить в него всё остальное. Мешал инструмент. Всевозможные single-table inheritance требуют дискриминатора и иерархии классов там, где они не были нужны. Дайте просто два независимых writable-представления одной строки. Articulate в том числе вырос из этой конкретной просьбы.

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

Anemic domain model в таком свете — не следствие лени или недостатка дисциплины, а рациональная реакция на entity, которая физически шире, чем контекст, которому она должна служить. God Object никто особо не любит.

Если же дать entity возможность быть ровно того размера, который нужен конкретному контексту, у изначального совета Фаулера наконец-то появляется шанс сработать так, как задумывалось.

Ссылка на репозиторий проекта: https://github.com/articulate-orm/core

Демо с примерами и описанием: https://github.com/articulate-orm/demo

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.