Божественный объект и анемичная доменная модель
Два структурных антипаттерна тянут в противоположные стороны: божественный объект знает и делает всё, а анемичная модель — это классы-данные без поведения. Лекарство от одного не должно стать другим: перенеси поведение к его данным, распределив по связным объектам.
Двое ревьюеров смотрят на один и тот же бэкенд. Один говорит: «этот OrderService — божественный объект, в нём 2000 строк и он трогает всё». Второй смотрит на класс Order, с которым тот работает, и говорит: «но Order анемичен — это просто геттеры и сеттеры, никакой логики в нём вообще нет». Оба правы, и они описывают одну и ту же проблему с двух концов.
Эти два антипаттерна — парные. Божественный объект держит всё поведение; анемичная модель держит все данные; и линия между ними — ровно там, где объектная ориентация сломалась. Лечить один наивно — значит породить другой: выкачаешь сервис, чтобы «вылечить» анемию, — и можешь раздуть сущность прямиком в новый божественный объект. Senior-навык — держать оба режима отказа в поле зрения одновременно.
После этого урока ты можешь распознать божественный объект как сингулярность связанности (один класс, от которого зависит всё), распознать анемичную доменную модель как мешки-данные-плюс-процедурный-сервис («ООП только по названию»), объяснить, почему лекарство от анемии — перенос поведения внутрь данных, над которыми оно работает, и — что важнее всего — назвать режим отказа, при котором это лекарство перелетает цель и превращает богатую сущность в следующий божественный объект.
Божественный объект — это разбухание на масштабе архитектуры: единственный класс, который знает и делает всё. Длинный метод — это разбухание внутри одной функции; божественный объект — та же деградация уровнем выше: один класс накапливает обязанности, пока не становится центром системы. Всё обращается к нему, значит всё зависит от него, значит ничего нельзя изменить, не рискнув им.
class OrderService {
createOrder() {/* ... */}
validateInventory() {/* ... */}
chargeCard() {/* ... */}
calculateTax() {/* ... */}
applyDiscount() {/* ... */}
sendConfirmationEmail() {/* ... */}
generateInvoicePdf() {/* ... */}
scheduleShipment() {/* ... */}
refund() {/* ... */}
// ...ещё 60 методов, 2000 строк
}Маркер — не одно лишь число строк, а то, что у класса много несвязанных причин для изменения (налоговое право, платёжный провайдер, шаблон письма, вёрстка PDF). Это сингулярность связанности: изменение в любой одной заботе рискует всеми остальными, и каждому тесту приходится строить всю эту вселенную, чтобы протестировать её уголок.
Анемичная доменная модель — противоположная форма: классы-данные без поведения, и вся логика — в отдельном процедурном слое. Фаулер дал этому имя: объекты, которые суть чистое состояние — геттеры и сеттеры, — а правила, управляющие этим состоянием, живут вне их, в «сервисах». Выглядит объектно-ориентированно (у тебя есть класс Order), но это ООП только по названию: это процедурный код, у которого данные и процедуры растащены по сторонам.
class Order {
status: string;
items: Item[];
discountCents = 0;
// ...только поля + get/set. Ни одного метода, что-либо значащего.
}
// всё настоящее поведение живёт здесь, дотягиваясь ВНУТРЬ данных
class OrderService {
applyDiscount(order: Order, pct: number) {
if (order.status !== "open") throw new Error("closed");
order.discountCents = Math.round(total(order.items) * pct);
}
}Цена: инварианты Order (нельзя скидывать закрытый заказ; скидка не может превышать сумму) защищены не самим Order. Любой код, держащий объект, может выставить discountCents в бессмыслицу. Данные и правила, что держат их валидными, живут в разных файлах, поэтому каждому читателю приходится собирать настоящий объект у себя в голове.
Лекарство — один и тот же принцип: поведение принадлежит данным, над которыми оно работает. Перенеси правило из сервиса внутрь сущности, чтобы объект сам обеспечивал свои инварианты. Это «говори, не спрашивай»: вместо того чтобы спрашивать у заказа его поля и решать снаружи, ты говоришь заказу сделать дело и даёшь ему охранять самого себя.
class Order {
private status: "open" | "closed" = "open";
private items: Item[];
private discountCents = 0;
applyDiscount(pct: number): void {
if (this.status !== "open") throw new Error("cannot discount a closed order");
if (pct < 0 || pct > 1) throw new Error("invalid discount");
this.discountCents = Math.round(this.subtotal() * pct);
}
private subtotal(): number {
return this.items.reduce((s, i) => s + i.priceCents * i.qty, 0);
}
}Теперь инвариант снаружи нерушим: discountCents приватен, и единственный путь его изменить идёт через метод, который проверяет правила. Правило живёт рядом с данными, которые оно ограничивает, — а в этом и весь смысл объекта. Процедурный сервис ужимается; модель становится богатой.
Ловушка: «починка» анемии путём впихивания всей логики в одну сущность воссоздаёт божественный объект. Это режим отказа, который ловит тех, кто выучил «богатая модель = хорошо». Если каждое правило, упоминающее заказ, отправляется в Order, то налоговые таблицы, платёжные шлюзы, рендеринг PDF и планирование отгрузки — все мигрируют в один класс, и ты заново собрал OrderService, только записанный как Order. Ты поменял анемичный мешок-данные плюс божественный сервис на единственную божественную сущность.
// ПЕРЕЛЁТ — анемия «починена» в божественную сущность
class Order {
applyDiscount(pct: number) {/* здесь и место ✓ */}
chargeCard(gateway: PaymentGateway) {/* забота платежей ✗ */}
renderInvoicePdf() {/* забота представления ✗ */}
calculateTax(rates: TaxTable) {/* забота налоговой политики ✗ */}
}Различитель — связность, а не «больше методов на сущности». Поведение принадлежит своим данным, когда оно работает над собственным состоянием этого объекта и защищает его собственные инварианты. applyDiscount читает и ограничивает позиции и статус заказа — оно здесь к месту. chargeCard оркестрирует внешний шлюз; renderInvoicePdf — это представление; calculateTax — политика, которая меняется независимо. Это разные обязанности, и они принадлежат разным связным объектам (объекту-значению Money/Discount, TaxPolicy, PaymentGateway), а не свалены в Order. Целью никогда не были «жирные сущности» — целью было поведение, распределённое по множеству связных объектов, каждый из которых владеет своими данными.
Одна обязанность, три размещения. Начни с анемичной формы — мешок-данные BankAccount и сервис, который дотягивается в него:
class BankAccount {
balanceCents = 0;
frozen = false;
}
class AccountService {
withdraw(acc: BankAccount, cents: number) {
if (acc.frozen) throw new Error("frozen");
if (cents > acc.balanceCents) throw new Error("overdraft");
acc.balanceCents -= cents;
}
}Инварианты («нет снятия с замороженного счёта», «нет овердрафта») не охраняются BankAccount — любой код может написать acc.balanceCents = -999. Теперь перенеси правило внутрь данных, где оно может само себя защищать:
class BankAccount {
private balanceCents = 0;
private frozen = false;
withdraw(cents: number): void {
if (this.frozen) throw new Error("account is frozen");
if (cents > this.balanceCents) throw new Error("insufficient funds");
this.balanceCents -= cents;
}
}Это верное лекарство: поведение, работающее над собственным состоянием счёта, теперь живёт на счёте, и инвариант обеспечивается у единственной двери. Но следи за перелётом — не продолжай и не приделывай всё, что лишь упоминает счёт:
class BankAccount {
withdraw(cents: number) {/* своё состояние ✓ */}
sendMonthlyStatementEmail() {/* забота уведомлений ✗ */}
reportToCreditBureau() {/* забота интеграции ✗ */}
calculateInterestForTaxYear(rates: TaxTable) {/* забота политики ✗ */}
}withdraw здесь к месту, потому что трогает собственный баланс счёта и защищает его собственные инварианты. Остальные три тянут за собой почтовую инфраструктуру, API внешнего бюро и налоговую политику — независимые причины для изменения, — поэтому они принадлежат StatementMailer, CreditReporter и InterestPolicy. Держи BankAccount богатым относительно своих собственных денежных правил и невежественным о чужой работе. В этом и разница между богатой моделью и божественной сущностью.
▸Почему это работает
Почему различитель — «поведение рядом с данными», а не число методов? Потому что реальная цена, которую навязывают оба антипаттерна, одна и та же: читатель не может понять объект в одном месте. В анемичной модели правила разбросаны по сервисам, поэтому ты читаешь три файла, чтобы узнать, что умеет Order. В божественном объекте обязанности разбросаны по одному классу, поэтому ты читаешь 2000 строк, чтобы найти те три, что важны. Связность — каждая единица делает одну родственную работу со своими данными — это то, что держит малыми и стоимость чтения, и радиус поражения изменения. «Богатая модель» — не цель сама по себе; это форма, которая случайно максимизирует связность для логики состояния-и-инвариантов.
▸Частая ошибка
Самая частая ошибка после изучения этого — относиться к «анемичности» как к абсолютному ругательству: выдирать всё поведение в каждую сущность, включая DTO на границе системы и проекции read-модели, которые по-настоящему должны быть тупыми данными. Не всякий класс-данные — это анемичная доменная модель. Транспортному DTO, форме ответа API или CQRS read-модели позволено быть чистыми данными — у них нет инвариантов для защиты. Антипаттерн — это конкретно доменный объект с реальными инвариантами, чьи правила сосланы в сервис. Применяй лекарство к доменным сущностям, которые владеют бизнес-правилами; оставляй пограничные структуры данных простыми. Промахнёшься этим — превратишь чистый слой DTO в клубок неуместной логики.
У команды есть анемичный 'Order' (только геттеры/сеттеры) и 'OrderService' на 1500 строк. Джуниор «чинит анемию», перенося каждый метод из OrderService в Order — включая chargeCard(), renderInvoicePdf() и calculateTax(). Что произошло?
Божественный объект и анемичная доменная модель — парные на одной оси. Божественный объект — это разбухание на масштабе архитектуры: один класс, который знает и делает всё, сингулярность связанности, от которой всё зависит. Анемичная доменная модель — его зеркало: классы-данные с геттерами и сеттерами, но без поведения, все правила сосланы в процедурный сервис, поэтому это ООП только по названию. Лекарство от анемии — один принцип: перенеси поведение в данные, над которыми оно работает, чтобы каждый объект сам обеспечивал свои инварианты («говори, не спрашивай»). Но у лекарства есть режим отказа: впихни каждое правило, упоминающее сущность, в неё — и ты пересоберёшь божественный объект как жирную сущность. Реальный различитель — связность: поведение принадлежит данным, когда трогает собственное состояние этого объекта и защищает его собственные инварианты, а несвязанные обязанности принадлежат другим связным объектам. Цель — никогда не «жирные сущности»; это поведение, распределённое по множеству объектов, каждый из которых владеет своими данными.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.