Формулировка «добавь подписку на задачи» стоила мне вечера ревью. Ответ пришёл рабочий, тесты зелёные, стиль проекта выдержан. Дифф — 640 строк, из которых примерно двести я не заказывал и не хотел.
Это не история про плохую модель. Это история про то, что задача без явно заданных границ разрастается сама.
Что выросло из одной строки
Проект — Symfony 8, модульный монолит, модули Core, Tasks, Docs. Мне нужно было, чтобы пользователь мог подписаться на задачу и получать письмо, когда её статус меняется. В голове это выглядело как таблица связей, подписчик в обработчике события и одно письмо.
Получил я:
migrations/Version20260421084512.php таблица task_subscription
src/Tasks/Entity/TaskSubscription.php
src/Tasks/Repository/TaskSubscriptionRepository.php
src/Tasks/Service/SubscriptionManager.php
src/Tasks/Service/DigestBuilder.php дайджест, которого я не просил
src/Tasks/Service/NotificationPreference.php настройки частоты, тоже нет
src/Core/Entity/UserNotificationSettings.php влез в чужой модуль
src/Tasks/MessageHandler/TaskChangedHandler.php
src/Tasks/Controller/SubscriptionController.php
templates/tasks/subscription/settings.html.twig
+ 6 файлов тестовТри механизма сверх задачи: дайджест раз в день, настройки частоты уведомлений на пользователя и таблица в Core под общие настройки нотификаций. Каждый сам по себе разумный. Вместе они меняют модель данных проекта решением, которое я не принимал.
Отдельно раздражает вот что: отклонить это дороже, чем принять. Код написан прилично, тесты на него есть, откат тянет за собой миграцию. Проще было махнуть рукой. Так в проектах и заводятся подсистемы, происхождение которых через год никто не помнит.
Пять блоков
После третьего такого случая я перестал формулировать задачи в одну строку и завёл шаблон. Пять блоков, по абзацу на каждый, всё вместе помещается на экран.
Цель. Одно предложение о том, что изменится для пользователя. Не «реализовать подписки», а «пользователь нажимает кнопку на задаче и получает письмо, когда меняется статус». Цель формулируется наблюдаемым результатом, потому что по ней потом проверяется готовность.
В объёме. Перечисление файлов, слоёв или сущностей, которых работа касается. Не описание архитектуры — просто список зон.
Вне объёма. Перечисление того, чего в этой задаче быть не должно, даже если оно напрашивается.
Ограничения. Конвенции проекта, применимые к этой задаче: где лежат миграции, через что идут переводы, какой слой не трогаем.
Готово когда. Условие завершения, проверяемое командой или действием, а не суждением.
Пять блоков пишутся минут за пять. Ревью диффа на 640 строк занимает час.
Почему «вне объёма» важнее остальных
Из пяти блоков четыре формулируют работу. Пятый её ограничивает, и именно он экономит больше всего.
Причина в том, как агент достраивает задачу. Он видит подписку и знает по тысячам похожих кодовых баз, что рядом с подпиской обычно живут настройки, дайджест и предпочтения по каналам. Достройка выглядит как забота: пользователь же захочет отключить письма. Отсутствие запрета читается как разрешение, а иногда и как намёк.
Мой список «вне объёма» для той задачи выглядел бы так:
ВНЕ ОБЪЁМА
- Настройки частоты и каналов уведомлений. Пишем письмо сразу, одно на событие.
- Дайджест и любая агрегация.
- Изменения в модуле Core. Таблица и сущность живут в Tasks.
- Отписка по ссылке из письма. Отдельная задача.
- Уведомления по любым событиям, кроме смены статуса.Пять строк. На втором заходе с ними дифф стал 180 строк.
Формулировать «вне объёма» проще, чем кажется. Спросите себя, что бы вы сами дописали, если бы задача была вашей и времени было много. Вот это и запрещайте.
Критерий готовности, который проверяется
Самый бесполезный критерий — «работает корректно». Второй по бесполезности — «покрыто тестами».
Готовность должна проверяться командой или последовательностью действий, у которой есть однозначный исход:
ГОТОВО КОГДА
- make check зелёный.
- Новый тест TaskSubscriptionTest: подписался, статус сменился,
в транспорте одно письмо; отписался — писем нет.
- На странице задачи есть кнопка, её состояние переживает перезагрузку.
- Дифф не трогает src/Core.Последний пункт — тоже критерий готовности, хотя выглядит как ограничение. Такие вещи полезно дублировать: то, что записано в «вне объёма», в конце проверяется командой git diff --stat.
Разница между «покрыто тестами» и описанием конкретного теста — в том, кто выбирает сценарий. В первом случае выбирает исполнитель, и выбирает он тот сценарий, который проще написать зелёным.
Ограничения — это список конвенций, а не пожелания
Блок ограничений сползает в вежливость чаще прочих. «Следуй стилю проекта», «пиши качественный код», «учитывай производительность» — всё это не ограничения, потому что не отсекают ни одного варианта.
Работает перечисление:
ОГРАНИЧЕНИЯ
- Миграции — только классы Doctrine в migrations/, никакого сырого SQL.
- Переводы во всех трёх локалях: en, ru, de. Иначе make check красный.
- Сервис возвращает строку ошибки, не бросает исключение (контракт проекта).
- Контроллер тонкий: логика в сервисе.
- Сущности Tasks не ссылаются на сущности Docs напрямую.Половина этих строк живёт в AGENTS.md, и в контракт задачи их можно не переписывать. Я всё равно дублирую те две-три, которые относятся к задаче вплотную. Файл контекста читается один раз в начале сессии, а на пятидесятом инструменте подряд из него остаётся не всё.
Контракт целиком
Вот как выглядела та же задача на втором заходе.
ЦЕЛЬ
Пользователь подписывается на задачу кнопкой на странице задачи и получает
письмо при смене её статуса. Отписка — той же кнопкой.
В ОБЪЁМЕ
- Модуль Tasks: сущность подписки, репозиторий, сервис, обработчик события
смены статуса, контроллер на две ручки, кнопка в шаблоне задачи.
- Одна миграция Doctrine.
- Один функциональный тест.
ВНЕ ОБЪЁМА
- Настройки частоты и каналов уведомлений.
- Дайджест и любая агрегация.
- Изменения в модуле Core.
- Отписка по ссылке из письма.
- Уведомления по событиям, кроме смены статуса.
ОГРАНИЧЕНИЯ
- Миграции — классы Doctrine в migrations/.
- Переводы en/ru/de.
- Сервис возвращает строку ошибки, не бросает исключение.
- Письмо отправляется через шину сообщений, не синхронно в обработчике.
ГОТОВО КОГДА
- make check зелёный.
- Тест: подписка → смена статуса → одно письмо в транспорте; отписка → ноль.
- Кнопка на странице задачи, состояние переживает перезагрузку.
- git diff --stat не содержит src/Core.Результат: 180 строк, 7 файлов, одна миграция. Ревью занял двадцать минут, из которых десять я потратил на спор с самим собой, правильно ли класть отправку письма в обработчик шины.
Разница между заходами — не в качестве кода. Код в обоих случаях приличный. Разница в том, что второй дифф я целиком понимаю и могу защитить на ревью через полгода.
Где контракт вреден
Есть класс задач, где пять блоков только мешают. Это задачи, в которых я не знаю правильного ответа.
«Разберись, почему страница списка задач тормозит на организации с сорока тысячами записей» — здесь «в объёме» неизвестен. Причина может оказаться в запросе, в шаблоне, в сериализации, в индексе, в жадной загрузке связей. Если я напишу «в объёме: репозиторий задач», я срежу поиск до своей гипотезы. А гипотеза у меня как раз слабая, иначе я бы уже починил.
Для таких задач я оставляю два блока из пяти: цель и ограничения. Цель — «найти причину и показать замеры». Ограничение — «ничего не менять, только диагностика». Всё остальное — простор, ради которого я и зову исполнителя.
Признак, по которому различаю: если я могу сформулировать «вне объёма» — контракт нужен. Если не могу, потому что сам не понимаю границ, — контракт преждевременен, сначала разведка.
Ещё контракт бесполезен на задачах на десять строк. Написать пять блоков ради переименования метода — способ потратить пять минут вместо одной.
Что осталось
Шаблон живёт у меня в заметках и переносится в задачу копированием. Пробовал сделать из него скилл, чтобы срабатывал автоматически, — не прижилось: контракт пишу я, а не агент, и автоматизировать тут нечего.
Чего не хватает — привычки писать «вне объёма» до того, как обжёгся. Первые три пункта в этом блоке у меня всегда появляются задним числом, после того как в диффе обнаружился очередной механизм с настройками, которого никто не просил.