AI не всегда генерирует анемичные модели. В этом и проблема
Содержание
TL;DR: AI не выбирает одну архитектуру "по умолчанию". Он продолжает сигналы из задачи и репозитория, а недостающую семантику заполняет предположениями. Rich model локализует решения, но только типы, тесты, правила и ревью делают архитектурное намерение воспроизводимым.
Я хотел поймать AI на анемичной модели.
План был почти идеальный: беру одну задачу про отмену бронирования деловой поездки, прогоняю через несколько моделей и получаю знакомый результат: Booking с полями, BookingService со всей логикой, событие где-нибудь в конце метода. Потом показываю несколько одинаковых ответов и говорю: "Вот среднее по индустрии".
Не получилось.
Первый промпт начинался так:
Напиши C# 14/.NET 10 код для доменной модели бронирования деловой поездки.
Дальше шли статусы, сегменты, штраф за отмену, валюта и BookingCancelled. Все 8 запусков с разными моделями получились domain-centric по вполне наблюдаемым признакам: Status с закрытым setter, Cancel внутри Booking, событие создаёт сама модель, отдельного BookingService нет.
Эксперимент отказался подтверждать тезис. И хорошо, потому что второй заход оказался полезнее первого.
Я переписал задачу в стиле типичного тикета из бэклога:
Нужно добавить отмену бронирования деловой поездки. Напиши C# код основных классов и сервиса приложения.
Бизнес-правила остались узнаваемыми, но архитектурные сигналы изменились. И теперь ответы разъехались. Один отдал решение с BookingService, другой построил настоящий rich aggregate и явно разрешил отменять Draft, третий выбрал другой инвариант, добавил Money, политику отмены и контракт атомарного сохранения.
Сравнение не годится на роль A/B-теста одного слова. Промпты различались версией C#, валютой, количеством заданных ограничений и прямой просьбой показать application service. Оба, кстати, оставляли вопрос про отмену Draft недоопределённым. Значит, я не могу честно сказать: "Убрал слово доменная, и архитектура сломалась".
Зато могу сказать другое.
AI продолжает архитектурное намерение, которое сумел распознать. А там, где намерение не записано, достраивает его сам.
Точные промпты, все 16 сырых ответов и методологию эксперимента я выложил отдельным архивом на GitHub.
Что показали две постановки одной задачи
Для подробного разбора я выбрал три контрастных ответа из восьми запусков второго промпта. В публичном тексте назову их A, B и C: задача статьи не в сравнении брендов и не в очередном leaderboard.
| Ответ | Где живёт решение | Что сделано хорошо | Что осталось предположением |
|---|---|---|---|
| A | В BookingService | Короткий, понятный happy path | Запрет отмены Draft, работа со временем, отсутствие атомарной публикации |
| B | В Booking.Cancel | Закрытый статус, тонкий application service, явно названа проблема outbox | Отмена Draft разрешена и штрафуется по общему правилу |
| C | В Booking и отдельной policy | Money, закрытое состояние, доменные события, атомарный контракт сохранения | Draft запрещён, начавшаяся поездка штрафуется на 100% |
Здесь есть ещё одна ловушка. Все восемь ответов на первый промпт прошли мои грубые структурные критерии: закрыли setter, положили Cancel внутрь Booking, создали событие. Но "похожий на rich model" код не стал от этого одинаково надёжным. Один ответ наружу отдавал изменяемый List<TripSegment>, другой читал системное время внутри агрегата, третий смешивал расчёт штрафа и инфраструктурные детали.
Более того, формулировка "подтверждённое бронирование можно отменить" не означала "только подтверждённое". Все восемь запусков сами усилили её до запрета отмены из Draft. Они сошлись в архитектурной форме и одновременно одинаково достроили отсутствующее правило.
Ключевые слова хорошо вызывают знакомый набор паттернов. Они не заменяют разговор о семантике.
Ответ A выглядит ровно как код, с которого обычно начинается анемичная модель. Booking хранит данные, а сервис проверяет статус, ищет первый сегмент, считает штраф, меняет состояние и собирает событие. Код читается без усилий. Для небольшого юзкейса это даже приятно.
Проблема становится видна не в этом методе, а в следующем. Потом ещё в одном.
Когда появится невозвратный тариф, проверка ляжет в тот же сервис. Частичная отмена сегмента тоже. Затем понадобится предпросмотр для UI, и формула переедет на фронт. Биллинг попросит объяснить сумму возврата, интеграция с поставщиком принесёт собственные статусы. Через несколько итераций CancelBooking будет отвечать сразу на вопросы разных частей бизнеса.
Ответ B устроен иначе. Booking сам меняет свой статус и возвращает BookingCancelled; application service только загружает агрегат, вызывает операцию, сохраняет результат и публикует событие. Это уже rich model, без скидок на "почти".
Но B разрешил отменять Draft.
Важно, что модель не спрятала это решение. Она отдельно написала: черновик отменяется по общему правилу, а если бизнес хочет бесплатную отмену, нужно добавить проверку. Хорошее инженерное поведение: предположение вынесено туда, где ревьюер его заметит.
Ответ C выбрал противоположную семантику. Отмена разрешена только для Confirmed; Draft и повторная отмена вернут доменную ошибку. Вокруг решения появились Money, CancellationPolicy, очередь событий и unit of work, который обязан сохранить состояние вместе с событиями атомарно.
C выглядит убедительнее. Это ещё не делает его бизнес-правило верным.
Оба rich-ответа локализовали решение и были вынуждены придумать недостающую семантику. Ни один не получил это правило от бизнеса. Полезная разница в другом – насколько явно ответ показал догадку и насколько легко человеку её проверить.
Даже заданная сетка штрафов оставила пространство для решений. Все три ответа согласились, что 30 и 14 дней входят в диапазон 50%, но по-разному обращались со временем. A трижды читал UtcNow внутри одного use case. Если вызов попадёт на границу календарного дня, проверка "поездка уже началась" и расчёт daysUntilDeparture теоретически могут увидеть разные даты. B перешёл на DateOnly, C отдельно зафиксировал календарные UTC-дни.
Ещё интереснее поездка, которая уже началась. A запрещает отмену. B применяет штраф 100%. C тоже выбирает 100% и называет это решением. Generic-промпт не определял ни один из этих вариантов.
Такие расхождения легко пропустить, если оценивать ответы по количеству Value Objects и красоте Aggregate. Но именно они определяют поведение на проде, а не расположение метода Cancel.
Теперь я читаю AI-ответ в два прохода. Сначала смотрю на архитектурный профиль: кто владеет переходом, можно ли обойти агрегат, где живёт policy, кто создаёт событие. Это быстро отделяет service-centric решение от rich model, но ничего не говорит о правильности правила.
Во втором проходе собираю реестр предположений (техника assumption ledger). Какие статусы разрешены? Как трактуются границы диапазона? Что происходит после начала поездки? Откуда берётся текущее время? Событие означает запрос или уже свершившийся факт? Если ответ принял решение без данных из задачи, оно должно оказаться в этом списке независимо от того, насколько красиво написан код.
Такой порядок защищает от простой когнитивной ловушки. После DTO и сервиса ответ C с Money, policy и unit of work выглядит как победитель. Но его главный domain choice имеет тот же источник, что у B: модель заполнила пустое место. Инфраструктурная полнота помогает реализовать выбранное правило надёжно. Она не подтверждает само правило.
На уровне команды это похоже на вывод DORA 2025: AI усиливает уже существующую социотехническую систему. Зрелые feedback loops, automated testing и loosely coupled architecture помогают переварить выросший объём изменений; слабые элементы управления превращают тот же рост в нестабильность. Отчёт не исследует богатые модели и не доказывает причинность для отдельного класса. Но системная рамка полезна: AI не чинит способ работы команды своим присутствием. Он делает последствия этого способа заметнее и быстрее.
Как одно правило отмены разъезжается по системе
Сразу скажу: дальше не реконструкция одного тикета. Я видел части этой проблемы в разных travel-системах, но точно не припоминаю случай, где всё проявилось одновременно и сохранилось в цифрах. Поэтому соберу повторявшуюся механику в один обезличенный составной сценарий.
Исходная модель выглядит так: Booking хранит поездку, сегменты, статус и сумму. Для отмены действует простая сетка:
- больше 30 дней до поездки – штраф 0%;
- от 14 до 30 дней включительно – 50%;
- меньше 14 дней – 100%.
Такое правило легко написать несколько раз. UI считает предварительный возврат, чтобы пользователь понимал последствия. Бэкенд повторяет формулу при нажатии "Отменить". Поставщик сообщает фактические условия тарифа во время cancellation request. Биллинг создаёт возврат после подтверждения.
Пока тарифы совпадают с общей сеткой, система выглядит согласованной.
Потом один сегмент перевыпускают в невозвратный тариф.
Ситуация:
Пользователь открывает форму отмены за двадцать дней до поездки и видит возврат 50%. UI знает дату, но не знает, что один сегмент стал невозвратным. Бэкенд применяет общую формулу и переводит Booking в
Cancelled. Поставщик отвечает штрафом 100%, поэтому биллинг не создаёт возврат. Расхождение становится видимым после обращения пользователя в поддержку.
Саппорт видит Cancelled. Для пользователя это означает "отменено, жду деньги". Для бэка – "мы приняли команду". Для интеграции от GDS – "запрос отправлен, ответ получен". Для биллинга – "обязательство вернуть деньги не возникло".
Один статус отвечает сразу на четыре разных вопроса и поэтому не отвечает толком ни на один.
Чтобы объяснить ситуацию, саппорт вручную сопоставляет превью из UI, лог вызова бэка, сырой ответ поставщика и состояние возврата. Исправить конкретный if можно быстро. Система от этого не станет согласованной: следующий особый тариф создаст другую развилку, а частичная отмена добавит ещё одну.
Здесь легко сделать неправильный вывод: вынести формулу в общую библиотеку кода и подключить её во всех местах. DRY будет доволен, система – не обязательно. UI считает предварительную оценку, поставщик подтверждает фактический штраф, биллинг отвечает за деньги. У них разные источники данных и разный момент принятия решения.
Нужны явные владельцы:
| Модель | На какой вопрос отвечает |
|---|---|
Booking | Можно ли запросить отмену в текущем локальном состоянии? |
CancellationQuote | Сумма подтверждена поставщиком или пока является предварительной? До какого момента она действует? |
| Cancellation workflow | Запрос находится в Requested, Confirmed или Rejected? |
| Supplier integration | Как внешний статус переводится в контракт нашего workflow? |
Refund | Возникло ли обязательство вернуть деньги и исполнено ли оно? |
Rich model защищает локальный инвариант. Распределённая отмена всё равно требует явных границ и состояний процесса.
В статье про тактические паттерны DDD я уже разбирал анемичную модель, Aggregate и CancellationPolicy подробнее. Там же была важная оговорка: Aggregate – граница транзакционной консистентности, а не контейнер для всей реальности вокруг Booking. Здесь она становится центральной. Если подтверждение поставщика и возврат живут в разных транзакциях, запихнуть их внутрь одного "богатого" объекта не получится без новой лжи в модели.
Что rich model меняет в коде
Мартин Фаулер описывает Anemic Domain Model как модель, где объекты носят правильные доменные имена и связаны между собой, но почти не содержат поведения. Решения живут в сервисах, которые вычисляют результат и меняют объекты-контейнеры. Команда платит стоимость Domain Model и маппинга, а по факту получает Transaction Script.
Из этого не следует, что любой service-centric код надо срочно переделывать. Для загрузки файлов, рассылки уведомлений или простого CRUD Transaction Script часто честнее и дешевле. Rich model окупается там, где появляются изменяемые инварианты, недопустимые состояния и одно правило начинает размножаться между сценариями использования.
Критерий не в количестве методов у Entity. Смотрите на цену рассогласования.
Вернёмся к ответу A. Если убрать инфраструктурный шум, его решение выглядит примерно так:
public sealed class Booking
{
public BookingId Id { get; init; }
public BookingStatus Status { get; set; }
public Money TotalCost { get; init; }
public DateOnly FirstDeparture { get; init; }
}
public sealed class BookingService
{
public BookingCancelled Cancel(Booking booking, DateOnly today)
{
if (booking.Status != BookingStatus.Confirmed)
throw new DomainRuleException("Booking must be confirmed.");
var penalty = CancellationPolicy.Calculate(
booking.TotalCost, booking.FirstDeparture, today);
booking.Status = BookingStatus.Cancelled;
return new BookingCancelled(booking.Id, penalty);
}
}
Метод короткий. Названия нормальные. В нём нет ни god object, ни четырёх уровней наследования, ни фабрики фабрик. Тем он и опасен как шаблон: решение о допустимом переходе принадлежит сервису, а состояние Booking можно поменять в обход этого решения.
Первый шаг к rich model очевиден: закрыть состояние и перенести переход внутрь агрегата. Но production-сценарий заставляет задать вопрос, которого не было в промпте: отмена происходит сразу или сначала запрашивается у поставщика?
В учебном примере можно написать Booking.Cancel и сразу создать BookingCancelled. В реальной интеграции это преждевременный факт. Если поставщик ещё может отказать, доменное событие должно говорить о запросе, а финальный переход произойдёт после подтверждения.
public sealed class Booking
{
private readonly List<IDomainEvent> _events = [];
public BookingId Id { get; }
public BookingStatus Status { get; private set; }
public void RequestCancellation(CancellationQuote quote)
{
if (Status != BookingStatus.Confirmed)
throw new DomainRuleException("Booking must be confirmed.");
Status = BookingStatus.CancellationPending;
_events.Add(new BookingCancellationRequested(Id, quote));
}
public void ConfirmCancellation(SupplierCancellation confirmation)
{
if (Status != BookingStatus.CancellationPending)
throw new DomainRuleException("Cancellation was not requested.");
Status = BookingStatus.Cancelled;
_events.Add(new BookingCancelled(Id, confirmation.Penalty));
}
}
Теперь BookingCancelled означает факт, а не оптимистичное намерение. Supplier adapter переводит внешний ответ в SupplierCancellation; workflow вызывает ConfirmCancellation; биллинг реагирует на финальное событие и создаёт Refund, если возврат больше нуля.
Кода стало больше. Это нормальная цена более точной модели, но платить её стоит только там, где процесс действительно асинхронный. Если отмена локальная и не зависит от поставщика, обычного Cancel достаточно. Rich model не должна соревноваться в количестве церемоний.
Ещё один неприятный момент: перенос метода внутрь класса не создаёт доменное знание. Можно красиво инкапсулировать неверное правило и получить архитектурно убедительный баг. Поэтому после подтверждения бизнеса инвариант нужно записать так, чтобы следующий агент не додумал его заново.
Например, если команда решила, что отмену можно запрашивать только из Confirmed, самое дешёвое исполняемое ограничение для модели (executable rail) выглядит так:
public static void DraftBookingCannotRequestCancellation()
{
var booking = Booking.CreateDraft();
try
{
booking.RequestCancellation(CancellationQuote.Estimated(50));
}
catch (DomainRuleException)
{
return;
}
throw new Exception("Draft booking accepted cancellation.");
}
Без тестового фреймворка, моков и контейнера. Тест фиксирует один вопрос, который модели B и C решили по-разному.
У доменного события граница другая. Сам факт внутри агрегата ещё не гарантирует доставку во внешний брокер. Если публикация асинхронная, изменение состояния и outbox-запись должны коммититься в одной транзакции. У ответа C был такой контракт на уровне unit of work, но не готовая реализация outbox. Это различие легко потерять за словом "атомарно".
Три слоя rails вокруг доменной задачи
Слово "rails" удобно тем, что не обещает самостоятельного мышления от файла с инструкциями. Рельсы не выбирают пункт назначения. Они не дают составу тихо уехать в поле.
1. Types и код показывают допустимую форму
BookingStatus лучше string не потому, что enum выглядит солиднее. Он сужает множество состояний. Закрытый setter не гарантирует верный переход, но заставляет пройти через именованный метод. Money не знает правил возвратов, зато при проверке валюты не позволяет случайно сложить RUB и EUR как два decimal.
Types делают ошибку труднее выразить и заметнее на diff. Этого уже много.
Соседний код работает как few-shot. Если агент видит пять агрегатов с закрытым состоянием, domain events и тонкими application services, шестой естественно продолжит этот стиль. Если вокруг DTO с публичными setters и *Service на все случаи жизни, именно они становятся нормой.
Здесь есть условие, которое часто пропускают: агент должен получить релевантный код в контекст. Наличие хорошего примера где-то в монорепо само по себе ничего не даёт.
Anthropic описывает context engineering шире prompt engineering: в контекст входят system instructions, tools, данные, история сообщений и материалы, которые агент извлекает во время работы. Контекст конечен, поэтому цель не в том, чтобы загрузить весь репозиторий. Нужен минимальный набор high-signal информации, достаточный для решения.
Для кодовой базы это: понятные пути, хорошие имена и документация, которую можно найти "just in time". Репозиторий становится не гигантским системным промптом, а навигационной системой.
2. Tests и analyzers дают обратную связь
Текстовое правило можно проигнорировать. Красный тест сложнее не заметить.
Для сценария отмены полезны проверки на конкретных границах:
Draftне принимает запрос на отмену, если бизнес утвердил именно этот инвариант;- 31 день даёт 0%, 30 и 14 – 50%, 13 – 100%;
- повторное подтверждение не создаёт второй
BookingCancelled; Refundне появляется до подтверждения поставщика.
Тест не гарантирует правильную архитектуру. Он сокращает пространство ответов и показывает, когда агент нарушил уже известное решение.
Analyzers и structural tests закрывают другой класс ограничений:
- у Aggregate нет публичных setters;
- application layer не меняет domain state напрямую;
- domain layer не зависит от infrastructure;
- событие финальной отмены создаётся только после подтверждения workflow.
Это механические правила. Не надо тратить человеческий ресурс на ревью на то, что способен проверить build.
В case study OpenAI про agent-first repository команда пришла к похожей конструкции: короткий AGENTS.md служит картой, версионированная дока остаётся источником правды, а кастомные линтеры и структурные тесты кодируют архитектурные ограничения. Особенно показательно другое наблюдение авторов: кодинговые агенты воспроизводят уже существующие паттерны, включая неудачные. Поэтому хорошие примеры без исполняемых границ со временем тоже размываются.
Это опыт одного внутреннего продукта, а не универсальный бенчмарк. Но для rails уровня репозитория он полезнее советов "напишите промпт подробнее", потому что показывает связку documentation, discoverability и feedback loops.
3. Rules и review называют неизвестное
Rules-файл нужен для решений, которые невозможно надёжно вывести из типов:
# Domain model
- Aggregates own their state transitions.
- Cancellation follows Requested → Confirmed | Rejected.
- UI never calculates refund from local dates alone.
- Domain assumptions must be listed in the response.
- If events publish asynchronously, Aggregate changes and outbox records commit atomically.
- Domain code does not depend on infrastructure.
Такой файл остаётся коротким. Если превратить его в 40 страниц "всего важного", агент перестанет отличать инвариант от исторической справки, а команда перестанет обновлять документ.
И всё равно остаётся человеческое ревью.
Модели B и C написали хороший rich-код, но выбрали противоположные правила для Draft. Ни type system, ни analyzer не скажет, какое из них верное. Ревьюер должен спросить:
- можно ли отменять
Draft; - что считается первым сегментом;
- включены ли ровно 14 и 30 дней;
- можно ли отменять поездку после её начала;
- когда локальная отмена считается завершённой;
- кто подтверждает сумму возврата;
- в какой момент появляется
BookingCancelled.
Код-ревью для AI-кода всё меньше похож на проверку синтаксиса и всё больше – на проверку предположений.
Где fast mode уместна
Ответ A не был "плохой моделью". Он сделал то, чего от быстрого режима обычно и ждут: выдал короткое правдоподобное решение в знакомом индустриальном стиле.
Вопрос в цене ошибки.
| Задача | Цена скрытой ошибки | Чем быстро проверить | Разумный режим |
|---|---|---|---|
| Boilerplate, маппинг, тестовые данные | Низкая | Компилятор и локальные тесты | Fast mode |
| Механический систематический рефакторинг | Средняя | Diff и хороший test suite | Fast mode под контролем |
| Статусы, деньги, доменные инварианты | Высокая | Проверка предположений и сценариев | Сильная модель + rails + ревью |
| Публичные контракты, миграции, outbox, безопасность | Очень высокая | Ошибка часто проявляется поздно | Явный дизайн + сильная модель + ответственное ревью |
Fast mode оптимизирует время до правдоподобного ответа. Для маппинга DTO это отличный компромисс. Для перехода состояния, который запускает внешний возврат, правдоподобность опасна: код компилируется, happy path проходит, а ошибка ждёт первого особого тарифа.
Выбор модели тоже является rail, только дорогим и последним. Если задача сформулирована двусмысленно, в репозитории нет примеров, а тесты проверяют только happy path, более сильная модель даст более убедительную догадку. Иногда этого хватает, чтобы ошибка прожила дольше.
Эксперимент изменил вопрос
Я начинал с удобного тезиса: AI генерирует анемичные модели, потому что усредняет индустрию. Такой текст легко написать и приятно обсуждать.
Эксперимент его не подтвердил. Явная доменная постановка привела все 8 запусков к rich-shaped коду. Обычный тикет дал разные профили. А два выбранных rich-ответа предложили противоположную семантику там, где бизнес-правило не было записано.
Получился менее эффектный, но более полезный вывод.
Service-centric код размазывает решения и создаёт естественное место для следующего if. Rich model собирает локальные инварианты вокруг состояния и делает предположения видимее. Но ни Aggregate, ни самый дорогой reasoning mode не создают знание, которого нет в задаче и у команды.
Качество следующей генерации начинается до открытия чата. Оно уже записано в types, tests, соседнем коде, документации, analyzers и правилах ревью. Либо не записано нигде.
Самый опасный AI-ответ может выглядеть аккуратно, типизированно и архитектурно убедительно. Rails нужны, чтобы неподтверждённое правило стало заметно раньше релиза в прод.
Обсуждаем в моём телеграм-канале.